diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 7f1766dd1b68..df1bf12fd2db 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,6 +2,16 @@ - \ No newline at end of file + diff --git a/README.adoc b/README.adoc index 15a75140de89..9cbf0fb87275 100755 --- a/README.adoc +++ b/README.adoc @@ -1,4 +1,4 @@ -= Spring Boot image:https://ci.spring.io/api/v1/teams/spring-boot/pipelines/spring-boot-3.1.x/jobs/build/badge["Build Status", link="https://ci.spring.io/teams/spring-boot/pipelines/spring-boot-3.1.x?groups=Build"] image:https://badges.gitter.im/Join Chat.svg["Chat",link="https://gitter.im/spring-projects/spring-boot?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"] image:https://img.shields.io/badge/Revved%20up%20by-Develocity-06A0CE?logo=Gradle&labelColor=02303A["Revved up by Develocity", link="https://ge.spring.io/scans?&search.rootProjectNames=Spring%20Boot%20Build&search.rootProjectNames=spring-boot-build"] += Spring Boot image:https://ci.spring.io/api/v1/teams/spring-boot/pipelines/spring-boot-3.3.x/jobs/build/badge["Build Status", link="https://ci.spring.io/teams/spring-boot/pipelines/spring-boot-3.3.x?groups=Build"] image:https://badges.gitter.im/Join Chat.svg["Chat",link="https://gitter.im/spring-projects/spring-boot?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge"] image:https://img.shields.io/badge/Revved%20up%20by-Develocity-06A0CE?logo=Gradle&labelColor=02303A["Revved up by Develocity", link="https://ge.spring.io/scans?&search.rootProjectNames=Spring%20Boot%20Build&search.rootProjectNames=spring-boot-build"] :docs: https://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference :github: https://github.com/spring-projects/spring-boot diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 5d9fe4c4b8cc..870f4a7355b8 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -17,11 +17,12 @@ def versions = [:] new File(projectDir.parentFile, "gradle.properties").withInputStream { def properties = new Properties() properties.load(it) - ["assertj", "commonsCodec", "hamcrest", "jackson", "junitJupiter", - "kotlin", "maven", "springFramework"].each { + ["assertj", "commonsCodec", "hamcrest", "junitJupiter", "kotlin", "maven"].each { versions[it] = properties[it + "Version"] } } +versions["jackson"] = "2.15.3" +versions["springFramework"] = "6.0.12" ext.set("versions", versions) if (versions.springFramework.contains("-")) { repositories { @@ -136,4 +137,3 @@ eclipse.classpath.file.whenMerged { jreEntry.entryAttributes['module'] = 'true' jreEntry.entryAttributes['limit-modules'] = 'java.base' } - diff --git a/buildSrc/src/main/java/org/springframework/boot/build/AsciidoctorConventions.java b/buildSrc/src/main/java/org/springframework/boot/build/AsciidoctorConventions.java index 537ff277f739..8fb4d4d9e4e1 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/AsciidoctorConventions.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/AsciidoctorConventions.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -134,7 +134,7 @@ private void configureForkOptions(AbstractAsciidoctorTask asciidoctorTask) { private String determineGitHubTag(Project project) { String version = "v" + project.getVersion(); - return (version.endsWith("-SNAPSHOT")) ? "3.1.x" : version; + return (version.endsWith("-SNAPSHOT")) ? "main" : version; } private void configureOptions(AbstractAsciidoctorTask asciidoctorTask) { diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java index 6cbd5ae787b5..0cc7b4189068 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -291,9 +291,7 @@ private boolean isNodeWithName(Object candidate, String name) { if ((node.name() instanceof QName qname) && name.equals(qname.getLocalPart())) { return true; } - if (name.equals(node.name())) { - return true; - } + return name.equals(node.name()); } return false; } diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/StandardLibraryUpdateResolver.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/StandardLibraryUpdateResolver.java index 517bf262a3a9..f5ffd02cac42 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/StandardLibraryUpdateResolver.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/StandardLibraryUpdateResolver.java @@ -19,14 +19,12 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.SortedSet; import java.util.function.BiPredicate; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -113,19 +111,14 @@ private List determineResolvedVersionOptions(Library library) { getLaterVersionsForModule(group.getId(), plugin, library)); } } - List allVersions = moduleVersions.values() + return moduleVersions.values() .stream() .flatMap(SortedSet::stream) .distinct() .filter((dependencyVersion) -> this.predicate.test(library, dependencyVersion)) - .toList(); - if (allVersions.isEmpty()) { - return Collections.emptyList(); - } - return allVersions.stream() - .map((version) -> new VersionOption.ResolvedVersionOption(version, + .map((version) -> (VersionOption) new VersionOption.ResolvedVersionOption(version, getMissingModules(moduleVersions, version))) - .collect(Collectors.toList()); + .toList(); } private List getMissingModules(Map> moduleVersions, diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/UpgradeDependencies.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/UpgradeDependencies.java index 04934445cd87..19e897cb823d 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/UpgradeDependencies.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/UpgradeDependencies.java @@ -169,8 +169,9 @@ private List verifyLabels(GitHubRepository repository) { if (!availableLabels.containsAll(issueLabels)) { List unknownLabels = new ArrayList<>(issueLabels); unknownLabels.removeAll(availableLabels); + String suffix = (unknownLabels.size() == 1) ? "" : "s"; throw new InvalidUserDataException( - "Unknown label(s): " + StringUtils.collectionToCommaDelimitedString(unknownLabels)); + "Unknown label" + suffix + ": " + StringUtils.collectionToCommaDelimitedString(unknownLabels)); } return issueLabels; } @@ -193,7 +194,7 @@ private Milestone determineMilestone(GitHubRepository repository) { java.util.Optional matchingMilestone = milestones.stream() .filter((milestone) -> milestone.getName().equals(getMilestone().get())) .findFirst(); - if (!matchingMilestone.isPresent()) { + if (matchingMilestone.isEmpty()) { throw new InvalidUserDataException("Unknown milestone: " + getMilestone().get()); } return matchingMilestone.get(); @@ -241,9 +242,9 @@ private boolean isAnUpgrade(Library library, DependencyVersion candidate) { } private boolean isNotProhibited(Library library, DependencyVersion candidate) { - return !library.getProhibitedVersions() + return library.getProhibitedVersions() .stream() - .anyMatch((prohibited) -> prohibited.isProhibited(candidate.toString())); + .noneMatch((prohibited) -> prohibited.isProhibited(candidate.toString())); } private List matchingLibraries() { diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/AbstractDependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/AbstractDependencyVersion.java index 4d17ceefc81f..d0c74aa2da4e 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/AbstractDependencyVersion.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/AbstractDependencyVersion.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,10 +58,7 @@ public boolean equals(Object obj) { return false; } AbstractDependencyVersion other = (AbstractDependencyVersion) obj; - if (!this.comparableVersion.equals(other.comparableVersion)) { - return false; - } - return true; + return this.comparableVersion.equals(other.comparableVersion); } @Override diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/DependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/DependencyVersion.java index f4b9b897a1ba..d82d5b8a50f5 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/DependencyVersion.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/DependencyVersion.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ public interface DependencyVersion extends Comparable { * Returns whether the given {@code candidate} is an upgrade of this version. * @param candidate the version to consider * @param movingToSnapshots whether the upgrade is to be considered as part of moving - * to snaphots + * to snapshots * @return {@code true} if the candidate is an upgrade, otherwise false */ boolean isUpgrade(DependencyVersion candidate, boolean movingToSnapshots); diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersion.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersion.java index c9c79bcdc69b..e43c1b05d9de 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersion.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/bomr/version/ReleaseTrainDependencyVersion.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,22 +64,25 @@ public int compareTo(DependencyVersion other) { @Override public boolean isUpgrade(DependencyVersion candidate, boolean movingToSnapshots) { - if (!(candidate instanceof ReleaseTrainDependencyVersion)) { - return true; + if (candidate instanceof ReleaseTrainDependencyVersion candidateReleaseTrain) { + return isUpgrade(candidateReleaseTrain, movingToSnapshots); } - ReleaseTrainDependencyVersion candidateReleaseTrain = (ReleaseTrainDependencyVersion) candidate; - int comparison = this.releaseTrain.compareTo(candidateReleaseTrain.releaseTrain); + return true; + } + + private boolean isUpgrade(ReleaseTrainDependencyVersion candidate, boolean movingToSnapshots) { + int comparison = this.releaseTrain.compareTo(candidate.releaseTrain); if (comparison != 0) { return comparison < 0; } - if (movingToSnapshots && !isSnapshot() && candidateReleaseTrain.isSnapshot()) { + if (movingToSnapshots && !isSnapshot() && candidate.isSnapshot()) { return true; } - comparison = this.type.compareTo(candidateReleaseTrain.type); + comparison = this.type.compareTo(candidate.type); if (comparison != 0) { return comparison < 0; } - return Integer.compare(this.version, candidateReleaseTrain.version) < 0; + return Integer.compare(this.version, candidate.version) < 0; } private boolean isSnapshot() { @@ -88,10 +91,9 @@ private boolean isSnapshot() { @Override public boolean isSnapshotFor(DependencyVersion candidate) { - if (!isSnapshot() || !(candidate instanceof ReleaseTrainDependencyVersion)) { + if (!isSnapshot() || !(candidate instanceof ReleaseTrainDependencyVersion candidateReleaseTrain)) { return false; } - ReleaseTrainDependencyVersion candidateReleaseTrain = (ReleaseTrainDependencyVersion) candidate; return this.releaseTrain.equals(candidateReleaseTrain.releaseTrain); } @@ -127,10 +129,7 @@ public boolean equals(Object obj) { return false; } ReleaseTrainDependencyVersion other = (ReleaseTrainDependencyVersion) obj; - if (!this.original.equals(other.original)) { - return false; - } - return true; + return this.original.equals(other.original); } @Override diff --git a/buildSrc/src/main/java/org/springframework/boot/build/classpath/CheckClasspathForProhibitedDependencies.java b/buildSrc/src/main/java/org/springframework/boot/build/classpath/CheckClasspathForProhibitedDependencies.java index 548344b731fd..abc1098e8d37 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/classpath/CheckClasspathForProhibitedDependencies.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/classpath/CheckClasspathForProhibitedDependencies.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,10 +100,7 @@ private boolean prohibited(ModuleVersionIdentifier id) { if (group.equals("org.apache.geronimo.specs")) { return true; } - if (group.equals("com.sun.activation")) { - return true; - } - return false; + return group.equals("com.sun.activation"); } } diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/CheckAdditionalSpringConfigurationMetadata.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/CheckAdditionalSpringConfigurationMetadata.java index d45b1d4e9299..cc7bdfd87b20 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/CheckAdditionalSpringConfigurationMetadata.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/CheckAdditionalSpringConfigurationMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -93,7 +93,7 @@ private Report createReport() throws IOException, JsonParseException, JsonMappin @SuppressWarnings("unchecked") private void check(String key, Map json, Analysis analysis) { - List> groups = (List>) json.get(key); + List> groups = (List>) json.getOrDefault(key, Collections.emptyList()); List names = groups.stream().map((group) -> (String) group.get("name")).toList(); List sortedNames = sortedCopy(names); for (int i = 0; i < names.size(); i++) { diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java index d7c8710e2cb4..0e7b5a9ffc6d 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,8 +78,10 @@ void documentConfigurationProperties() throws IOException { snippets.add("application-properties.security", "Security Properties", this::securityPrefixes); snippets.add("application-properties.rsocket", "RSocket Properties", this::rsocketPrefixes); snippets.add("application-properties.actuator", "Actuator Properties", this::actuatorPrefixes); - snippets.add("application-properties.docker-compose", "Docker Compose Properties", this::dockerComposePrefixes); snippets.add("application-properties.devtools", "Devtools Properties", this::devtoolsPrefixes); + snippets.add("application-properties.docker-compose", "Docker Compose Properties", this::dockerComposePrefixes); + snippets.add("application-properties.testcontainers", "Testcontainers Properties", + this::testcontainersPrefixes); snippets.add("application-properties.testing", "Testing Properties", this::testingPrefixes); snippets.writeTo(this.outputDir.toPath()); } @@ -106,6 +108,7 @@ private void corePrefixes(Config config) { config.accept("spring.reactor"); config.accept("spring.ssl"); config.accept("spring.task"); + config.accept("spring.threads"); config.accept("spring.mandatory-file-encoding"); config.accept("info"); config.accept("spring.output.ansi.enabled"); @@ -170,6 +173,7 @@ private void integrationPrefixes(Config prefix) { prefix.accept("spring.integration"); prefix.accept("spring.jms"); prefix.accept("spring.kafka"); + prefix.accept("spring.pulsar"); prefix.accept("spring.rabbitmq"); prefix.accept("spring.hazelcast"); prefix.accept("spring.webservices"); @@ -211,6 +215,7 @@ private void rsocketPrefixes(Config prefix) { private void actuatorPrefixes(Config prefix) { prefix.accept("management"); + prefix.accept("micrometer"); } private void dockerComposePrefixes(Config prefix) { @@ -222,7 +227,11 @@ private void devtoolsPrefixes(Config prefix) { } private void testingPrefixes(Config prefix) { - prefix.accept("spring.test"); + prefix.accept("spring.test."); + } + + private void testcontainersPrefixes(Config prefix) { + prefix.accept("spring.testcontainers."); } } diff --git a/buildSrc/src/main/java/org/springframework/boot/build/docs/ApplicationRunner.java b/buildSrc/src/main/java/org/springframework/boot/build/docs/ApplicationRunner.java index 0f95d55d3d67..785be772544b 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/docs/ApplicationRunner.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/docs/ApplicationRunner.java @@ -105,8 +105,8 @@ public Property getApplicationJar() { } public void normalizeTomcatPort() { - this.normalizations.put("(Tomcat started on port\\(s\\): )[\\d]+( \\(http\\))", "$18080$2"); - this.normalizations.put("(Tomcat initialized with port\\(s\\): )[\\d]+( \\(http\\))", "$18080$2"); + this.normalizations.put("(Tomcat started on port )[\\d]+( \\(http\\))", "$18080$2"); + this.normalizations.put("(Tomcat initialized with port )[\\d]+( \\(http\\))", "$18080$2"); } public void normalizeLiveReloadPort() { diff --git a/ci/README.adoc b/ci/README.adoc index 4601e84e0b13..6b4dcd7d2e92 100644 --- a/ci/README.adoc +++ b/ci/README.adoc @@ -11,7 +11,7 @@ The pipeline can be deployed using the following command: [source] ---- -$ fly -t spring-boot set-pipeline -p spring-boot-3.1.x -c ci/pipeline.yml -l ci/parameters.yml +$ fly -t spring-boot set-pipeline -p spring-boot-3.3.x -c ci/pipeline.yml -l ci/parameters.yml ---- NOTE: This assumes that you have credhub integration configured with the appropriate diff --git a/ci/images/ci-image-jdk21/Dockerfile b/ci/images/ci-image-jdk21/Dockerfile index 777a94f0a731..d5e635377a4a 100644 --- a/ci/images/ci-image-jdk21/Dockerfile +++ b/ci/images/ci-image-jdk21/Dockerfile @@ -7,6 +7,9 @@ ADD get-docker-url.sh /get-docker-url.sh ADD get-docker-compose-url.sh /get-docker-compose-url.sh RUN ./setup.sh java17 java21 +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 ENV JAVA_HOME /opt/openjdk ENV PATH $JAVA_HOME/bin:$PATH ADD docker-lib.sh /docker-lib.sh diff --git a/ci/images/ci-image/Dockerfile b/ci/images/ci-image/Dockerfile index 581c18b9de00..fd9d192ec122 100644 --- a/ci/images/ci-image/Dockerfile +++ b/ci/images/ci-image/Dockerfile @@ -7,6 +7,9 @@ ADD get-docker-url.sh /get-docker-url.sh ADD get-docker-compose-url.sh /get-docker-compose-url.sh RUN ./setup.sh java17 +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 ENV JAVA_HOME /opt/openjdk ENV PATH $JAVA_HOME/bin:$PATH ADD docker-lib.sh /docker-lib.sh diff --git a/ci/images/setup.sh b/ci/images/setup.sh index 2b286d284bd6..f1d15e5149c0 100755 --- a/ci/images/setup.sh +++ b/ci/images/setup.sh @@ -2,12 +2,13 @@ set -ex ########################################################### -# UTILS +# OS and UTILS ########################################################### export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install --no-install-recommends -y tzdata ca-certificates net-tools libxml2-utils git curl libudev1 libxml2-utils iptables iproute2 jq +apt-get install --no-install-recommends -y locales tzdata ca-certificates net-tools libxml2-utils git curl libudev1 libxml2-utils iptables iproute2 jq +locale-gen en_US.utf8 ln -fs /usr/share/zoneinfo/UTC /etc/localtime dpkg-reconfigure --frontend noninteractive tzdata rm -rf /var/lib/apt/lists/* diff --git a/ci/parameters.yml b/ci/parameters.yml index 16e49dffa10c..362da5a78f1a 100644 --- a/ci/parameters.yml +++ b/ci/parameters.yml @@ -7,8 +7,8 @@ docker-hub-repository-prefix: "spring-boot" artifactory-snapshot-repository: "libs-snapshot-local" artifactory-staging-repository: "libs-staging-local" artifactory-url: "https://repo.spring.io" -branch: "3.1.x" -milestone: "3.1.x" +branch: "main" +milestone: "3.3.x" build-name: "spring-boot" concourse-url: "https://ci.spring.io" task-timeout: 2h00m diff --git a/eclipse/spring-boot-project.setup b/eclipse/spring-boot-project.setup index 6cd6605e50d8..2b63bfbc12e9 100644 --- a/eclipse/spring-boot-project.setup +++ b/eclipse/spring-boot-project.setup @@ -11,8 +11,8 @@ xmlns:setup.workingsets="http://www.eclipse.org/oomph/setup/workingsets/1.0" xmlns:workingsets="http://www.eclipse.org/oomph/workingsets/1.0" xsi:schemaLocation="http://www.eclipse.org/oomph/setup/jdt/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/JDT.ecore http://www.eclipse.org/buildship/oomph/1.0 https://raw.githubusercontent.com/eclipse/buildship/master/org.eclipse.buildship.oomph/model/GradleImport-1.0.ecore http://www.eclipse.org/oomph/predicates/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/Predicates.ecore http://www.eclipse.org/oomph/setup/workingsets/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/SetupWorkingSets.ecore http://www.eclipse.org/oomph/workingsets/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/WorkingSets.ecore" - name="spring.boot.3.1.x" - label="Spring Boot 3.1.x"> + name="spring.boot.3.3.x" + label="Spring Boot 3.3.x"> + pattern="spring-boot-(tools|antlib|configuration-.*|loader|loader-classic|.*-tools|.*-layertools|.*-plugin|autoconfigure-processor|buildpack.*)"/> diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge index e9f03a32b594..1bf66fc1bd07 100755 --- a/git/hooks/prepare-forward-merge +++ b/git/hooks/prepare-forward-merge @@ -4,7 +4,7 @@ require 'net/http' require 'yaml' require 'logger' -$main_branch = "3.1.x" +$main_branch = "3.3.x" $log = Logger.new(STDOUT) $log.level = Logger::WARN diff --git a/settings.gradle b/settings.gradle index e088c59ec4c3..8312af44273a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -53,11 +53,13 @@ include "spring-boot-project:spring-boot-tools:spring-boot-autoconfigure-process include "spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform" include "spring-boot-project:spring-boot-tools:spring-boot-cli" include "spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata" +include "spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata-changelog-generator" include "spring-boot-project:spring-boot-tools:spring-boot-configuration-processor" include "spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin" include "spring-boot-project:spring-boot-tools:spring-boot-gradle-test-support" include "spring-boot-project:spring-boot-tools:spring-boot-jarmode-layertools" include "spring-boot-project:spring-boot-tools:spring-boot-loader" +include "spring-boot-project:spring-boot-tools:spring-boot-loader-classic" include "spring-boot-project:spring-boot-tools:spring-boot-loader-tools" include "spring-boot-project:spring-boot-tools:spring-boot-maven-plugin" include "spring-boot-project:spring-boot-tools:spring-boot-properties-migrator" @@ -75,6 +77,7 @@ include "spring-boot-project:spring-boot-test-autoconfigure" include "spring-boot-tests:spring-boot-integration-tests:spring-boot-configuration-processor-tests" include "spring-boot-tests:spring-boot-integration-tests:spring-boot-launch-script-tests" include "spring-boot-tests:spring-boot-integration-tests:spring-boot-loader-tests" +include "spring-boot-tests:spring-boot-integration-tests:spring-boot-loader-classic-tests" include "spring-boot-tests:spring-boot-integration-tests:spring-boot-server-tests" include "spring-boot-system-tests:spring-boot-deployment-tests" include "spring-boot-system-tests:spring-boot-image-tests" diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle index 50d236750faa..272e36bbc9f9 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle @@ -34,10 +34,9 @@ dependencies { optional("com.hazelcast:hazelcast") optional("com.hazelcast:hazelcast-spring") optional("com.zaxxer:HikariCP") - optional("io.dropwizard.metrics:metrics-jmx") optional("io.lettuce:lettuce-core") optional("io.micrometer:micrometer-observation") - optional("io.micrometer:micrometer-core") + optional("io.micrometer:micrometer-jakarta9") optional("io.micrometer:micrometer-tracing") optional("io.micrometer:micrometer-tracing-bridge-brave") optional("io.micrometer:micrometer-tracing-bridge-otel") @@ -74,6 +73,7 @@ dependencies { optional("io.opentelemetry:opentelemetry-exporter-otlp") optional("io.projectreactor.netty:reactor-netty-http") optional("io.r2dbc:r2dbc-pool") + optional("io.r2dbc:r2dbc-proxy") optional("io.r2dbc:r2dbc-spi") optional("jakarta.jms:jakarta.jms-api") optional("jakarta.persistence:jakarta.persistence-api") @@ -160,9 +160,7 @@ dependencies { testImplementation("org.assertj:assertj-core") testImplementation("org.awaitility:awaitility") testImplementation("org.cache2k:cache2k-api") - testImplementation("org.eclipse.jetty:jetty-webapp") { - exclude group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api" - } + testImplementation("org.eclipse.jetty.ee10:jetty-ee10-webapp") testImplementation("org.glassfish.jersey.ext:jersey-spring6") testImplementation("org.glassfish.jersey.media:jersey-media-json-jackson") testImplementation("org.hamcrest:hamcrest") @@ -250,8 +248,12 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) { dependsOn dependencyVersions doFirst { def versionConstraints = dependencyVersions.versionConstraints + def toAntoraVersion = version -> { + String formatted = version.split("\\.").take(2).join('.') + return version.endsWith("-SNAPSHOT") ? formatted + "-SNAPSHOT" : formatted + } def integrationVersion = versionConstraints["org.springframework.integration:spring-integration-core"] - def integrationDocs = String.format("https://docs.spring.io/spring-integration/docs/%s/reference/html/", integrationVersion) + String integrationDocs = String.format("https://docs.spring.io/spring-integration/reference/%s", toAntoraVersion(integrationVersion)) attributes "spring-integration-docs": integrationDocs } dependsOn documentationTest diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/integrationgraph.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/integrationgraph.adoc index 5709ca935833..dad34a128c32 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/integrationgraph.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/integrationgraph.adoc @@ -19,7 +19,7 @@ include::{snippets}/integrationgraph/graph/http-response.adoc[] [[integrationgraph.retrieving.response-structure]] === Response Structure The response contains all Spring Integration components used within the application, as well as the links between them. -More information about the structure can be found in the {spring-integration-docs}index-single.html#integration-graph[reference documentation]. +More information about the structure can be found in the {spring-integration-docs}/index.html#integration-graph[reference documentation]. diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java index 173bcbe9e951..c401f5cf7801 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,11 +65,9 @@ public CloudFoundryWebEndpointDiscoverer(ApplicationContext applicationContext, @Override protected boolean isExtensionTypeExposed(Class extensionBeanType) { - if (isHealthEndpointExtension(extensionBeanType) && !isCloudFoundryHealthEndpointExtension(extensionBeanType)) { - // Filter regular health endpoint extensions so a CF version can replace them - return false; - } - return true; + // Filter regular health endpoint extensions so a CF version can replace them + return !isHealthEndpointExtension(extensionBeanType) + || isCloudFoundryHealthEndpointExtension(extensionBeanType); } private boolean isHealthEndpointExtension(Class extensionBeanType) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java index 01eafcf65125..c5c4b2c8e4d2 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -86,7 +86,7 @@ SecurityResponse preHandle(HttpServletRequest request, EndpointId endpointId) { return SecurityResponse.success(); } - private void check(HttpServletRequest request, EndpointId endpointId) throws Exception { + private void check(HttpServletRequest request, EndpointId endpointId) { Token token = getToken(request); this.tokenValidator.validate(token); AccessLevel accessLevel = this.cloudFoundrySecurityService.getAccessLevel(token.toString(), this.applicationId); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java index 4478b0ed9426..a485aa2a4a10 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java @@ -143,11 +143,8 @@ private ConditionOutcome getEnablementOutcome(Environment environment, } private Boolean isEnabledByDefault(Environment environment) { - Optional enabledByDefault = enabledByDefaultCache.get(environment); - if (enabledByDefault == null) { - enabledByDefault = Optional.ofNullable(environment.getProperty(ENABLED_BY_DEFAULT_KEY, Boolean.class)); - enabledByDefaultCache.put(environment, enabledByDefault); - } + Optional enabledByDefault = enabledByDefaultCache.computeIfAbsent(environment, + (ignore) -> Optional.ofNullable(environment.getProperty(ENABLED_BY_DEFAULT_KEY, Boolean.class))); return enabledByDefault.orElse(null); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/expose/IncludeExcludeEndpointFilter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/expose/IncludeExcludeEndpointFilter.java index d2c738b5e2ec..00affa4cfff3 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/expose/IncludeExcludeEndpointFilter.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/expose/IncludeExcludeEndpointFilter.java @@ -150,7 +150,7 @@ private static class EndpointPatterns { private final Set endpointIds; EndpointPatterns(String[] patterns) { - this((patterns != null) ? Arrays.asList(patterns) : (Collection) null); + this((patterns != null) ? Arrays.asList(patterns) : null); } EndpointPatterns(Collection patterns) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java index 24736e2647d0..dd7a8668ca4c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java @@ -103,7 +103,8 @@ JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar jerseyDifferentP ExposableWebEndpoint health = webEndpoints.stream() .filter((endpoint) -> endpoint.getEndpointId().equals(HEALTH_ENDPOINT_ID)) .findFirst() - .get(); + .orElseThrow( + () -> new IllegalStateException("No endpoint with id '%s' found".formatted(HEALTH_ENDPOINT_ID))); return new JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar(health, healthEndpointGroups); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java index 9e117dd3b7c9..94ea4766f50f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java @@ -120,7 +120,8 @@ public AdditionalHealthEndpointPathsWebFluxHandlerMapping managementHealthEndpoi ExposableWebEndpoint health = webEndpoints.stream() .filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID)) .findFirst() - .get(); + .orElseThrow( + () -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID))); return new AdditionalHealthEndpointPathsWebFluxHandlerMapping(new EndpointMapping(""), health, groups.getAllWithAdditionalPath(WebServerNamespace.MANAGEMENT)); } @@ -162,16 +163,16 @@ static class ServerCodecConfigurerEndpointObjectMapperBeanPostProcessor implemen @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (bean instanceof ServerCodecConfigurer) { - process((ServerCodecConfigurer) bean); + if (bean instanceof ServerCodecConfigurer serverCodecConfigurer) { + process(serverCodecConfigurer); } return bean; } private void process(ServerCodecConfigurer configurer) { for (HttpMessageWriter writer : configurer.getWriters()) { - if (writer instanceof EncoderHttpMessageWriter) { - process(((EncoderHttpMessageWriter) writer).getEncoder()); + if (writer instanceof EncoderHttpMessageWriter encoderHttpMessageWriter) { + process((encoderHttpMessageWriter).getEncoder()); } } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java index f271c663ab95..451e08b61396 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java @@ -115,7 +115,8 @@ public AdditionalHealthEndpointPathsWebMvcHandlerMapping managementHealthEndpoin ExposableWebEndpoint health = webEndpoints.stream() .filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID)) .findFirst() - .get(); + .orElseThrow( + () -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID))); return new AdditionalHealthEndpointPathsWebMvcHandlerMapping(health, groups.getAllWithAdditionalPath(WebServerNamespace.MANAGEMENT)); } @@ -157,8 +158,8 @@ static class EndpointObjectMapperWebMvcConfigurer implements WebMvcConfigurer { @Override public void configureMessageConverters(List> converters) { for (HttpMessageConverter converter : converters) { - if (converter instanceof MappingJackson2HttpMessageConverter) { - configure((MappingJackson2HttpMessageConverter) converter); + if (converter instanceof MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter) { + configure(mappingJackson2HttpMessageConverter); } } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/AbstractCompositeHealthContributorConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/AbstractCompositeHealthContributorConfiguration.java index 5a7454b08a50..3b5aeda866da 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/AbstractCompositeHealthContributorConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/AbstractCompositeHealthContributorConfiguration.java @@ -16,12 +16,9 @@ package org.springframework.boot.actuate.autoconfigure.health; -import java.lang.reflect.Constructor; import java.util.Map; import java.util.function.Function; -import org.springframework.beans.BeanUtils; -import org.springframework.core.ResolvableType; import org.springframework.util.Assert; /** @@ -39,18 +36,6 @@ public abstract class AbstractCompositeHealthContributorConfiguration indicatorFactory; - /** - * Creates a {@code AbstractCompositeHealthContributorConfiguration} that will use - * reflection to create health indicator instances. - * @deprecated since 3.0.0 in favor of - * {@link #AbstractCompositeHealthContributorConfiguration(Function)} - */ - @Deprecated(since = "3.0.0", forRemoval = true) - protected AbstractCompositeHealthContributorConfiguration() { - this.indicatorFactory = new ReflectionIndicatorFactory( - ResolvableType.forClass(AbstractCompositeHealthContributorConfiguration.class, getClass())); - } - /** * Creates a {@code AbstractCompositeHealthContributorConfiguration} that will use the * given {@code indicatorFactory} to create health indicator instances. @@ -75,34 +60,4 @@ protected I createIndicator(B bean) { return this.indicatorFactory.apply(bean); } - private class ReflectionIndicatorFactory implements Function { - - private final Class indicatorType; - - private final Class beanType; - - ReflectionIndicatorFactory(ResolvableType type) { - this.indicatorType = type.resolveGeneric(1); - this.beanType = type.resolveGeneric(2); - } - - @Override - public I apply(B bean) { - try { - return BeanUtils.instantiateClass(getConstructor(), bean); - } - catch (Exception ex) { - throw new IllegalStateException("Unable to create health indicator %s for bean type %s" - .formatted(this.indicatorType, this.beanType), ex); - } - - } - - @SuppressWarnings("unchecked") - private Constructor getConstructor() throws NoSuchMethodException { - return (Constructor) this.indicatorType.getDeclaredConstructor(this.beanType); - } - - } - } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfiguration.java index 7901e1307552..4b979e94d19e 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -36,18 +36,6 @@ public abstract class CompositeHealthContributorConfiguration extends AbstractCompositeHealthContributorConfiguration { - /** - * Creates a {@code CompositeHealthContributorConfiguration} that will use reflection - * to create {@link HealthIndicator} instances. - * @deprecated since 3.0.0 in favor of - * {@link #CompositeHealthContributorConfiguration(Function)} - */ - @SuppressWarnings("removal") - @Deprecated(since = "3.0.0", forRemoval = true) - public CompositeHealthContributorConfiguration() { - super(); - } - /** * Creates a {@code CompositeHealthContributorConfiguration} that will use the given * {@code indicatorFactory} to create {@link HealthIndicator} instances. diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfiguration.java index 57b45ff1a10f..12c4ff22a88e 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -36,18 +36,6 @@ public abstract class CompositeReactiveHealthContributorConfiguration extends AbstractCompositeHealthContributorConfiguration { - /** - * Creates a {@code CompositeReactiveHealthContributorConfiguration} that will use - * reflection to create {@link ReactiveHealthIndicator} instances. - * @deprecated since 3.0.0 in favor of - * {@link #CompositeReactiveHealthContributorConfiguration(Function)} - */ - @SuppressWarnings("removal") - @Deprecated(since = "3.0.0", forRemoval = true) - public CompositeReactiveHealthContributorConfiguration() { - super(); - } - /** * Creates a {@code CompositeReactiveHealthContributorConfiguration} that will use the * given {@code indicatorFactory} to create {@link ReactiveHealthIndicator} instances. diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointReactiveWebExtensionConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointReactiveWebExtensionConfiguration.java index 6e80745fa7e7..4a8d814ebd4e 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointReactiveWebExtensionConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointReactiveWebExtensionConfiguration.java @@ -70,7 +70,8 @@ AdditionalHealthEndpointPathsWebFluxHandlerMapping healthEndpointWebFluxHandlerM ExposableWebEndpoint health = webEndpoints.stream() .filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID)) .findFirst() - .get(); + .orElseThrow( + () -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID))); return new AdditionalHealthEndpointPathsWebFluxHandlerMapping(new EndpointMapping(""), health, groups.getAllWithAdditionalPath(WebServerNamespace.SERVER)); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java index b0924d928018..a973b2f0fa4c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java @@ -81,7 +81,8 @@ private static ExposableWebEndpoint getHealthEndpoint(WebEndpointsSupplier webEn return webEndpoints.stream() .filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID)) .findFirst() - .get(); + .orElseThrow( + () -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID))); } @ConditionalOnBean(DispatcherServlet.class) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfiguration.java index 7f93279fde82..2a9b13603f90 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -37,11 +37,16 @@ * * @author Eddú Meléndez * @since 2.0.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the + * new client and its own + * Spring Boot integration. */ +@SuppressWarnings("removal") @AutoConfiguration(after = InfluxDbAutoConfiguration.class) @ConditionalOnClass(InfluxDB.class) @ConditionalOnBean(InfluxDB.class) @ConditionalOnEnabledHealthIndicator("influxdb") +@Deprecated(since = "3.2.0", forRemoval = true) public class InfluxDbHealthContributorAutoConfiguration extends CompositeHealthContributorConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfiguration.java index ae674854af1a..b26455ad43ab 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import org.springframework.boot.actuate.info.InfoContributor; import org.springframework.boot.actuate.info.JavaInfoContributor; import org.springframework.boot.actuate.info.OsInfoContributor; +import org.springframework.boot.actuate.info.ProcessInfoContributor; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -92,4 +93,11 @@ public OsInfoContributor osInfoContributor() { return new OsInfoContributor(); } + @Bean + @ConditionalOnEnabledInfoContributor(value = "process", fallback = InfoContributorFallback.DISABLE) + @Order(DEFAULT_ORDER) + public ProcessInfoContributor processInfoContributor() { + return new ProcessInfoContributor(); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorFallback.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorFallback.java index 0f78db5cbe44..ce03c3f76a44 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorFallback.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorFallback.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 the original author 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,6 @@ public enum InfoContributorFallback { /** * Do not fall back, thereby disabling the info contributor. */ - DISABLE; + DISABLE } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValue.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValue.java index d475f8c5d685..fa429ca765b1 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValue.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValue.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -90,7 +90,7 @@ public static MeterValue valueOf(String value) { if (duration != null) { return new MeterValue(duration); } - return new MeterValue(Double.valueOf(value)); + return new MeterValue(Double.parseDouble(value)); } /** diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfiguration.java new file mode 100644 index 000000000000..1541778479a9 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfiguration.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.metrics; + +import io.micrometer.core.aop.CountedAspect; +import io.micrometer.core.aop.MeterTagAnnotationHandler; +import io.micrometer.core.aop.TimedAspect; +import io.micrometer.core.instrument.MeterRegistry; +import org.aspectj.weaver.Advice; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Micrometer-based metrics + * aspects. + * + * @author Jonatan Ivanov + * @since 3.2.0 + */ +@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class }) +@ConditionalOnClass({ MeterRegistry.class, Advice.class }) +@ConditionalOnProperty(prefix = "micrometer.observations.annotations", name = "enabled", havingValue = "true") +@ConditionalOnBean(MeterRegistry.class) +public class MetricsAspectsAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + CountedAspect countedAspect(MeterRegistry registry) { + return new CountedAspect(registry); + } + + @Bean + @ConditionalOnMissingBean + TimedAspect timedAspect(MeterRegistry registry, + ObjectProvider meterTagAnnotationHandler) { + TimedAspect timedAspect = new TimedAspect(registry); + meterTagAnnotationHandler.ifAvailable(timedAspect::setMeterTagAnnotationHandler); + return timedAspect; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfiguration.java index 16eaab791d98..dfb7e73a5f61 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfiguration.java @@ -16,8 +16,11 @@ package org.springframework.boot.actuate.autoconfigure.metrics; +import java.util.List; + import io.micrometer.core.annotation.Timed; import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.binder.MeterBinder; import io.micrometer.core.instrument.config.MeterFilter; @@ -28,7 +31,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Bean; +import org.springframework.context.event.ContextClosedEvent; import org.springframework.core.annotation.Order; /** @@ -36,6 +41,7 @@ * * @author Jon Schneider * @author Stephane Nicoll + * @author Moritz Halbritter * @since 2.0.0 */ @AutoConfiguration(before = CompositeMeterRegistryAutoConfiguration.class) @@ -64,4 +70,32 @@ public PropertiesMeterFilter propertiesMeterFilter(MetricsProperties properties) return new PropertiesMeterFilter(properties); } + @Bean + MeterRegistryCloser meterRegistryCloser(ObjectProvider meterRegistries) { + return new MeterRegistryCloser(meterRegistries.orderedStream().toList()); + } + + /** + * Ensures that {@link MeterRegistry meter registries} are closed early in the + * shutdown process. + */ + static class MeterRegistryCloser implements ApplicationListener { + + private final List meterRegistries; + + MeterRegistryCloser(List meterRegistries) { + this.meterRegistries = meterRegistries; + } + + @Override + public void onApplicationEvent(ContextClosedEvent event) { + for (MeterRegistry meterRegistry : this.meterRegistries) { + if (!meterRegistry.isClosed()) { + meterRegistry.close(); + } + } + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java index f06176bb95dc..6ed7759cd14b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -25,7 +25,6 @@ import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; import org.springframework.boot.context.properties.NestedConfigurationProperty; /** @@ -115,8 +114,6 @@ public Server getServer() { public static class Client { - private final ClientRequest request = new ClientRequest(); - /** * Maximum number of unique URI tag values allowed. After the max number of * tag values is reached, metrics with additional tag values are denied by @@ -124,10 +121,6 @@ public static class Client { */ private int maxUriTags = 100; - public ClientRequest getRequest() { - return this.request; - } - public int getMaxUriTags() { return this.maxUriTags; } @@ -136,32 +129,10 @@ public void setMaxUriTags(int maxUriTags) { this.maxUriTags = maxUriTags; } - public static class ClientRequest { - - /** - * Name of the metric for sent requests. - */ - private String metricName = "http.client.requests"; - - @Deprecated(since = "3.0.0", forRemoval = true) - @DeprecatedConfigurationProperty(replacement = "management.observations.http.client.requests.name") - public String getMetricName() { - return this.metricName; - } - - @Deprecated(since = "3.0.0", forRemoval = true) - public void setMetricName(String metricName) { - this.metricName = metricName; - } - - } - } public static class Server { - private final ServerRequest request = new ServerRequest(); - /** * Maximum number of unique URI tag values allowed. After the max number of * tag values is reached, metrics with additional tag values are denied by @@ -169,10 +140,6 @@ public static class Server { */ private int maxUriTags = 100; - public ServerRequest getRequest() { - return this.request; - } - public int getMaxUriTags() { return this.maxUriTags; } @@ -181,27 +148,6 @@ public void setMaxUriTags(int maxUriTags) { this.maxUriTags = maxUriTags; } - public static class ServerRequest { - - /** - * Name of the metric for received requests. - */ - private String metricName = "http.server.requests"; - - @Deprecated(since = "3.0.0", forRemoval = true) - @DeprecatedConfigurationProperty(replacement = "management.observations.http.server.requests.name") - public String getMetricName() { - return this.metricName; - } - - @Deprecated(since = "3.0.0", forRemoval = true) - @DeprecatedConfigurationProperty(replacement = "management.observations.http.server.requests.name") - public void setMetricName(String metricName) { - this.metricName = metricName; - } - - } - } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatraceProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatraceProperties.java index 91c710100843..189ef09a86b5 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatraceProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatraceProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -140,6 +140,12 @@ public static class V2 { */ private boolean useDynatraceSummaryInstruments = true; + /** + * Whether to export meter metadata (unit and description) to the Dynatrace + * backend. + */ + private boolean exportMeterMetadata = true; + public Map getDefaultDimensions() { return this.defaultDimensions; } @@ -172,6 +178,14 @@ public void setUseDynatraceSummaryInstruments(boolean useDynatraceSummaryInstrum this.useDynatraceSummaryInstruments = useDynatraceSummaryInstruments; } + public boolean isExportMeterMetadata() { + return this.exportMeterMetadata; + } + + public void setExportMeterMetadata(boolean exportMeterMetadata) { + this.exportMeterMetadata = exportMeterMetadata; + } + } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapter.java index 82135f989860..bbdc14db563a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapter.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesConfigAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -95,6 +95,11 @@ public boolean useDynatraceSummaryInstruments() { return get(v2(V2::isUseDynatraceSummaryInstruments), DynatraceConfig.super::useDynatraceSummaryInstruments); } + @Override + public boolean exportMeterMetadata() { + return get(v2(V2::isExportMeterMetadata), DynatraceConfig.super::exportMeterMetadata); + } + private Function v1(Function getter) { return (properties) -> getter.apply(properties.getV1()); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsConnectionDetails.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsConnectionDetails.java new file mode 100644 index 000000000000..eeef0ae685bc --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsConnectionDetails.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.metrics.export.otlp; + +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; + +/** + * Details required to establish a connection to an OpenTelemetry Collector service. + * + * @author Eddú Meléndez + * @since 3.2.0 + */ +public interface OtlpMetricsConnectionDetails extends ConnectionDetails { + + /** + * Address to where metrics will be published. + * @return the address to where metrics will be published + */ + String getUrl(); + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfiguration.java index 29e89c29e50a..c7da21f488d1 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -24,6 +24,7 @@ import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.metrics.export.ConditionalOnEnabledMetricsExport; import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryProperties; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -31,11 +32,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; /** * {@link EnableAutoConfiguration Auto-configuration} for exporting metrics to OTLP. * * @author Eddú Meléndez + * @author Moritz Halbritter * @since 3.0.0 */ @AutoConfiguration( @@ -44,19 +47,27 @@ @ConditionalOnBean(Clock.class) @ConditionalOnClass(OtlpMeterRegistry.class) @ConditionalOnEnabledMetricsExport("otlp") -@EnableConfigurationProperties(OtlpProperties.class) +@EnableConfigurationProperties({ OtlpProperties.class, OpenTelemetryProperties.class }) public class OtlpMetricsExportAutoConfiguration { private final OtlpProperties properties; - public OtlpMetricsExportAutoConfiguration(OtlpProperties properties) { + OtlpMetricsExportAutoConfiguration(OtlpProperties properties) { this.properties = properties; } @Bean @ConditionalOnMissingBean - public OtlpConfig otlpConfig() { - return new OtlpPropertiesConfigAdapter(this.properties); + OtlpMetricsConnectionDetails otlpMetricsConnectionDetails() { + return new PropertiesOtlpMetricsConnectionDetails(this.properties); + } + + @Bean + @ConditionalOnMissingBean + OtlpConfig otlpConfig(OpenTelemetryProperties openTelemetryProperties, + OtlpMetricsConnectionDetails connectionDetails, Environment environment) { + return new OtlpPropertiesConfigAdapter(this.properties, openTelemetryProperties, connectionDetails, + environment); } @Bean @@ -65,4 +76,22 @@ public OtlpMeterRegistry otlpMeterRegistry(OtlpConfig otlpConfig, Clock clock) { return new OtlpMeterRegistry(otlpConfig, clock); } + /** + * Adapts {@link OtlpProperties} to {@link OtlpMetricsConnectionDetails}. + */ + static class PropertiesOtlpMetricsConnectionDetails implements OtlpMetricsConnectionDetails { + + private final OtlpProperties properties; + + PropertiesOtlpMetricsConnectionDetails(OtlpProperties properties) { + this.properties = properties; + } + + @Override + public String getUrl() { + return this.properties.getUrl(); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpProperties.java index 701d45c30896..e9a038d3e664 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpProperties.java @@ -17,11 +17,13 @@ package org.springframework.boot.actuate.autoconfigure.metrics.export.otlp; import java.util.Map; +import java.util.concurrent.TimeUnit; import io.micrometer.registry.otlp.AggregationTemporality; import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; /** * {@link ConfigurationProperties @ConfigurationProperties} for configuring OTLP metrics @@ -55,6 +57,11 @@ public class OtlpProperties extends StepRegistryProperties { */ private Map headers; + /** + * Time unit for exported metrics. + */ + private TimeUnit baseTimeUnit = TimeUnit.MILLISECONDS; + public String getUrl() { return this.url; } @@ -71,10 +78,13 @@ public void setAggregationTemporality(AggregationTemporality aggregationTemporal this.aggregationTemporality = aggregationTemporality; } + @Deprecated(since = "3.2.0", forRemoval = true) + @DeprecatedConfigurationProperty(replacement = "management.opentelemetry.resource-attributes", since = "3.2.0") public Map getResourceAttributes() { return this.resourceAttributes; } + @Deprecated(since = "3.2.0", forRemoval = true) public void setResourceAttributes(Map resourceAttributes) { this.resourceAttributes = resourceAttributes; } @@ -87,4 +97,12 @@ public void setHeaders(Map headers) { this.headers = headers; } + public TimeUnit getBaseTimeUnit() { + return this.baseTimeUnit; + } + + public void setBaseTimeUnit(TimeUnit baseTimeUnit) { + this.baseTimeUnit = baseTimeUnit; + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapter.java index 814298d364e3..79640714a1e5 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapter.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,23 +16,45 @@ package org.springframework.boot.actuate.autoconfigure.metrics.export.otlp; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; +import java.util.concurrent.TimeUnit; import io.micrometer.registry.otlp.AggregationTemporality; import io.micrometer.registry.otlp.OtlpConfig; import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryPropertiesConfigAdapter; +import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryProperties; +import org.springframework.core.env.Environment; +import org.springframework.util.CollectionUtils; /** * Adapter to convert {@link OtlpProperties} to an {@link OtlpConfig}. * * @author Eddú Meléndez * @author Jonatan Ivanov + * @author Moritz Halbritter */ class OtlpPropertiesConfigAdapter extends StepRegistryPropertiesConfigAdapter implements OtlpConfig { - OtlpPropertiesConfigAdapter(OtlpProperties properties) { + /** + * Default value for application name if {@code spring.application.name} is not set. + */ + private static final String DEFAULT_APPLICATION_NAME = "unknown_service"; + + private final OpenTelemetryProperties openTelemetryProperties; + + private final OtlpMetricsConnectionDetails connectionDetails; + + private final Environment environment; + + OtlpPropertiesConfigAdapter(OtlpProperties properties, OpenTelemetryProperties openTelemetryProperties, + OtlpMetricsConnectionDetails connectionDetails, Environment environment) { super(properties); + this.connectionDetails = connectionDetails; + this.openTelemetryProperties = openTelemetryProperties; + this.environment = environment; } @Override @@ -42,7 +64,7 @@ public String prefix() { @Override public String url() { - return get(OtlpProperties::getUrl, OtlpConfig.super::url); + return get((properties) -> this.connectionDetails.getUrl(), OtlpConfig.super::url); } @Override @@ -51,8 +73,17 @@ public AggregationTemporality aggregationTemporality() { } @Override + @SuppressWarnings("removal") public Map resourceAttributes() { - return get(OtlpProperties::getResourceAttributes, OtlpConfig.super::resourceAttributes); + Map resourceAttributes = this.openTelemetryProperties.getResourceAttributes(); + Map result = new HashMap<>((!CollectionUtils.isEmpty(resourceAttributes)) ? resourceAttributes + : get(OtlpProperties::getResourceAttributes, OtlpConfig.super::resourceAttributes)); + result.computeIfAbsent("service.name", (key) -> getApplicationName()); + return Collections.unmodifiableMap(result); + } + + private String getApplicationName() { + return this.environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME); } @Override @@ -60,4 +91,9 @@ public Map headers() { return get(OtlpProperties::getHeaders, OtlpConfig.super::headers); } + @Override + public TimeUnit baseTimeUnit() { + return get(OtlpProperties::getBaseTimeUnit, OtlpConfig.super::baseTimeUnit); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxProperties.java index 3a5735537a3f..0b9555b9aecb 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -116,7 +116,7 @@ public enum HistogramType { /** * Delta histogram. */ - DELTA; + DELTA } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapter.java index 9ffa626b2bb9..bd898ddad050 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapter.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapter.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.autoconfigure.metrics.export.wavefront; +import com.wavefront.sdk.common.clients.service.token.TokenService.Type; import io.micrometer.wavefront.WavefrontConfig; import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.PushRegistryPropertiesConfigAdapter; @@ -84,4 +85,9 @@ public boolean reportDayDistribution() { return get(Export::isReportDayDistribution, WavefrontConfig.super::reportDayDistribution); } + @Override + public Type apiTokenType() { + return this.properties.getWavefrontApiTokenType(); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/jersey/JerseyServerMetricsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/jersey/JerseyServerMetricsAutoConfiguration.java index 7bd894f08a0e..27d9729cc80f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/jersey/JerseyServerMetricsAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/jersey/JerseyServerMetricsAutoConfiguration.java @@ -29,9 +29,9 @@ import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties; -import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties.Web.Server; import org.springframework.boot.actuate.autoconfigure.metrics.OnlyOnceLoggingDenyMeterFilter; import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -57,13 +57,12 @@ @ConditionalOnClass({ ResourceConfig.class, MetricsApplicationEventListener.class }) @ConditionalOnBean({ MeterRegistry.class, ResourceConfig.class }) @EnableConfigurationProperties(MetricsProperties.class) -@SuppressWarnings("removal") public class JerseyServerMetricsAutoConfiguration { - private final MetricsProperties properties; + private final ObservationProperties observationProperties; - public JerseyServerMetricsAutoConfiguration(MetricsProperties properties) { - this.properties = properties; + public JerseyServerMetricsAutoConfiguration(ObservationProperties observationProperties) { + this.observationProperties = observationProperties; } @Bean @@ -75,19 +74,19 @@ public DefaultJerseyTagsProvider jerseyTagsProvider() { @Bean public ResourceConfigCustomizer jerseyServerMetricsResourceConfigCustomizer(MeterRegistry meterRegistry, JerseyTagsProvider tagsProvider) { - Server server = this.properties.getWeb().getServer(); - return (config) -> config.register(new MetricsApplicationEventListener(meterRegistry, tagsProvider, - server.getRequest().getMetricName(), true, new AnnotationUtilsAnnotationFinder())); + String metricName = this.observationProperties.getHttp().getServer().getRequests().getName(); + return (config) -> config.register(new MetricsApplicationEventListener(meterRegistry, tagsProvider, metricName, + true, new AnnotationUtilsAnnotationFinder())); } @Bean @Order(0) - public MeterFilter jerseyMetricsUriTagFilter() { - String metricName = this.properties.getWeb().getServer().getRequest().getMetricName(); + public MeterFilter jerseyMetricsUriTagFilter(MetricsProperties metricsProperties) { + String metricName = this.observationProperties.getHttp().getServer().getRequests().getName(); MeterFilter filter = new OnlyOnceLoggingDenyMeterFilter( () -> String.format("Reached the maximum number of URI tags for '%s'.", metricName)); - return MeterFilter.maximumAllowableTags(metricName, "uri", this.properties.getWeb().getServer().getMaxUriTags(), - filter); + return MeterFilter.maximumAllowableTags(metricName, "uri", + metricsProperties.getWeb().getServer().getMaxUriTags(), filter); } /** diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfiguration.java index 216bc4262408..b9230b45dd12 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfiguration.java @@ -27,9 +27,11 @@ import io.micrometer.observation.ObservationHandler; import io.micrometer.observation.ObservationPredicate; import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.aop.ObservedAspect; import io.micrometer.tracing.Tracer; import io.micrometer.tracing.handler.TracingAwareMeterObservationHandler; import io.micrometer.tracing.handler.TracingObservationHandler; +import org.aspectj.weaver.Advice; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; @@ -43,6 +45,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; /** * {@link EnableAutoConfiguration Auto-configuration} for the Micrometer Observation API. @@ -50,6 +53,7 @@ * @author Moritz Halbritter * @author Brian Clozel * @author Jonatan Ivanov + * @author Vedran Pavic * @since 3.0.0 */ @AutoConfiguration(after = { CompositeMeterRegistryAutoConfiguration.class, MicrometerTracingAutoConfiguration.class }) @@ -75,6 +79,12 @@ ObservationRegistry observationRegistry() { return ObservationRegistry.create(); } + @Bean + @Order(0) + PropertiesObservationFilterPredicate propertiesObservationFilter(ObservationProperties properties) { + return new PropertiesObservationFilterPredicate(properties); + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(MeterRegistry.class) @ConditionalOnMissingClass("io.micrometer.tracing.Tracer") @@ -142,4 +152,16 @@ TracingAwareMeterObservationHandler tracingAwareMeterObserv } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(Advice.class) + static class ObservedAspectConfiguration { + + @Bean + @ConditionalOnMissingBean + ObservedAspect observedAspect(ObservationRegistry observationRegistry) { + return new ObservedAspect(observationRegistry); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGrouping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGrouping.java index 163186947e8a..df964c813366 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGrouping.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGrouping.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.autoconfigure.observation; +import java.util.ArrayList; import java.util.List; import io.micrometer.observation.ObservationHandler; @@ -30,6 +31,7 @@ * Groups {@link ObservationHandler ObservationHandlers} by type. * * @author Andy Wilkinson + * @author Moritz Halbritter */ @SuppressWarnings("rawtypes") class ObservationHandlerGrouping { @@ -46,13 +48,14 @@ class ObservationHandlerGrouping { void apply(List> handlers, ObservationConfig config) { MultiValueMap, ObservationHandler> groupings = new LinkedMultiValueMap<>(); + List> handlersWithoutCategory = new ArrayList<>(); for (ObservationHandler handler : handlers) { Class category = findCategory(handler); if (category != null) { groupings.add(category, handler); } else { - config.observationHandler(handler); + handlersWithoutCategory.add(handler); } } for (Class category : this.categories) { @@ -61,6 +64,9 @@ void apply(List> handlers, ObservationConfig config) { config.observationHandler(new FirstMatchingCompositeObservationHandler(handlerGroup)); } } + for (ObservationHandler observationHandler : handlersWithoutCategory) { + config.observationHandler(observationHandler); + } } private Class findCategory(ObservationHandler handler) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationProperties.java index e4668d7f4b59..08de1a01c104 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,6 +16,9 @@ package org.springframework.boot.actuate.autoconfigure.observation; +import java.util.LinkedHashMap; +import java.util.Map; + import org.springframework.boot.context.properties.ConfigurationProperties; /** @@ -23,6 +26,7 @@ * observations. * * @author Brian Clozel + * @author Moritz Halbritter * @since 3.0.0 */ @ConfigurationProperties("management.observations") @@ -30,10 +34,37 @@ public class ObservationProperties { private final Http http = new Http(); + /** + * Common key-values that are applied to every observation. + */ + private Map keyValues = new LinkedHashMap<>(); + + /** + * Whether observations starting with the specified name should be enabled. The + * longest match wins, the key 'all' can also be used to configure all observations. + */ + private Map enable = new LinkedHashMap<>(); + + public Map getEnable() { + return this.enable; + } + + public void setEnable(Map enable) { + this.enable = enable; + } + public Http getHttp() { return this.http; } + public Map getKeyValues() { + return this.keyValues; + } + + public void setKeyValues(Map keyValues) { + this.keyValues = keyValues; + } + public static class Http { private final Client client = new Client(); @@ -59,10 +90,9 @@ public ClientRequests getRequests() { public static class ClientRequests { /** - * Name of the observation for client requests. If empty, will use the - * default "http.client.requests". + * Name of the observation for client requests. */ - private String name; + private String name = "http.client.requests"; public String getName() { return this.name; @@ -87,10 +117,9 @@ public ServerRequests getRequests() { public static class ServerRequests { /** - * Name of the observation for server requests. If empty, will use the - * default "http.server.requests". + * Name of the observation for server requests. */ - private String name; + private String name = "http.server.requests"; public String getName() { return this.name; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicate.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicate.java new file mode 100644 index 000000000000..1154668798af --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicate.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.observation; + +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Supplier; + +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation.Context; +import io.micrometer.observation.ObservationFilter; +import io.micrometer.observation.ObservationPredicate; + +import org.springframework.util.StringUtils; + +/** + * {@link ObservationFilter} to apply settings from {@link ObservationProperties}. + * + * @author Moritz Halbritter + */ +class PropertiesObservationFilterPredicate implements ObservationFilter, ObservationPredicate { + + private final ObservationFilter commonKeyValuesFilter; + + private final ObservationProperties properties; + + PropertiesObservationFilterPredicate(ObservationProperties properties) { + this.properties = properties; + this.commonKeyValuesFilter = createCommonKeyValuesFilter(properties); + } + + @Override + public Context map(Context context) { + return this.commonKeyValuesFilter.map(context); + } + + @Override + public boolean test(String name, Context context) { + return lookupWithFallbackToAll(this.properties.getEnable(), name, true); + } + + private static T lookupWithFallbackToAll(Map values, String name, T defaultValue) { + if (values.isEmpty()) { + return defaultValue; + } + return doLookup(values, name, () -> values.getOrDefault("all", defaultValue)); + } + + private static T doLookup(Map values, String name, Supplier defaultValue) { + while (StringUtils.hasLength(name)) { + T result = values.get(name); + if (result != null) { + return result; + } + int lastDot = name.lastIndexOf('.'); + name = (lastDot != -1) ? name.substring(0, lastDot) : ""; + } + return defaultValue.get(); + } + + private static ObservationFilter createCommonKeyValuesFilter(ObservationProperties properties) { + if (properties.getKeyValues().isEmpty()) { + return (context) -> context; + } + KeyValues keyValues = KeyValues.of(properties.getKeyValues().entrySet(), Entry::getKey, Entry::getValue); + return (context) -> context.addLowCardinalityKeyValues(keyValues); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/graphql/GraphQlObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/graphql/GraphQlObservationAutoConfiguration.java index 5c447db00fba..86b5ed0aee35 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/graphql/GraphQlObservationAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/graphql/GraphQlObservationAutoConfiguration.java @@ -43,7 +43,6 @@ @AutoConfiguration(after = ObservationAutoConfiguration.class) @ConditionalOnBean(ObservationRegistry.class) @ConditionalOnClass({ GraphQL.class, GraphQlSource.class, Observation.class }) -@SuppressWarnings("removal") public class GraphQlObservationAutoConfiguration { @Bean diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapter.java deleted file mode 100644 index 87205527f5e2..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapter.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.client; - -import io.micrometer.common.KeyValues; -import io.micrometer.core.instrument.Tag; - -import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider; -import org.springframework.http.client.observation.ClientRequestObservationContext; -import org.springframework.http.client.observation.ClientRequestObservationConvention; - -/** - * Adapter class that applies {@link RestTemplateExchangeTagsProvider} tags as a - * {@link ClientRequestObservationConvention}. - * - * @author Brian Clozel - */ -@SuppressWarnings({ "removal" }) -class ClientHttpObservationConventionAdapter implements ClientRequestObservationConvention { - - private final String metricName; - - private final RestTemplateExchangeTagsProvider tagsProvider; - - ClientHttpObservationConventionAdapter(String metricName, RestTemplateExchangeTagsProvider tagsProvider) { - this.metricName = metricName; - this.tagsProvider = tagsProvider; - } - - @Override - @SuppressWarnings("deprecation") - public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) { - Iterable tags = this.tagsProvider.getTags(context.getUriTemplate(), context.getCarrier(), - context.getResponse()); - return KeyValues.of(tags, Tag::getKey, Tag::getValue); - } - - @Override - public KeyValues getHighCardinalityKeyValues(ClientRequestObservationContext context) { - return KeyValues.empty(); - } - - @Override - public String getName() { - return this.metricName; - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapter.java deleted file mode 100644 index 0f3230a5bf8a..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapter.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.client; - -import io.micrometer.common.KeyValues; -import io.micrometer.core.instrument.Tag; -import io.micrometer.observation.Observation; - -import org.springframework.boot.actuate.metrics.web.reactive.client.WebClientExchangeTagsProvider; -import org.springframework.core.Conventions; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ClientRequestObservationContext; -import org.springframework.web.reactive.function.client.ClientRequestObservationConvention; -import org.springframework.web.reactive.function.client.WebClient; - -/** - * Adapter class that applies {@link WebClientExchangeTagsProvider} tags as a - * {@link ClientRequestObservationConvention}. - * - * @author Brian Clozel - */ -@SuppressWarnings("removal") -class ClientObservationConventionAdapter implements ClientRequestObservationConvention { - - private static final String URI_TEMPLATE_ATTRIBUTE = Conventions.getQualifiedAttributeName(WebClient.class, - "uriTemplate"); - - private final String metricName; - - private final WebClientExchangeTagsProvider tagsProvider; - - ClientObservationConventionAdapter(String metricName, WebClientExchangeTagsProvider tagsProvider) { - this.metricName = metricName; - this.tagsProvider = tagsProvider; - } - - @Override - public boolean supportsContext(Observation.Context context) { - return context instanceof ClientRequestObservationContext; - } - - @Override - public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) { - ClientRequest request = context.getRequest(); - if (request == null) { - request = context.getCarrier().attribute(URI_TEMPLATE_ATTRIBUTE, context.getUriTemplate()).build(); - } - Iterable tags = this.tagsProvider.tags(request, context.getResponse(), context.getError()); - return KeyValues.of(tags, Tag::getKey, Tag::getValue); - } - - @Override - public KeyValues getHighCardinalityKeyValues(ClientRequestObservationContext context) { - return KeyValues.empty(); - } - - @Override - public String getName() { - return this.metricName; - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java index 23323d25238c..60595014ef39 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/HttpClientObservationsAutoConfiguration.java @@ -31,6 +31,7 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -48,13 +49,15 @@ * @author Stephane Nicoll * @author Raheela Aslam * @author Brian Clozel + * @author Moritz Halbritter * @since 3.0.0 */ @AutoConfiguration(after = { ObservationAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class, - RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class }) + RestTemplateAutoConfiguration.class, WebClientAutoConfiguration.class, RestClientAutoConfiguration.class }) @ConditionalOnClass(Observation.class) @ConditionalOnBean(ObservationRegistry.class) -@Import({ RestTemplateObservationConfiguration.class, WebClientObservationConfiguration.class }) +@Import({ RestTemplateObservationConfiguration.class, WebClientObservationConfiguration.class, + RestClientObservationConfiguration.class }) @EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class }) public class HttpClientObservationsAutoConfiguration { @@ -65,13 +68,10 @@ static class MeterFilterConfiguration { @Bean @Order(0) - @SuppressWarnings("removal") MeterFilter metricsHttpClientUriTagFilter(ObservationProperties observationProperties, MetricsProperties metricsProperties) { Client clientProperties = metricsProperties.getWeb().getClient(); - String metricName = clientProperties.getRequest().getMetricName(); - String observationName = observationProperties.getHttp().getClient().getRequests().getName(); - String name = (observationName != null) ? observationName : metricName; + String name = observationProperties.getHttp().getClient().getRequests().getName(); MeterFilter denyFilter = new OnlyOnceLoggingDenyMeterFilter( () -> "Reached the maximum number of URI tags for '%s'. Are you using 'uriVariables'?" .formatted(name)); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfiguration.java new file mode 100644 index 000000000000..6b97d6c65e51 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfiguration.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.client; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties; +import org.springframework.boot.actuate.metrics.web.client.ObservationRestClientCustomizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.observation.ClientRequestObservationConvention; +import org.springframework.http.client.observation.DefaultClientRequestObservationConvention; +import org.springframework.web.client.RestClient; + +/** + * Configure the instrumentation of {@link RestClient}. + * + * @author Moritz Halbritter + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(RestClient.class) +@ConditionalOnBean(RestClient.Builder.class) +class RestClientObservationConfiguration { + + @Bean + RestClientCustomizer observationRestClientCustomizer(ObservationRegistry observationRegistry, + ObjectProvider customConvention, + ObservationProperties observationProperties) { + String name = observationProperties.getHttp().getClient().getRequests().getName(); + ClientRequestObservationConvention observationConvention = customConvention + .getIfAvailable(() -> new DefaultClientRequestObservationConvention(name)); + return new ObservationRestClientCustomizer(observationRegistry, observationConvention); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java index 0f62f2849b19..81fb154a230a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -19,10 +19,8 @@ import io.micrometer.observation.ObservationRegistry; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties; import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties; import org.springframework.boot.actuate.metrics.web.client.ObservationRestTemplateCustomizer; -import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.web.client.RestTemplateBuilder; @@ -40,39 +38,16 @@ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(RestTemplate.class) @ConditionalOnBean(RestTemplateBuilder.class) -@SuppressWarnings("removal") class RestTemplateObservationConfiguration { @Bean ObservationRestTemplateCustomizer observationRestTemplateCustomizer(ObservationRegistry observationRegistry, ObjectProvider customConvention, - ObservationProperties observationProperties, MetricsProperties metricsProperties, - ObjectProvider optionalTagsProvider) { - String name = observationName(observationProperties, metricsProperties); - ClientRequestObservationConvention observationConvention = createConvention(customConvention.getIfAvailable(), - name, optionalTagsProvider.getIfAvailable()); + ObservationProperties observationProperties) { + String name = observationProperties.getHttp().getClient().getRequests().getName(); + ClientRequestObservationConvention observationConvention = customConvention + .getIfAvailable(() -> new DefaultClientRequestObservationConvention(name)); return new ObservationRestTemplateCustomizer(observationRegistry, observationConvention); } - private static String observationName(ObservationProperties observationProperties, - MetricsProperties metricsProperties) { - String metricName = metricsProperties.getWeb().getClient().getRequest().getMetricName(); - String observationName = observationProperties.getHttp().getClient().getRequests().getName(); - return (observationName != null) ? observationName : metricName; - } - - private static ClientRequestObservationConvention createConvention( - ClientRequestObservationConvention customConvention, String name, - RestTemplateExchangeTagsProvider tagsProvider) { - if (customConvention != null) { - return customConvention; - } - else if (tagsProvider != null) { - return new ClientHttpObservationConventionAdapter(name, tagsProvider); - } - else { - return new DefaultClientRequestObservationConvention(name); - } - } - } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfiguration.java index ce531912e1dc..2df9c4bf9104 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -22,7 +22,6 @@ import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties; import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties; import org.springframework.boot.actuate.metrics.web.reactive.client.ObservationWebClientCustomizer; -import org.springframework.boot.actuate.metrics.web.reactive.client.WebClientExchangeTagsProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -37,39 +36,16 @@ */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(WebClient.class) -@SuppressWarnings("removal") class WebClientObservationConfiguration { @Bean ObservationWebClientCustomizer observationWebClientCustomizer(ObservationRegistry observationRegistry, ObjectProvider customConvention, - ObservationProperties observationProperties, ObjectProvider tagsProvider, - MetricsProperties metricsProperties) { - String name = observationName(observationProperties, metricsProperties); - ClientRequestObservationConvention observationConvention = createConvention(customConvention.getIfAvailable(), - tagsProvider.getIfAvailable(), name); + ObservationProperties observationProperties, MetricsProperties metricsProperties) { + String name = observationProperties.getHttp().getClient().getRequests().getName(); + ClientRequestObservationConvention observationConvention = customConvention + .getIfAvailable(() -> new DefaultClientRequestObservationConvention(name)); return new ObservationWebClientCustomizer(observationRegistry, observationConvention); } - private static ClientRequestObservationConvention createConvention( - ClientRequestObservationConvention customConvention, WebClientExchangeTagsProvider tagsProvider, - String name) { - if (customConvention != null) { - return customConvention; - } - else if (tagsProvider != null) { - return new ClientObservationConventionAdapter(name, tagsProvider); - } - else { - return new DefaultClientRequestObservationConvention(name); - } - } - - private static String observationName(ObservationProperties observationProperties, - MetricsProperties metricsProperties) { - String metricName = metricsProperties.getWeb().getClient().getRequest().getMetricName(); - String observationName = observationProperties.getHttp().getClient().getRequests().getName(); - return (observationName != null) ? observationName : metricName; - } - } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapter.java deleted file mode 100644 index 43689a962a05..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapter.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.reactive; - -import java.util.List; - -import io.micrometer.common.KeyValues; -import io.micrometer.core.instrument.Tag; - -import org.springframework.boot.actuate.metrics.web.reactive.server.DefaultWebFluxTagsProvider; -import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsContributor; -import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsProvider; -import org.springframework.http.codec.ServerCodecConfigurer; -import org.springframework.http.server.reactive.observation.ServerRequestObservationContext; -import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention; -import org.springframework.web.server.adapter.DefaultServerWebExchange; -import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver; -import org.springframework.web.server.i18n.LocaleContextResolver; -import org.springframework.web.server.session.DefaultWebSessionManager; -import org.springframework.web.server.session.WebSessionManager; - -/** - * Adapter class that applies {@link WebFluxTagsProvider} tags as a - * {@link ServerRequestObservationConvention}. - * - * @author Brian Clozel - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class ServerRequestObservationConventionAdapter implements ServerRequestObservationConvention { - - private final WebSessionManager webSessionManager = new DefaultWebSessionManager(); - - private final ServerCodecConfigurer serverCodecConfigurer = ServerCodecConfigurer.create(); - - private final LocaleContextResolver localeContextResolver = new AcceptHeaderLocaleContextResolver(); - - private final String name; - - private final WebFluxTagsProvider tagsProvider; - - ServerRequestObservationConventionAdapter(String name, WebFluxTagsProvider tagsProvider) { - this.name = name; - this.tagsProvider = tagsProvider; - } - - ServerRequestObservationConventionAdapter(String name, List contributors) { - this(name, new DefaultWebFluxTagsProvider(contributors)); - } - - @Override - public String getName() { - return this.name; - } - - @Override - public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) { - DefaultServerWebExchange serverWebExchange = new DefaultServerWebExchange(context.getCarrier(), - context.getResponse(), this.webSessionManager, this.serverCodecConfigurer, this.localeContextResolver); - serverWebExchange.getAttributes().putAll(context.getAttributes()); - Iterable tags = this.tagsProvider.httpRequestTags(serverWebExchange, context.getError()); - return KeyValues.of(tags, Tag::getKey, Tag::getValue); - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfiguration.java index a998e6b5d24b..94d125f63f2a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,23 +16,16 @@ package org.springframework.boot.actuate.autoconfigure.observation.web.reactive; -import java.util.List; - import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.config.MeterFilter; import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; -import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties; import org.springframework.boot.actuate.autoconfigure.metrics.OnlyOnceLoggingDenyMeterFilter; import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties; -import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsContributor; -import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -41,12 +34,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention; import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention; -import org.springframework.web.filter.reactive.ServerHttpObservationFilter; /** * {@link EnableAutoConfiguration Auto-configuration} for instrumentation of Spring @@ -55,77 +45,37 @@ * @author Brian Clozel * @author Jon Schneider * @author Dmytro Nosan + * @author Moritz Halbritter * @since 3.0.0 */ -@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class, - SimpleMetricsExportAutoConfiguration.class, ObservationAutoConfiguration.class }) -@ConditionalOnClass(Observation.class) -@ConditionalOnBean(ObservationRegistry.class) +@AutoConfiguration(after = { SimpleMetricsExportAutoConfiguration.class, ObservationAutoConfiguration.class }) +@ConditionalOnClass({ Observation.class, MeterRegistry.class }) +@ConditionalOnBean({ ObservationRegistry.class, MeterRegistry.class }) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) @EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class }) -@SuppressWarnings("removal") public class WebFluxObservationAutoConfiguration { - private final MetricsProperties metricsProperties; - private final ObservationProperties observationProperties; - public WebFluxObservationAutoConfiguration(MetricsProperties metricsProperties, - ObservationProperties observationProperties) { - this.metricsProperties = metricsProperties; + WebFluxObservationAutoConfiguration(ObservationProperties observationProperties) { this.observationProperties = observationProperties; } @Bean - @ConditionalOnMissingBean - @Order(Ordered.HIGHEST_PRECEDENCE + 1) - public ServerHttpObservationFilter webfluxObservationFilter(ObservationRegistry registry, - ObjectProvider customConvention, - ObjectProvider tagConfigurer, - ObjectProvider contributorsProvider) { - String observationName = this.observationProperties.getHttp().getServer().getRequests().getName(); - String metricName = this.metricsProperties.getWeb().getServer().getRequest().getMetricName(); - String name = (observationName != null) ? observationName : metricName; - WebFluxTagsProvider tagsProvider = tagConfigurer.getIfAvailable(); - List tagsContributors = contributorsProvider.orderedStream().toList(); - ServerRequestObservationConvention convention = createConvention(customConvention.getIfAvailable(), name, - tagsProvider, tagsContributors); - return new ServerHttpObservationFilter(registry, convention); - } - - private static ServerRequestObservationConvention createConvention( - ServerRequestObservationConvention customConvention, String name, WebFluxTagsProvider tagsProvider, - List tagsContributors) { - if (customConvention != null) { - return customConvention; - } - if (tagsProvider != null) { - return new ServerRequestObservationConventionAdapter(name, tagsProvider); - } - if (!tagsContributors.isEmpty()) { - return new ServerRequestObservationConventionAdapter(name, tagsContributors); - } - return new DefaultServerRequestObservationConvention(name); + @Order(0) + MeterFilter metricsHttpServerUriTagFilter(MetricsProperties metricsProperties) { + String name = this.observationProperties.getHttp().getServer().getRequests().getName(); + MeterFilter filter = new OnlyOnceLoggingDenyMeterFilter( + () -> "Reached the maximum number of URI tags for '%s'.".formatted(name)); + return MeterFilter.maximumAllowableTags(name, "uri", metricsProperties.getWeb().getServer().getMaxUriTags(), + filter); } - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(MeterRegistry.class) - @ConditionalOnBean(MeterRegistry.class) - static class MeterFilterConfiguration { - - @Bean - @Order(0) - MeterFilter metricsHttpServerUriTagFilter(MetricsProperties metricsProperties, - ObservationProperties observationProperties) { - String observationName = observationProperties.getHttp().getServer().getRequests().getName(); - String name = (observationName != null) ? observationName - : metricsProperties.getWeb().getServer().getRequest().getMetricName(); - MeterFilter filter = new OnlyOnceLoggingDenyMeterFilter( - () -> "Reached the maximum number of URI tags for '%s'.".formatted(name)); - return MeterFilter.maximumAllowableTags(name, "uri", metricsProperties.getWeb().getServer().getMaxUriTags(), - filter); - } - + @Bean + @ConditionalOnMissingBean(ServerRequestObservationConvention.class) + DefaultServerRequestObservationConvention defaultServerRequestObservationConvention() { + return new DefaultServerRequestObservationConvention( + this.observationProperties.getHttp().getServer().getRequests().getName()); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapter.java deleted file mode 100644 index df52cbca7407..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapter.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.servlet; - -import java.util.List; - -import io.micrometer.common.KeyValues; -import io.micrometer.core.instrument.Tag; -import io.micrometer.observation.Observation; - -import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider; -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor; -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsProvider; -import org.springframework.http.server.observation.ServerRequestObservationContext; -import org.springframework.http.server.observation.ServerRequestObservationConvention; -import org.springframework.util.Assert; -import org.springframework.web.servlet.HandlerMapping; - -/** - * Adapter class that applies {@link WebMvcTagsProvider} tags as a - * {@link ServerRequestObservationConvention}. - * - * @author Brian Clozel - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class ServerRequestObservationConventionAdapter implements ServerRequestObservationConvention { - - private final String observationName; - - private final WebMvcTagsProvider tagsProvider; - - ServerRequestObservationConventionAdapter(String observationName, WebMvcTagsProvider tagsProvider, - List contributors) { - Assert.state((tagsProvider != null) || (contributors != null), - "adapter should adapt to a WebMvcTagsProvider or a list of contributors"); - this.observationName = observationName; - this.tagsProvider = (tagsProvider != null) ? tagsProvider : new DefaultWebMvcTagsProvider(contributors); - } - - @Override - public String getName() { - return this.observationName; - } - - @Override - public boolean supportsContext(Observation.Context context) { - return context instanceof ServerRequestObservationContext; - } - - @Override - public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) { - Iterable tags = this.tagsProvider.getTags(context.getCarrier(), context.getResponse(), getHandler(context), - context.getError()); - return KeyValues.of(tags, Tag::getKey, Tag::getValue); - } - - private Object getHandler(ServerRequestObservationContext context) { - return context.getCarrier().getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE); - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfiguration.java index 5a70728ac125..2b4aa96c3933 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,8 +16,6 @@ package org.springframework.boot.actuate.autoconfigure.observation.web.servlet; -import java.util.List; - import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.config.MeterFilter; import io.micrometer.observation.Observation; @@ -32,8 +30,6 @@ import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties; -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor; -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -66,28 +62,16 @@ @ConditionalOnClass({ DispatcherServlet.class, Observation.class }) @ConditionalOnBean(ObservationRegistry.class) @EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class }) -@SuppressWarnings("removal") public class WebMvcObservationAutoConfiguration { - private final MetricsProperties metricsProperties; - - private final ObservationProperties observationProperties; - - public WebMvcObservationAutoConfiguration(ObservationProperties observationProperties, - MetricsProperties metricsProperties) { - this.observationProperties = observationProperties; - this.metricsProperties = metricsProperties; - } - @Bean @ConditionalOnMissingFilterBean public FilterRegistrationBean webMvcObservationFilter(ObservationRegistry registry, ObjectProvider customConvention, - ObjectProvider customTagsProvider, - ObjectProvider contributorsProvider) { - String name = httpRequestsMetricName(this.observationProperties, this.metricsProperties); - ServerRequestObservationConvention convention = createConvention(customConvention.getIfAvailable(), name, - customTagsProvider.getIfAvailable(), contributorsProvider.orderedStream().toList()); + ObservationProperties observationProperties) { + String name = observationProperties.getHttp().getServer().getRequests().getName(); + ServerRequestObservationConvention convention = customConvention + .getIfAvailable(() -> new DefaultServerRequestObservationConvention(name)); ServerHttpObservationFilter filter = new ServerHttpObservationFilter(registry, convention); FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); @@ -95,27 +79,6 @@ public FilterRegistrationBean webMvcObservationFilt return registration; } - private static ServerRequestObservationConvention createConvention( - ServerRequestObservationConvention customConvention, String name, WebMvcTagsProvider tagsProvider, - List contributors) { - if (customConvention != null) { - return customConvention; - } - else if (tagsProvider != null || contributors.size() > 0) { - return new ServerRequestObservationConventionAdapter(name, tagsProvider, contributors); - } - else { - return new DefaultServerRequestObservationConvention(name); - } - } - - private static String httpRequestsMetricName(ObservationProperties observationProperties, - MetricsProperties metricsProperties) { - String observationName = observationProperties.getHttp().getServer().getRequests().getName(); - return (observationName != null) ? observationName - : metricsProperties.getWeb().getServer().getRequest().getMetricName(); - } - @Configuration(proxyBeanMethods = false) @ConditionalOnClass(MeterRegistry.class) @ConditionalOnBean(MeterRegistry.class) @@ -123,9 +86,9 @@ static class MeterFilterConfiguration { @Bean @Order(0) - MeterFilter metricsHttpServerUriTagFilter(MetricsProperties metricsProperties, - ObservationProperties observationProperties) { - String name = httpRequestsMetricName(observationProperties, metricsProperties); + MeterFilter metricsHttpServerUriTagFilter(ObservationProperties observationProperties, + MetricsProperties metricsProperties) { + String name = observationProperties.getHttp().getServer().getRequests().getName(); MeterFilter filter = new OnlyOnceLoggingDenyMeterFilter( () -> String.format("Reached the maximum number of URI tags for '%s'.", name)); return MeterFilter.maximumAllowableTags(name, "uri", metricsProperties.getWeb().getServer().getMaxUriTags(), diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java new file mode 100644 index 000000000000..622ad4371b07 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.opentelemetry; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.OpenTelemetrySdkBuilder; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.resources.ResourceBuilder; +import io.opentelemetry.sdk.trace.SdkTracerProvider; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for OpenTelemetry. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +@AutoConfiguration +@ConditionalOnClass(OpenTelemetrySdk.class) +@EnableConfigurationProperties(OpenTelemetryProperties.class) +public class OpenTelemetryAutoConfiguration { + + /** + * Default value for application name if {@code spring.application.name} is not set. + */ + private static final String DEFAULT_APPLICATION_NAME = "unknown_service"; + + private static final AttributeKey ATTRIBUTE_KEY_SERVICE_NAME = AttributeKey.stringKey("service.name"); + + @Bean + @ConditionalOnMissingBean(OpenTelemetry.class) + OpenTelemetrySdk openTelemetry(ObjectProvider tracerProvider, + ObjectProvider propagators, ObjectProvider loggerProvider, + ObjectProvider meterProvider) { + OpenTelemetrySdkBuilder builder = OpenTelemetrySdk.builder(); + tracerProvider.ifAvailable(builder::setTracerProvider); + propagators.ifAvailable(builder::setPropagators); + loggerProvider.ifAvailable(builder::setLoggerProvider); + meterProvider.ifAvailable(builder::setMeterProvider); + return builder.build(); + } + + @Bean + @ConditionalOnMissingBean + Resource openTelemetryResource(Environment environment, OpenTelemetryProperties properties) { + String applicationName = environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME); + return Resource.getDefault() + .merge(Resource.create(Attributes.of(ATTRIBUTE_KEY_SERVICE_NAME, applicationName))) + .merge(toResource(properties)); + } + + private static Resource toResource(OpenTelemetryProperties properties) { + ResourceBuilder builder = Resource.builder(); + properties.getResourceAttributes().forEach(builder::put); + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryProperties.java new file mode 100644 index 000000000000..4c973ecf578b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryProperties.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.opentelemetry; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for OpenTelemetry. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +@ConfigurationProperties(prefix = "management.opentelemetry") +public class OpenTelemetryProperties { + + /** + * Resource attributes. + */ + private Map resourceAttributes = new HashMap<>(); + + public Map getResourceAttributes() { + return this.resourceAttributes; + } + + public void setResourceAttributes(Map resourceAttributes) { + this.resourceAttributes = resourceAttributes; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/package-info.java similarity index 77% rename from spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/package-info.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/package-info.java index 1167af5ca302..c1aab18823c6 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/package-info.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -15,6 +15,6 @@ */ /** - * Actuator support for WebFlux metrics. + * Auto-configuration for OpenTelemetry. */ -package org.springframework.boot.actuate.metrics.web.reactive.server; +package org.springframework.boot.actuate.autoconfigure.opentelemetry; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfiguration.java new file mode 100644 index 000000000000..75f619c1bdfe --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfiguration.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.r2dbc; + +import io.micrometer.observation.ObservationRegistry; +import io.r2dbc.proxy.ProxyConnectionFactory; +import io.r2dbc.proxy.observation.ObservationProxyExecutionListener; +import io.r2dbc.proxy.observation.QueryObservationConvention; +import io.r2dbc.proxy.observation.QueryParametersTagProvider; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryOptions; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.r2dbc.ConnectionFactoryDecorator; +import org.springframework.boot.r2dbc.OptionsCapableConnectionFactory; +import org.springframework.context.annotation.Bean; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for R2DBC observability support. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +@AutoConfiguration(after = ObservationAutoConfiguration.class) +@ConditionalOnClass({ ConnectionFactory.class, ProxyConnectionFactory.class }) +@EnableConfigurationProperties(R2dbcObservationProperties.class) +public class R2dbcObservationAutoConfiguration { + + @Bean + @ConditionalOnBean(ObservationRegistry.class) + ConnectionFactoryDecorator connectionFactoryDecorator(R2dbcObservationProperties properties, + ObservationRegistry observationRegistry, + ObjectProvider queryObservationConvention, + ObjectProvider queryParametersTagProvider) { + return (connectionFactory) -> { + HostAndPort hostAndPort = extractHostAndPort(connectionFactory); + ObservationProxyExecutionListener listener = new ObservationProxyExecutionListener(observationRegistry, + connectionFactory, hostAndPort.host(), hostAndPort.port()); + listener.setIncludeParameterValues(properties.isIncludeParameterValues()); + queryObservationConvention.ifAvailable(listener::setQueryObservationConvention); + queryParametersTagProvider.ifAvailable(listener::setQueryParametersTagProvider); + return ProxyConnectionFactory.builder(connectionFactory).listener(listener).build(); + }; + } + + private HostAndPort extractHostAndPort(ConnectionFactory connectionFactory) { + OptionsCapableConnectionFactory optionsCapableConnectionFactory = OptionsCapableConnectionFactory + .unwrapFrom(connectionFactory); + if (optionsCapableConnectionFactory == null) { + return HostAndPort.empty(); + } + ConnectionFactoryOptions options = optionsCapableConnectionFactory.getOptions(); + Object host = options.getValue(ConnectionFactoryOptions.HOST); + Object port = options.getValue(ConnectionFactoryOptions.PORT); + if (!(host instanceof String hostAsString) || !(port instanceof Integer portAsInt)) { + return HostAndPort.empty(); + } + return new HostAndPort(hostAsString, portAsInt); + } + + private record HostAndPort(String host, Integer port) { + static HostAndPort empty() { + return new HostAndPort(null, null); + } + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationProperties.java new file mode 100644 index 000000000000..4eedf3e12282 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationProperties.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.r2dbc; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for R2DBC observability. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +@ConfigurationProperties("management.observations.r2dbc") +public class R2dbcObservationProperties { + + /** + * Whether to tag actual query parameter values. + */ + private boolean includeParameterValues; + + public boolean isIncludeParameterValues() { + return this.includeParameterValues; + } + + public void setIncludeParameterValues(boolean includeParameterValues) { + this.includeParameterValues = includeParameterValues; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfiguration.java new file mode 100644 index 000000000000..a4014d2d3eb5 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.scheduling; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +/** + * {@link EnableAutoConfiguration Auto-configuration} to enable observability for + * scheduled tasks. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +@AutoConfiguration(after = ObservationAutoConfiguration.class) +@ConditionalOnBean(ObservationRegistry.class) +@ConditionalOnClass(ThreadPoolTaskScheduler.class) +public class ScheduledTasksObservabilityAutoConfiguration { + + @Bean + ObservabilitySchedulingConfigurer observabilitySchedulingConfigurer(ObservationRegistry observationRegistry) { + return new ObservabilitySchedulingConfigurer(observationRegistry); + } + + static final class ObservabilitySchedulingConfigurer implements SchedulingConfigurer { + + private final ObservationRegistry observationRegistry; + + ObservabilitySchedulingConfigurer(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + taskRegistrar.setObservationRegistry(this.observationRegistry); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java index 050613f1fc75..bed3b8f5c068 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,9 +136,7 @@ protected boolean ignoreApplicationContext(ApplicationContext applicationContext return true; } String managementContextId = applicationContext.getParent().getId() + ":management"; - if (!managementContextId.equals(applicationContext.getId())) { - return true; - } + return !managementContextId.equals(applicationContext.getId()); } return false; } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfiguration.java index 7ceff5128753..e9da837d148e 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.actuate.autoconfigure.security.reactive; +import reactor.core.publisher.Mono; + import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration; @@ -28,10 +30,14 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.reactive.ReactiveOAuth2ClientAutoConfiguration; import org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration; import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.web.cors.reactive.PreFlightRequestHandler; @@ -50,7 +56,8 @@ @AutoConfiguration(before = ReactiveSecurityAutoConfiguration.class, after = { HealthEndpointAutoConfiguration.class, InfoEndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class, ReactiveOAuth2ClientAutoConfiguration.class, - ReactiveOAuth2ResourceServerAutoConfiguration.class }) + ReactiveOAuth2ResourceServerAutoConfiguration.class, + ReactiveUserDetailsServiceAutoConfiguration.class }) @ConditionalOnClass({ EnableWebFluxSecurity.class, WebFilterChainProxy.class }) @ConditionalOnMissingBean({ SecurityWebFilterChain.class, WebFilterChainProxy.class }) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) @@ -69,4 +76,10 @@ public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, return http.build(); } + @Bean + @ConditionalOnMissingBean({ ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class }) + ReactiveAuthenticationManager denyAllAuthenticationManager() { + return (authentication) -> Mono.error(new UsernameNotFoundException(authentication.getName())); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/session/SessionsEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/session/SessionsEndpointAutoConfiguration.java index 3ec9589a29f5..30c6a155c1d9 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/session/SessionsEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/session/SessionsEndpointAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,17 +16,25 @@ package org.springframework.boot.actuate.autoconfigure.session; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; +import org.springframework.boot.actuate.session.ReactiveSessionsEndpoint; import org.springframework.boot.actuate.session.SessionsEndpoint; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.autoconfigure.session.SessionAutoConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; import org.springframework.session.Session; +import org.springframework.session.SessionRepository; /** * {@link EnableAutoConfiguration Auto-configuration} for {@link SessionsEndpoint}. @@ -35,15 +43,36 @@ * @since 2.0.0 */ @AutoConfiguration(after = SessionAutoConfiguration.class) -@ConditionalOnClass(FindByIndexNameSessionRepository.class) +@ConditionalOnClass(Session.class) @ConditionalOnAvailableEndpoint(endpoint = SessionsEndpoint.class) public class SessionsEndpointAutoConfiguration { - @Bean - @ConditionalOnBean(FindByIndexNameSessionRepository.class) - @ConditionalOnMissingBean - public SessionsEndpoint sessionEndpoint(FindByIndexNameSessionRepository sessionRepository) { - return new SessionsEndpoint(sessionRepository); + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = Type.SERVLET) + @ConditionalOnBean(SessionRepository.class) + static class ServletSessionEndpointConfiguration { + + @Bean + @ConditionalOnMissingBean + SessionsEndpoint sessionEndpoint(SessionRepository sessionRepository, + ObjectProvider> indexedSessionRepository) { + return new SessionsEndpoint(sessionRepository, indexedSessionRepository.getIfAvailable()); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = Type.REACTIVE) + @ConditionalOnBean(ReactiveSessionRepository.class) + static class ReactiveSessionEndpointConfiguration { + + @Bean + @ConditionalOnMissingBean + ReactiveSessionsEndpoint sessionsEndpoint(ReactiveSessionRepository sessionRepository, + ObjectProvider> indexedSessionRepository) { + return new ReactiveSessionsEndpoint(sessionRepository, indexedSessionRepository.getIfAvailable()); + } + } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfiguration.java index 6bb0528d7db8..eaa754c5e1ec 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,22 +24,10 @@ import brave.Tracing; import brave.Tracing.Builder; import brave.TracingCustomizer; -import brave.baggage.BaggageField; -import brave.baggage.BaggagePropagation; -import brave.baggage.BaggagePropagation.FactoryBuilder; -import brave.baggage.BaggagePropagationConfig; -import brave.baggage.BaggagePropagationCustomizer; -import brave.baggage.CorrelationScopeConfig.SingleCorrelationField; -import brave.baggage.CorrelationScopeCustomizer; -import brave.baggage.CorrelationScopeDecorator; -import brave.context.slf4j.MDCScopeDecorator; import brave.handler.SpanHandler; import brave.propagation.CurrentTraceContext; -import brave.propagation.CurrentTraceContext.ScopeDecorator; import brave.propagation.CurrentTraceContextCustomizer; -import brave.propagation.Propagation; import brave.propagation.Propagation.Factory; -import brave.propagation.Propagation.KeyFactory; import brave.propagation.ThreadLocalCurrentTraceContext; import brave.sampler.Sampler; import io.micrometer.tracing.brave.bridge.BraveBaggageManager; @@ -53,17 +41,15 @@ import io.micrometer.tracing.exporter.SpanReporter; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.actuate.autoconfigure.tracing.TracingProperties.Baggage.Correlation; import org.springframework.boot.actuate.autoconfigure.tracing.TracingProperties.Propagation.PropagationType; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.IncompatibleConfigurationException; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; @@ -76,19 +62,25 @@ * @author Jonatan Ivanov * @since 3.0.0 */ -@AutoConfiguration(before = MicrometerTracingAutoConfiguration.class) +@AutoConfiguration(before = { MicrometerTracingAutoConfiguration.class, NoopTracerAutoConfiguration.class }) @ConditionalOnClass({ Tracer.class, BraveTracer.class }) @EnableConfigurationProperties(TracingProperties.class) -@ConditionalOnEnabledTracing +@Import({ BravePropagationConfigurations.PropagationWithoutBaggage.class, + BravePropagationConfigurations.PropagationWithBaggage.class, + BravePropagationConfigurations.NoPropagation.class }) public class BraveAutoConfiguration { - private static final BraveBaggageManager BRAVE_BAGGAGE_MANAGER = new BraveBaggageManager(); - /** * Default value for application name if {@code spring.application.name} is not set. */ private static final String DEFAULT_APPLICATION_NAME = "application"; + private final TracingProperties tracingProperties; + + BraveAutoConfiguration(TracingProperties tracingProperties) { + this.tracingProperties = tracingProperties; + } + @Bean @ConditionalOnMissingBean @Order(Ordered.HIGHEST_PRECEDENCE) @@ -100,22 +92,22 @@ CompositeSpanHandler compositeSpanHandler(ObjectProvider @Bean @ConditionalOnMissingBean - public Tracing braveTracing(Environment environment, TracingProperties properties, List spanHandlers, + Tracing braveTracing(Environment environment, List spanHandlers, List tracingCustomizers, CurrentTraceContext currentTraceContext, Factory propagationFactory, Sampler sampler) { - if (properties.getBrave().isSpanJoiningSupported()) { - if (properties.getPropagation().getType() != null - && properties.getPropagation().getType().contains(PropagationType.W3C)) { + if (this.tracingProperties.getBrave().isSpanJoiningSupported()) { + if (this.tracingProperties.getPropagation().getType() != null + && this.tracingProperties.getPropagation().getType().contains(PropagationType.W3C)) { throw new IncompatibleConfigurationException("management.tracing.propagation.type", "management.tracing.brave.span-joining-supported"); } - if (properties.getPropagation().getType() == null - && properties.getPropagation().getProduce().contains(PropagationType.W3C)) { + if (this.tracingProperties.getPropagation().getType() == null + && this.tracingProperties.getPropagation().getProduce().contains(PropagationType.W3C)) { throw new IncompatibleConfigurationException("management.tracing.propagation.produce", "management.tracing.brave.span-joining-supported"); } - if (properties.getPropagation().getType() == null - && properties.getPropagation().getConsume().contains(PropagationType.W3C)) { + if (this.tracingProperties.getPropagation().getType() == null + && this.tracingProperties.getPropagation().getConsume().contains(PropagationType.W3C)) { throw new IncompatibleConfigurationException("management.tracing.propagation.consume", "management.tracing.brave.span-joining-supported"); } @@ -124,7 +116,7 @@ public Tracing braveTracing(Environment environment, TracingProperties propertie Builder builder = Tracing.newBuilder() .currentTraceContext(currentTraceContext) .traceId128Bit(true) - .supportsJoin(properties.getBrave().isSpanJoiningSupported()) + .supportsJoin(this.tracingProperties.getBrave().isSpanJoiningSupported()) .propagationFactory(propagationFactory) .sampler(sampler) .localServiceName(applicationName); @@ -137,13 +129,13 @@ public Tracing braveTracing(Environment environment, TracingProperties propertie @Bean @ConditionalOnMissingBean - public brave.Tracer braveTracer(Tracing tracing) { + brave.Tracer braveTracer(Tracing tracing) { return tracing.tracer(); } @Bean @ConditionalOnMissingBean - public CurrentTraceContext braveCurrentTraceContext(List scopeDecorators, + CurrentTraceContext braveCurrentTraceContext(List scopeDecorators, List currentTraceContextCustomizers) { ThreadLocalCurrentTraceContext.Builder builder = ThreadLocalCurrentTraceContext.newBuilder(); scopeDecorators.forEach(builder::addScopeDecorator); @@ -155,14 +147,15 @@ public CurrentTraceContext braveCurrentTraceContext(List baggagePropagationCustomizers) { - // There's a chicken-and-egg problem here: to create a builder, we need a - // factory. But the CompositePropagationFactory needs data from the builder. - // We create a throw-away builder with a throw-away factory, and then copy the - // config to the real builder. - FactoryBuilder throwAwayBuilder = BaggagePropagation.newFactoryBuilder(createThrowAwayFactory()); - baggagePropagationCustomizers.orderedStream() - .forEach((customizer) -> customizer.customize(throwAwayBuilder)); - CompositePropagationFactory propagationFactory = CompositePropagationFactory.create( - this.tracingProperties.getPropagation(), BRAVE_BAGGAGE_MANAGER, - LocalBaggageFields.extractFrom(throwAwayBuilder)); - FactoryBuilder builder = BaggagePropagation.newFactoryBuilder(propagationFactory); - throwAwayBuilder.configs().forEach(builder::add); - return builder; - } - - @SuppressWarnings("deprecation") - private Factory createThrowAwayFactory() { - return new Factory() { - - @Override - public Propagation create(KeyFactory keyFactory) { - return null; - } - - }; - } - - @Bean - @Order(0) - BaggagePropagationCustomizer remoteFieldsBaggagePropagationCustomizer() { - return (builder) -> { - List remoteFields = this.tracingProperties.getBaggage().getRemoteFields(); - for (String fieldName : remoteFields) { - builder.add(BaggagePropagationConfig.SingleBaggageField.remote(BaggageField.create(fieldName))); - } - }; - } - - @Bean - @ConditionalOnMissingBean - Factory propagationFactory(BaggagePropagation.FactoryBuilder factoryBuilder) { - return factoryBuilder.build(); - } - - @Bean - @ConditionalOnMissingBean - CorrelationScopeDecorator.Builder mdcCorrelationScopeDecoratorBuilder( - ObjectProvider correlationScopeCustomizers) { - CorrelationScopeDecorator.Builder builder = MDCScopeDecorator.newBuilder(); - correlationScopeCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); - return builder; - } - - @Bean - @Order(0) - @ConditionalOnProperty(prefix = "management.tracing.baggage.correlation", name = "enabled", - matchIfMissing = true) - CorrelationScopeCustomizer correlationFieldsCorrelationScopeCustomizer() { - return (builder) -> { - Correlation correlationProperties = this.tracingProperties.getBaggage().getCorrelation(); - for (String field : correlationProperties.getFields()) { - BaggageField baggageField = BaggageField.create(field); - SingleCorrelationField correlationField = SingleCorrelationField.newBuilder(baggageField) - .flushOnUpdate() - .build(); - builder.add(correlationField); - } - }; - } - - @Bean - @ConditionalOnMissingBean(CorrelationScopeDecorator.class) - ScopeDecorator correlationScopeDecorator(CorrelationScopeDecorator.Builder builder) { - return builder.build(); - } - - } - } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BravePropagationConfigurations.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BravePropagationConfigurations.java new file mode 100644 index 000000000000..31de139c7df3 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/BravePropagationConfigurations.java @@ -0,0 +1,183 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.tracing; + +import java.util.List; + +import brave.baggage.BaggageField; +import brave.baggage.BaggagePropagation; +import brave.baggage.BaggagePropagation.FactoryBuilder; +import brave.baggage.BaggagePropagationConfig; +import brave.baggage.BaggagePropagationCustomizer; +import brave.baggage.CorrelationScopeConfig.SingleCorrelationField; +import brave.baggage.CorrelationScopeCustomizer; +import brave.baggage.CorrelationScopeDecorator; +import brave.context.slf4j.MDCScopeDecorator; +import brave.propagation.CurrentTraceContext.ScopeDecorator; +import brave.propagation.Propagation; +import brave.propagation.Propagation.Factory; +import brave.propagation.Propagation.KeyFactory; +import io.micrometer.tracing.brave.bridge.BraveBaggageManager; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.tracing.TracingProperties.Baggage.Correlation; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; + +/** + * Brave propagation configurations. They are imported by {@link BraveAutoConfiguration}. + * + * @author Moritz Halbritter + */ +class BravePropagationConfigurations { + + /** + * Propagates traces but no baggage. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(value = "management.tracing.baggage.enabled", havingValue = "false") + static class PropagationWithoutBaggage { + + @Bean + @ConditionalOnMissingBean(Factory.class) + @ConditionalOnEnabledTracing + CompositePropagationFactory propagationFactory(TracingProperties properties) { + return CompositePropagationFactory.create(properties.getPropagation()); + } + + } + + /** + * Propagates traces and baggage. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(value = "management.tracing.baggage.enabled", matchIfMissing = true) + @EnableConfigurationProperties(TracingProperties.class) + static class PropagationWithBaggage { + + private final TracingProperties tracingProperties; + + PropagationWithBaggage(TracingProperties tracingProperties) { + this.tracingProperties = tracingProperties; + } + + @Bean + @ConditionalOnMissingBean + BaggagePropagation.FactoryBuilder propagationFactoryBuilder( + ObjectProvider baggagePropagationCustomizers) { + // There's a chicken-and-egg problem here: to create a builder, we need a + // factory. But the CompositePropagationFactory needs data from the builder. + // We create a throw-away builder with a throw-away factory, and then copy the + // config to the real builder. + FactoryBuilder throwAwayBuilder = BaggagePropagation.newFactoryBuilder(createThrowAwayFactory()); + baggagePropagationCustomizers.orderedStream() + .forEach((customizer) -> customizer.customize(throwAwayBuilder)); + CompositePropagationFactory propagationFactory = CompositePropagationFactory.create( + this.tracingProperties.getPropagation(), + new BraveBaggageManager(this.tracingProperties.getBaggage().getTagFields()), + LocalBaggageFields.extractFrom(throwAwayBuilder)); + FactoryBuilder builder = BaggagePropagation.newFactoryBuilder(propagationFactory); + throwAwayBuilder.configs().forEach(builder::add); + return builder; + } + + @SuppressWarnings("deprecation") + private Factory createThrowAwayFactory() { + return new Factory() { + + @Override + public Propagation create(KeyFactory keyFactory) { + return null; + } + + }; + } + + @Bean + BaggagePropagationCustomizer remoteFieldsBaggagePropagationCustomizer() { + return (builder) -> { + List remoteFields = this.tracingProperties.getBaggage().getRemoteFields(); + for (String fieldName : remoteFields) { + builder.add(BaggagePropagationConfig.SingleBaggageField.remote(BaggageField.create(fieldName))); + } + List localFields = this.tracingProperties.getBaggage().getLocalFields(); + for (String localFieldName : localFields) { + builder.add(BaggagePropagationConfig.SingleBaggageField.local(BaggageField.create(localFieldName))); + } + }; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledTracing + Factory propagationFactory(BaggagePropagation.FactoryBuilder factoryBuilder) { + return factoryBuilder.build(); + } + + @Bean + @ConditionalOnMissingBean + CorrelationScopeDecorator.Builder mdcCorrelationScopeDecoratorBuilder( + ObjectProvider correlationScopeCustomizers) { + CorrelationScopeDecorator.Builder builder = MDCScopeDecorator.newBuilder(); + correlationScopeCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + return builder; + } + + @Bean + @Order(0) + @ConditionalOnProperty(prefix = "management.tracing.baggage.correlation", name = "enabled", + matchIfMissing = true) + CorrelationScopeCustomizer correlationFieldsCorrelationScopeCustomizer() { + return (builder) -> { + Correlation correlationProperties = this.tracingProperties.getBaggage().getCorrelation(); + for (String field : correlationProperties.getFields()) { + BaggageField baggageField = BaggageField.create(field); + SingleCorrelationField correlationField = SingleCorrelationField.newBuilder(baggageField) + .flushOnUpdate() + .build(); + builder.add(correlationField); + } + }; + } + + @Bean + @ConditionalOnMissingBean(CorrelationScopeDecorator.class) + ScopeDecorator correlationScopeDecorator(CorrelationScopeDecorator.Builder builder) { + return builder.build(); + } + + } + + /** + * Propagates neither traces nor baggage. + */ + @Configuration(proxyBeanMethods = false) + static class NoPropagation { + + @Bean + @ConditionalOnMissingBean(Factory.class) + CompositePropagationFactory noopPropagationFactory() { + return CompositePropagationFactory.noop(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactory.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactory.java index c4ccc0f301c2..14ec2aea1e66 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactory.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/CompositePropagationFactory.java @@ -17,6 +17,7 @@ package org.springframework.boot.actuate.autoconfigure.tracing; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.function.Predicate; import java.util.stream.Stream; @@ -84,6 +85,14 @@ public TraceContext decorate(TraceContext context) { .orElse(context); } + /** + * Creates a new {@link CompositePropagationFactory} which doesn't do any propagation. + * @return the {@link CompositePropagationFactory} + */ + static CompositePropagationFactory noop() { + return new CompositePropagationFactory(Collections.emptyList(), Collections.emptyList()); + } + /** * Creates a new {@link CompositePropagationFactory}. * @param properties the propagation properties @@ -136,7 +145,7 @@ Propagation.Factory map(PropagationType type) { * @return the B3 propagation factory */ private Propagation.Factory b3Single() { - return B3Propagation.newFactoryBuilder().injectFormat(B3Propagation.Format.SINGLE_NO_PARENT).build(); + return B3Propagation.newFactoryBuilder().injectFormat(B3Propagation.Format.SINGLE).build(); } /** diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessor.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessor.java new file mode 100644 index 000000000000..b0479bd29c3f --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessor.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.boot.logging.LoggingSystem; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.Environment; +import org.springframework.core.env.PropertySource; +import org.springframework.util.ClassUtils; + +/** + * {@link EnvironmentPostProcessor} to add a {@link PropertySource} to support log + * correlation IDs when Micrometer Tracing is present. Adds support for the + * {@value LoggingSystem#EXPECT_CORRELATION_ID_PROPERTY} property by delegating to + * {@code management.tracing.enabled}. + * + * @author Jonatan Ivanov + * @author Phillip Webb + */ +class LogCorrelationEnvironmentPostProcessor implements EnvironmentPostProcessor { + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (ClassUtils.isPresent("io.micrometer.tracing.Tracer", application.getClassLoader())) { + environment.getPropertySources().addLast(new LogCorrelationPropertySource(this, environment)); + } + } + + /** + * Log correlation {@link PropertySource}. + */ + private static class LogCorrelationPropertySource extends EnumerablePropertySource { + + private static final String NAME = "logCorrelation"; + + private final Environment environment; + + LogCorrelationPropertySource(Object source, Environment environment) { + super(NAME, source); + this.environment = environment; + } + + @Override + public String[] getPropertyNames() { + return new String[] { LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY }; + } + + @Override + public Object getProperty(String name) { + if (name.equals(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY)) { + return this.environment.getProperty("management.tracing.enabled", Boolean.class, Boolean.TRUE); + } + return null; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfiguration.java index e91e41a5b057..f64e80fceac6 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,30 +16,46 @@ package org.springframework.boot.actuate.autoconfigure.tracing; +import io.micrometer.common.annotation.ValueExpressionResolver; import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.annotation.DefaultNewSpanParser; +import io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor; +import io.micrometer.tracing.annotation.MethodInvocationProcessor; +import io.micrometer.tracing.annotation.NewSpanParser; +import io.micrometer.tracing.annotation.SpanAspect; +import io.micrometer.tracing.annotation.SpanTagAnnotationHandler; import io.micrometer.tracing.handler.DefaultTracingObservationHandler; import io.micrometer.tracing.handler.PropagatingReceiverTracingObservationHandler; import io.micrometer.tracing.handler.PropagatingSenderTracingObservationHandler; import io.micrometer.tracing.propagation.Propagator; +import org.aspectj.weaver.Advice; +import org.springframework.beans.factory.BeanFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.SimpleEvaluationContext; /** * {@link EnableAutoConfiguration Auto-configuration} for the Micrometer Tracing API. * * @author Moritz Halbritter + * @author Jonatan Ivanov * @since 3.0.0 */ @AutoConfiguration @ConditionalOnClass(Tracer.class) -@ConditionalOnEnabledTracing +@ConditionalOnBean(Tracer.class) public class MicrometerTracingAutoConfiguration { /** @@ -61,7 +77,6 @@ public class MicrometerTracingAutoConfiguration { @Bean @ConditionalOnMissingBean - @ConditionalOnBean(Tracer.class) @Order(DEFAULT_TRACING_OBSERVATION_HANDLER_ORDER) public DefaultTracingObservationHandler defaultTracingObservationHandler(Tracer tracer) { return new DefaultTracingObservationHandler(tracer); @@ -69,7 +84,7 @@ public DefaultTracingObservationHandler defaultTracingObservationHandler(Tracer @Bean @ConditionalOnMissingBean - @ConditionalOnBean({ Tracer.class, Propagator.class }) + @ConditionalOnBean(Propagator.class) @Order(SENDER_TRACING_OBSERVATION_HANDLER_ORDER) public PropagatingSenderTracingObservationHandler propagatingSenderTracingObservationHandler(Tracer tracer, Propagator propagator) { @@ -78,11 +93,61 @@ public PropagatingSenderTracingObservationHandler propagatingSenderTracingObs @Bean @ConditionalOnMissingBean - @ConditionalOnBean({ Tracer.class, Propagator.class }) + @ConditionalOnBean(Propagator.class) @Order(RECEIVER_TRACING_OBSERVATION_HANDLER_ORDER) public PropagatingReceiverTracingObservationHandler propagatingReceiverTracingObservationHandler(Tracer tracer, Propagator propagator) { return new PropagatingReceiverTracingObservationHandler<>(tracer, propagator); } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(Advice.class) + @ConditionalOnProperty(prefix = "micrometer.observations.annotations", name = "enabled", havingValue = "true") + static class SpanAspectConfiguration { + + @Bean + @ConditionalOnMissingBean(NewSpanParser.class) + DefaultNewSpanParser newSpanParser() { + return new DefaultNewSpanParser(); + } + + @Bean + @ConditionalOnMissingBean + SpanTagAnnotationHandler spanTagAnnotationHandler(BeanFactory beanFactory) { + ValueExpressionResolver valueExpressionResolver = new SpelTagValueExpressionResolver(); + return new SpanTagAnnotationHandler(beanFactory::getBean, (ignored) -> valueExpressionResolver); + } + + @Bean + @ConditionalOnMissingBean(MethodInvocationProcessor.class) + ImperativeMethodInvocationProcessor imperativeMethodInvocationProcessor(NewSpanParser newSpanParser, + Tracer tracer, SpanTagAnnotationHandler spanTagAnnotationHandler) { + return new ImperativeMethodInvocationProcessor(newSpanParser, tracer, spanTagAnnotationHandler); + } + + @Bean + @ConditionalOnMissingBean + SpanAspect spanAspect(MethodInvocationProcessor methodInvocationProcessor) { + return new SpanAspect(methodInvocationProcessor); + } + + } + + private static final class SpelTagValueExpressionResolver implements ValueExpressionResolver { + + @Override + public String resolve(String expression, Object parameter) { + try { + SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + ExpressionParser expressionParser = new SpelExpressionParser(); + Expression expressionToEvaluate = expressionParser.parseExpression(expression); + return expressionToEvaluate.getValue(context, parameter, String.class); + } + catch (Exception ex) { + throw new IllegalStateException("Unable to evaluate SpEL expression '%s'".formatted(expression), ex); + } + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/NoopTracerAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/NoopTracerAutoConfiguration.java new file mode 100644 index 000000000000..ee2f65f8025a --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/NoopTracerAutoConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing; + +import io.micrometer.tracing.Tracer; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for a no-op implementation of + * {@link Tracer}. + * + * @author Moritz Halbritter + * @since 3.2.1 + */ +@AutoConfiguration(before = MicrometerTracingAutoConfiguration.class) +@ConditionalOnClass(Tracer.class) +@ConditionalOnMissingBean(Tracer.class) +public class NoopTracerAutoConfiguration { + + @Bean + Tracer noopTracer() { + return Tracer.NOOP; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfiguration.java index 60a15e904f61..7db17f72146c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.actuate.autoconfigure.tracing; -import java.util.Collections; import java.util.List; import io.micrometer.tracing.SpanCustomizer; @@ -32,24 +31,23 @@ import io.micrometer.tracing.otel.bridge.OtelSpanCustomizer; import io.micrometer.tracing.otel.bridge.OtelTracer; import io.micrometer.tracing.otel.bridge.OtelTracer.EventPublisher; -import io.micrometer.tracing.otel.bridge.Slf4JBaggageEventListener; import io.micrometer.tracing.otel.bridge.Slf4JEventListener; -import io.micrometer.tracing.otel.propagation.BaggageTextMapPropagator; import io.opentelemetry.api.OpenTelemetry; -import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.MeterProvider; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.ContextStorage; import io.opentelemetry.context.propagation.ContextPropagators; import io.opentelemetry.context.propagation.TextMapPropagator; -import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; import io.opentelemetry.sdk.trace.SpanProcessor; import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessorBuilder; import io.opentelemetry.sdk.trace.export.SpanExporter; import io.opentelemetry.sdk.trace.samplers.Sampler; -import io.opentelemetry.semconv.resource.attributes.ResourceAttributes; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.SpringBootVersion; @@ -57,56 +55,45 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; +import org.springframework.context.annotation.Import; +import org.springframework.util.CollectionUtils; /** - * {@link EnableAutoConfiguration Auto-configuration} for OpenTelemetry. + * {@link EnableAutoConfiguration Auto-configuration} for OpenTelemetry tracing. * * @author Moritz Halbritter * @author Marcin Grzejszczak * @author Yanming Zhou * @since 3.0.0 */ -@AutoConfiguration(before = MicrometerTracingAutoConfiguration.class) -@ConditionalOnEnabledTracing +@AutoConfiguration(value = "openTelemetryTracingAutoConfiguration", + before = { MicrometerTracingAutoConfiguration.class, NoopTracerAutoConfiguration.class }) @ConditionalOnClass({ OtelTracer.class, SdkTracerProvider.class, OpenTelemetry.class }) @EnableConfigurationProperties(TracingProperties.class) +@Import({ OpenTelemetryPropagationConfigurations.PropagationWithoutBaggage.class, + OpenTelemetryPropagationConfigurations.PropagationWithBaggage.class, + OpenTelemetryPropagationConfigurations.NoPropagation.class }) public class OpenTelemetryAutoConfiguration { - /** - * Default value for application name if {@code spring.application.name} is not set. - */ - private static final String DEFAULT_APPLICATION_NAME = "application"; + private static final Log logger = LogFactory.getLog(OpenTelemetryAutoConfiguration.class); private final TracingProperties tracingProperties; OpenTelemetryAutoConfiguration(TracingProperties tracingProperties) { this.tracingProperties = tracingProperties; + if (!CollectionUtils.isEmpty(this.tracingProperties.getBaggage().getLocalFields())) { + logger.warn("Local fields are not supported when using OpenTelemetry!"); + } } @Bean @ConditionalOnMissingBean - OpenTelemetry openTelemetry(SdkTracerProvider sdkTracerProvider, ContextPropagators contextPropagators) { - return OpenTelemetrySdk.builder() - .setTracerProvider(sdkTracerProvider) - .setPropagators(contextPropagators) - .build(); - } - - @Bean - @ConditionalOnMissingBean - SdkTracerProvider otelSdkTracerProvider(Environment environment, ObjectProvider spanProcessors, - Sampler sampler, ObjectProvider customizers) { - String applicationName = environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME); - Resource springResource = Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, applicationName)); - SdkTracerProviderBuilder builder = SdkTracerProvider.builder() - .setSampler(sampler) - .setResource(Resource.getDefault().merge(springResource)); - spanProcessors.orderedStream().forEach(builder::addSpanProcessor); + SdkTracerProvider otelSdkTracerProvider(Resource resource, SpanProcessors spanProcessors, Sampler sampler, + ObjectProvider customizers) { + SdkTracerProviderBuilder builder = SdkTracerProvider.builder().setSampler(sampler).setResource(resource); + spanProcessors.forEach(builder::addSpanProcessor); customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); return builder.build(); } @@ -125,14 +112,26 @@ Sampler otelSampler() { } @Bean - SpanProcessor otelSpanProcessor(ObjectProvider spanExporters, + @ConditionalOnMissingBean + SpanProcessors spanProcessors(ObjectProvider spanProcessors) { + return SpanProcessors.of(spanProcessors.orderedStream().toList()); + } + + @Bean + BatchSpanProcessor otelSpanProcessor(SpanExporters spanExporters, ObjectProvider spanExportingPredicates, ObjectProvider spanReporters, - ObjectProvider spanFilters) { - return BatchSpanProcessor - .builder(new CompositeSpanExporter(spanExporters.orderedStream().toList(), - spanExportingPredicates.orderedStream().toList(), spanReporters.orderedStream().toList(), - spanFilters.orderedStream().toList())) - .build(); + ObjectProvider spanFilters, ObjectProvider meterProvider) { + BatchSpanProcessorBuilder builder = BatchSpanProcessor + .builder(new CompositeSpanExporter(spanExporters.list(), spanExportingPredicates.orderedStream().toList(), + spanReporters.orderedStream().toList(), spanFilters.orderedStream().toList())); + meterProvider.ifAvailable(builder::setMeterProvider); + return builder.build(); + } + + @Bean + @ConditionalOnMissingBean + SpanExporters spanExporters(ObjectProvider spanExporters) { + return SpanExporters.of(spanExporters.orderedStream().toList()); } @Bean @@ -145,9 +144,10 @@ Tracer otelTracer(OpenTelemetry openTelemetry) { @ConditionalOnMissingBean(io.micrometer.tracing.Tracer.class) OtelTracer micrometerOtelTracer(Tracer tracer, EventPublisher eventPublisher, OtelCurrentTraceContext otelCurrentTraceContext) { + List remoteFields = this.tracingProperties.getBaggage().getRemoteFields(); + List tagFields = this.tracingProperties.getBaggage().getTagFields(); return new OtelTracer(tracer, otelCurrentTraceContext, eventPublisher, - new OtelBaggageManager(otelCurrentTraceContext, this.tracingProperties.getBaggage().getRemoteFields(), - Collections.emptyList())); + new OtelBaggageManager(otelCurrentTraceContext, remoteFields, tagFields)); } @Bean @@ -181,45 +181,6 @@ OtelSpanCustomizer otelSpanCustomizer() { return new OtelSpanCustomizer(); } - @Configuration(proxyBeanMethods = false) - @ConditionalOnProperty(prefix = "management.tracing.baggage", name = "enabled", matchIfMissing = true) - static class BaggageConfiguration { - - private final TracingProperties tracingProperties; - - BaggageConfiguration(TracingProperties tracingProperties) { - this.tracingProperties = tracingProperties; - } - - @Bean - TextMapPropagator textMapPropagatorWithBaggage(OtelCurrentTraceContext otelCurrentTraceContext) { - List remoteFields = this.tracingProperties.getBaggage().getRemoteFields(); - BaggageTextMapPropagator baggagePropagator = new BaggageTextMapPropagator(remoteFields, - new OtelBaggageManager(otelCurrentTraceContext, remoteFields, Collections.emptyList())); - return CompositeTextMapPropagator.create(this.tracingProperties.getPropagation(), baggagePropagator); - } - - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(prefix = "management.tracing.baggage.correlation", name = "enabled", - matchIfMissing = true) - Slf4JBaggageEventListener otelSlf4JBaggageEventListener() { - return new Slf4JBaggageEventListener(this.tracingProperties.getBaggage().getCorrelation().getFields()); - } - - } - - @Configuration(proxyBeanMethods = false) - @ConditionalOnProperty(prefix = "management.tracing.baggage", name = "enabled", havingValue = "false") - static class NoBaggageConfiguration { - - @Bean - TextMapPropagator textMapPropagator(TracingProperties properties) { - return CompositeTextMapPropagator.create(properties.getPropagation(), null); - } - - } - static class OTelEventPublisher implements EventPublisher { private final List listeners; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryPropagationConfigurations.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryPropagationConfigurations.java new file mode 100644 index 000000000000..e35e132a5592 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryPropagationConfigurations.java @@ -0,0 +1,105 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.tracing; + +import java.util.List; + +import io.micrometer.tracing.otel.bridge.OtelBaggageManager; +import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext; +import io.micrometer.tracing.otel.bridge.Slf4JBaggageEventListener; +import io.micrometer.tracing.otel.propagation.BaggageTextMapPropagator; +import io.opentelemetry.context.propagation.TextMapPropagator; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenTelemetry propagation configurations. They are imported by + * {@link OpenTelemetryAutoConfiguration}. + * + * @author Moritz Halbritter + */ +class OpenTelemetryPropagationConfigurations { + + /** + * Propagates traces but no baggage. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "management.tracing.baggage", name = "enabled", havingValue = "false") + @EnableConfigurationProperties(TracingProperties.class) + static class PropagationWithoutBaggage { + + @Bean + @ConditionalOnEnabledTracing + TextMapPropagator textMapPropagator(TracingProperties properties) { + return CompositeTextMapPropagator.create(properties.getPropagation(), null); + } + + } + + /** + * Propagates traces and baggage. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "management.tracing.baggage", name = "enabled", matchIfMissing = true) + @EnableConfigurationProperties(TracingProperties.class) + static class PropagationWithBaggage { + + private final TracingProperties tracingProperties; + + PropagationWithBaggage(TracingProperties tracingProperties) { + this.tracingProperties = tracingProperties; + } + + @Bean + @ConditionalOnEnabledTracing + TextMapPropagator textMapPropagatorWithBaggage(OtelCurrentTraceContext otelCurrentTraceContext) { + List remoteFields = this.tracingProperties.getBaggage().getRemoteFields(); + List tagFields = this.tracingProperties.getBaggage().getTagFields(); + BaggageTextMapPropagator baggagePropagator = new BaggageTextMapPropagator(remoteFields, + new OtelBaggageManager(otelCurrentTraceContext, remoteFields, tagFields)); + return CompositeTextMapPropagator.create(this.tracingProperties.getPropagation(), baggagePropagator); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "management.tracing.baggage.correlation", name = "enabled", + matchIfMissing = true) + Slf4JBaggageEventListener otelSlf4JBaggageEventListener() { + return new Slf4JBaggageEventListener(this.tracingProperties.getBaggage().getCorrelation().getFields()); + } + + } + + /** + * Propagates neither traces nor baggage. + */ + @Configuration(proxyBeanMethods = false) + static class NoPropagation { + + @Bean + @ConditionalOnMissingBean + TextMapPropagator noopTextMapPropagator() { + return TextMapPropagator.noop(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExporters.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExporters.java new file mode 100644 index 000000000000..a44f8ce0e035 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExporters.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; + +import io.opentelemetry.sdk.trace.export.SpanExporter; + +import org.springframework.util.Assert; + +/** + * A collection of {@link SpanExporter span exporters}. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +@FunctionalInterface +public interface SpanExporters extends Iterable { + + /** + * Returns the list of {@link SpanExporter span exporters}. + * @return the list of span exporters + */ + List list(); + + @Override + default Iterator iterator() { + return list().iterator(); + } + + @Override + default Spliterator spliterator() { + return list().spliterator(); + } + + /** + * Constructs a {@link SpanExporters} instance with the given {@link SpanExporter span + * exporters}. + * @param spanExporters the span exporters + * @return the constructed {@link SpanExporters} instance + */ + static SpanExporters of(SpanExporter... spanExporters) { + return of(Arrays.asList(spanExporters)); + } + + /** + * Constructs a {@link SpanExporters} instance with the given list of + * {@link SpanExporter span exporters}. + * @param spanExporters the list of span exporters + * @return the constructed {@link SpanExporters} instance + */ + static SpanExporters of(Collection spanExporters) { + Assert.notNull(spanExporters, "SpanExporters must not be null"); + List copy = List.copyOf(spanExporters); + return () -> copy; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessors.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessors.java new file mode 100644 index 000000000000..ca8c55498d07 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessors.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; + +import io.opentelemetry.sdk.trace.SpanProcessor; + +import org.springframework.util.Assert; + +/** + * A collection of {@link SpanProcessor span processors}. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +@FunctionalInterface +public interface SpanProcessors extends Iterable { + + /** + * Returns the list of {@link SpanProcessor span processors}. + * @return the list of span processors + */ + List list(); + + @Override + default Iterator iterator() { + return list().iterator(); + } + + @Override + default Spliterator spliterator() { + return list().spliterator(); + } + + /** + * Constructs a {@link SpanProcessors} instance with the given {@link SpanProcessor + * span processors}. + * @param spanProcessors the span processors + * @return the constructed {@link SpanProcessors} instance + */ + static SpanProcessors of(SpanProcessor... spanProcessors) { + return of(Arrays.asList(spanProcessors)); + } + + /** + * Constructs a {@link SpanProcessors} instance with the given list of + * {@link SpanProcessor span processors}. + * @param spanProcessors the list of span processors + * @return the constructed {@link SpanProcessors} instance + */ + static SpanProcessors of(Collection spanProcessors) { + Assert.notNull(spanProcessors, "SpanProcessors must not be null"); + List copy = List.copyOf(spanProcessors); + return () -> copy; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/TracingProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/TracingProperties.java index c38ef94d1253..175ff03a8175 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/TracingProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/TracingProperties.java @@ -103,6 +103,17 @@ public static class Baggage { */ private List remoteFields = new ArrayList<>(); + /** + * List of fields that should be accessible within the JVM process but not + * propagated over the wire. Local fields are not supported with OpenTelemetry. + */ + private List localFields = new ArrayList<>(); + + /** + * List of fields that should automatically become tags. + */ + private List tagFields = new ArrayList<>(); + public boolean isEnabled() { return this.enabled; } @@ -123,10 +134,26 @@ public List getRemoteFields() { return this.remoteFields; } + public List getLocalFields() { + return this.localFields; + } + + public List getTagFields() { + return this.tagFields; + } + public void setRemoteFields(List remoteFields) { this.remoteFields = remoteFields; } + public void setLocalFields(List localFields) { + this.localFields = localFields; + } + + public void setTagFields(List tagFields) { + this.tagFields = tagFields; + } + public static class Correlation { /** @@ -241,7 +268,7 @@ public enum PropagationType { * B3 * multiple headers propagation. */ - B3_MULTI; + B3_MULTI } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java index c9493f0d0d99..abb3253f2f99 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfiguration.java @@ -16,22 +16,17 @@ package org.springframework.boot.actuate.autoconfigure.tracing.otlp; -import java.util.Map.Entry; - import io.micrometer.tracing.otel.bridge.OtelTracer; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; -import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; import io.opentelemetry.sdk.trace.SdkTracerProvider; -import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; /** * {@link EnableAutoConfiguration Auto-configuration} for OTLP. Brave does not support @@ -45,26 +40,14 @@ * define an {@link OtlpGrpcSpanExporter} and this auto-configuration will back off. * * @author Jonatan Ivanov + * @author Moritz Halbritter + * @author Eddú Meléndez * @since 3.1.0 */ @AutoConfiguration -@ConditionalOnEnabledTracing @ConditionalOnClass({ OtelTracer.class, SdkTracerProvider.class, OpenTelemetry.class, OtlpHttpSpanExporter.class }) @EnableConfigurationProperties(OtlpProperties.class) +@Import({ OtlpTracingConfigurations.ConnectionDetails.class, OtlpTracingConfigurations.Exporters.class }) public class OtlpAutoConfiguration { - @Bean - @ConditionalOnMissingBean(value = OtlpHttpSpanExporter.class, - type = "io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter") - OtlpHttpSpanExporter otlpHttpSpanExporter(OtlpProperties properties) { - OtlpHttpSpanExporterBuilder builder = OtlpHttpSpanExporter.builder() - .setEndpoint(properties.getEndpoint()) - .setTimeout(properties.getTimeout()) - .setCompression(properties.getCompression().name().toLowerCase()); - for (Entry header : properties.getHeaders().entrySet()) { - builder.addHeader(header.getKey(), header.getValue()); - } - return builder.build(); - } - } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java index efb1a32f7553..371de8491146 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpProperties.java @@ -34,7 +34,7 @@ public class OtlpProperties { /** * URL to the OTel collector's HTTP API. */ - private String endpoint = "http://localhost:4318/v1/traces"; + private String endpoint; /** * Call timeout for the OTel Collector to process an exported batch of data. This diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java new file mode 100644 index 000000000000..492e43792d02 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConfigurations.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing.otlp; + +import java.util.Map.Entry; + +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; + +import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configurations imported by {@link OtlpAutoConfiguration}. + * + * @author Moritz Halbritter + */ +class OtlpTracingConfigurations { + + @Configuration(proxyBeanMethods = false) + static class ConnectionDetails { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "management.otlp.tracing", name = "endpoint") + OtlpTracingConnectionDetails otlpTracingConnectionDetails(OtlpProperties properties) { + return new PropertiesOtlpTracingConnectionDetails(properties); + } + + /** + * Adapts {@link OtlpProperties} to {@link OtlpTracingConnectionDetails}. + */ + static class PropertiesOtlpTracingConnectionDetails implements OtlpTracingConnectionDetails { + + private final OtlpProperties properties; + + PropertiesOtlpTracingConnectionDetails(OtlpProperties properties) { + this.properties = properties; + } + + @Override + public String getUrl() { + return this.properties.getEndpoint(); + } + + } + + } + + @Configuration(proxyBeanMethods = false) + static class Exporters { + + @Bean + @ConditionalOnMissingBean(value = OtlpHttpSpanExporter.class, + type = "io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter") + @ConditionalOnBean(OtlpTracingConnectionDetails.class) + @ConditionalOnEnabledTracing + OtlpHttpSpanExporter otlpHttpSpanExporter(OtlpProperties properties, + OtlpTracingConnectionDetails connectionDetails) { + OtlpHttpSpanExporterBuilder builder = OtlpHttpSpanExporter.builder() + .setEndpoint(connectionDetails.getUrl()) + .setTimeout(properties.getTimeout()) + .setCompression(properties.getCompression().name().toLowerCase()); + for (Entry header : properties.getHeaders().entrySet()) { + builder.addHeader(header.getKey(), header.getValue()); + } + return builder.build(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConnectionDetails.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConnectionDetails.java new file mode 100644 index 000000000000..a84b11d64da3 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpTracingConnectionDetails.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing.otlp; + +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; + +/** + * Details required to establish a connection to an OpenTelemetry service. + * + * @author Eddú Meléndez + * @since 3.2.0 + */ +public interface OtlpTracingConnectionDetails extends ConnectionDetails { + + /** + * Address to where tracing will be published. + * @return the address to where tracing will be published + */ + String getUrl(); + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfiguration.java index 9032a0712ad5..4176d2c2462b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -22,7 +22,6 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.autoconfigure.metrics.export.prometheus.PrometheusMetricsExportAutoConfiguration; -import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing; import org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -43,7 +42,6 @@ after = MicrometerTracingAutoConfiguration.class) @ConditionalOnBean(Tracer.class) @ConditionalOnClass({ Tracer.class, SpanContextSupplier.class }) -@ConditionalOnEnabledTracing public class PrometheusExemplarsAutoConfiguration { @Bean diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfiguration.java index 81a0dd0863c3..f3fab744bcc4 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfiguration.java @@ -52,7 +52,6 @@ @AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class, WavefrontAutoConfiguration.class }) @ConditionalOnClass({ WavefrontSender.class, WavefrontSpanHandler.class }) -@ConditionalOnEnabledTracing @EnableConfigurationProperties(WavefrontProperties.class) @Import(WavefrontSenderConfiguration.class) public class WavefrontTracingAutoConfiguration { @@ -60,6 +59,7 @@ public class WavefrontTracingAutoConfiguration { @Bean @ConditionalOnMissingBean @ConditionalOnBean(WavefrontSender.class) + @ConditionalOnEnabledTracing WavefrontSpanHandler wavefrontSpanHandler(WavefrontProperties properties, WavefrontSender wavefrontSender, SpanMetrics spanMetrics, ApplicationTags applicationTags) { return new WavefrontSpanHandler(properties.getSender().getMaxQueueSize(), wavefrontSender, spanMetrics, @@ -96,6 +96,7 @@ static class WavefrontBrave { @Bean @ConditionalOnMissingBean + @ConditionalOnEnabledTracing WavefrontBraveSpanHandler wavefrontBraveSpanHandler(WavefrontSpanHandler wavefrontSpanHandler) { return new WavefrontBraveSpanHandler(wavefrontSpanHandler); } @@ -108,6 +109,7 @@ static class WavefrontOpenTelemetry { @Bean @ConditionalOnMissingBean + @ConditionalOnEnabledTracing WavefrontOtelSpanExporter wavefrontOtelSpanExporter(WavefrontSpanHandler wavefrontSpanHandler) { return new WavefrontOtelSpanExporter(wavefrontSpanHandler); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfiguration.java index 971b9d514ec1..daff635f8631 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfiguration.java @@ -21,7 +21,6 @@ import zipkin2.codec.SpanBytesEncoder; import zipkin2.reporter.Sender; -import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing; import org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinConfigurations.BraveConfiguration; import org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinConfigurations.OpenTelemetryConfiguration; import org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinConfigurations.ReporterConfiguration; @@ -48,7 +47,6 @@ @ConditionalOnClass(Sender.class) @Import({ SenderConfiguration.class, ReporterConfiguration.class, BraveConfiguration.class, OpenTelemetryConfiguration.class }) -@ConditionalOnEnabledTracing @EnableConfigurationProperties(ZipkinProperties.class) public class ZipkinAutoConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurations.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurations.java index 722b502befa5..f4ecc9503125 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurations.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurations.java @@ -26,6 +26,7 @@ import zipkin2.reporter.urlconnection.URLConnectionSender; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -118,7 +119,8 @@ ZipkinWebClientSender webClientSender(ZipkinProperties properties, .getIfAvailable(() -> new PropertiesZipkinConnectionDetails(properties)); WebClient.Builder builder = WebClient.builder(); customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); - return new ZipkinWebClientSender(connectionDetails.getSpanEndpoint(), builder.build()); + return new ZipkinWebClientSender(connectionDetails.getSpanEndpoint(), builder.build(), + properties.getConnectTimeout().plus(properties.getReadTimeout())); } } @@ -142,6 +144,7 @@ static class BraveConfiguration { @Bean @ConditionalOnMissingBean @ConditionalOnBean(Reporter.class) + @ConditionalOnEnabledTracing ZipkinSpanHandler zipkinSpanHandler(Reporter spanReporter) { return (ZipkinSpanHandler) ZipkinSpanHandler.newBuilder(spanReporter).build(); } @@ -155,6 +158,7 @@ static class OpenTelemetryConfiguration { @Bean @ConditionalOnMissingBean @ConditionalOnBean(Sender.class) + @ConditionalOnEnabledTracing ZipkinSpanExporter zipkinSpanExporter(BytesEncoder encoder, Sender sender) { return ZipkinSpanExporter.builder().setEncoder(encoder).setSender(sender).build(); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSender.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSender.java index b9992bd5754d..2ef8cb74c09a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSender.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSender.java @@ -16,6 +16,8 @@ package org.springframework.boot.actuate.autoconfigure.tracing.zipkin; +import java.time.Duration; + import reactor.core.publisher.Mono; import zipkin2.Call; import zipkin2.Callback; @@ -28,6 +30,7 @@ * An {@link HttpSender} which uses {@link WebClient} for HTTP communication. * * @author Stefan Bratanov + * @author Moritz Halbritter */ class ZipkinWebClientSender extends HttpSender { @@ -35,14 +38,17 @@ class ZipkinWebClientSender extends HttpSender { private final WebClient webClient; - ZipkinWebClientSender(String endpoint, WebClient webClient) { + private final Duration timeout; + + ZipkinWebClientSender(String endpoint, WebClient webClient, Duration timeout) { this.endpoint = endpoint; this.webClient = webClient; + this.timeout = timeout; } @Override public HttpPostCall sendSpans(byte[] batchedEncodedSpans) { - return new WebClientHttpPostCall(this.endpoint, batchedEncodedSpans, this.webClient); + return new WebClientHttpPostCall(this.endpoint, batchedEncodedSpans, this.webClient, this.timeout); } private static class WebClientHttpPostCall extends HttpPostCall { @@ -51,15 +57,18 @@ private static class WebClientHttpPostCall extends HttpPostCall { private final WebClient webClient; - WebClientHttpPostCall(String endpoint, byte[] body, WebClient webClient) { + private final Duration timeout; + + WebClientHttpPostCall(String endpoint, byte[] body, WebClient webClient, Duration timeout) { super(body); this.endpoint = endpoint; this.webClient = webClient; + this.timeout = timeout; } @Override public Call clone() { - return new WebClientHttpPostCall(this.endpoint, getUncompressedBody(), this.webClient); + return new WebClientHttpPostCall(this.endpoint, getUncompressedBody(), this.webClient, this.timeout); } @Override @@ -79,7 +88,8 @@ private Mono> sendRequest() { .headers(this::addDefaultHeaders) .bodyValue(getBody()) .retrieve() - .toBodilessEntity(); + .toBodilessEntity() + .timeout(this.timeout); } private void addDefaultHeaders(HttpHeaders headers) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontProperties.java index 19c66c0071c5..ce313c3360d1 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontProperties.java @@ -25,6 +25,8 @@ import java.util.Map; import java.util.Set; +import com.wavefront.sdk.common.clients.service.token.TokenService.Type; + import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.PushRegistryProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; @@ -57,6 +59,11 @@ public class WavefrontProperties { */ private String apiToken; + /** + * Type of the API token. + */ + private TokenType apiTokenType; + /** * Application configuration. */ @@ -132,7 +139,7 @@ public URI getEffectiveUri() { * @return the API token */ public String getApiTokenOrThrow() { - if (this.apiToken == null && !usesProxy()) { + if (this.apiTokenType != TokenType.NO_TOKEN && this.apiToken == null && !usesProxy()) { throw new InvalidConfigurationPropertyValueException("management.wavefront.api-token", null, "This property is mandatory whenever publishing directly to the Wavefront API"); } @@ -167,6 +174,31 @@ public void setTraceDerivedCustomTagKeys(Set traceDerivedCustomTagKeys) this.traceDerivedCustomTagKeys = traceDerivedCustomTagKeys; } + public TokenType getApiTokenType() { + return this.apiTokenType; + } + + public void setApiTokenType(TokenType apiTokenType) { + this.apiTokenType = apiTokenType; + } + + /** + * Returns the {@link Type Wavefront token type}. + * @return the Wavefront token type + * @since 3.2.0 + */ + public Type getWavefrontApiTokenType() { + if (this.apiTokenType == null) { + return usesProxy() ? Type.NO_TOKEN : Type.WAVEFRONT_API_TOKEN; + } + return switch (this.apiTokenType) { + case NO_TOKEN -> Type.NO_TOKEN; + case WAVEFRONT_API_TOKEN -> Type.WAVEFRONT_API_TOKEN; + case CSP_API_TOKEN -> Type.CSP_API_TOKEN; + case CSP_CLIENT_CREDENTIALS -> Type.CSP_CLIENT_CREDENTIALS; + }; + } + public static class Application { /** @@ -385,4 +417,30 @@ public void setReportDayDistribution(boolean reportDayDistribution) { } + /** + * Wavefront token type. + * + * @since 3.2.0 + */ + public enum TokenType { + + /** + * No token. + */ + NO_TOKEN, + /** + * Wavefront API token. + */ + WAVEFRONT_API_TOKEN, + /** + * CSP API token. + */ + CSP_API_TOKEN, + /** + * CSP client credentials. + */ + CSP_CLIENT_CREDENTIALS + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfiguration.java index 6cb11ae31df3..f7ffa4d06929 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -21,13 +21,17 @@ import com.wavefront.sdk.common.WavefrontSender; import com.wavefront.sdk.common.clients.WavefrontClient.Builder; +import org.springframework.boot.actuate.autoconfigure.metrics.export.ConditionalOnEnabledMetricsExport; import org.springframework.boot.actuate.autoconfigure.metrics.export.wavefront.WavefrontMetricsExportAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.tracing.ConditionalOnEnabledTracing; import org.springframework.boot.actuate.autoconfigure.tracing.wavefront.WavefrontTracingAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.util.unit.DataSize; @@ -46,8 +50,10 @@ public class WavefrontSenderConfiguration { @Bean @ConditionalOnMissingBean + @Conditional(WavefrontTracingOrMetricsCondition.class) public WavefrontSender wavefrontSender(WavefrontProperties properties) { - Builder builder = new Builder(properties.getEffectiveUri().toString(), properties.getApiTokenOrThrow()); + Builder builder = new Builder(properties.getEffectiveUri().toString(), properties.getWavefrontApiTokenType(), + properties.getApiTokenOrThrow()); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); WavefrontProperties.Sender sender = properties.getSender(); map.from(sender.getMaxQueueSize()).to(builder::maxQueueSize); @@ -57,4 +63,22 @@ public WavefrontSender wavefrontSender(WavefrontProperties properties) { return builder.build(); } + static final class WavefrontTracingOrMetricsCondition extends AnyNestedCondition { + + WavefrontTracingOrMetricsCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnEnabledTracing + static class TracingCondition { + + } + + @ConditionalOnEnabledMetricsExport("wavefront") + static class MetricsCondition { + + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/ManagementContextFactory.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/ManagementContextFactory.java index 0871218563fb..4b98cdfc51a7 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/ManagementContextFactory.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/ManagementContextFactory.java @@ -60,8 +60,8 @@ public ConfigurableApplicationContext createManagementContext(ApplicationContext Environment parentEnvironment = parentContext.getEnvironment(); ConfigurableEnvironment childEnvironment = ApplicationContextFactory.DEFAULT .createEnvironment(this.webApplicationType); - if (parentEnvironment instanceof ConfigurableEnvironment) { - childEnvironment.setConversionService(((ConfigurableEnvironment) parentEnvironment).getConversionService()); + if (parentEnvironment instanceof ConfigurableEnvironment configurableEnvironment) { + childEnvironment.setConversionService((configurableEnvironment).getConversionService()); } ConfigurableApplicationContext managementContext = ApplicationContextFactory.DEFAULT .create(this.webApplicationType); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/reactive/ReactiveManagementChildContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/reactive/ReactiveManagementChildContextConfiguration.java index 657f9dbda20d..d28ec5d41561 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/reactive/ReactiveManagementChildContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/reactive/ReactiveManagementChildContextConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -26,8 +26,10 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ManagementWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.autoconfigure.web.embedded.JettyVirtualThreadsWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.embedded.JettyWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.embedded.NettyWebServerFactoryCustomizer; +import org.springframework.boot.autoconfigure.web.embedded.TomcatVirtualThreadsWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.embedded.TomcatWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.embedded.UndertowWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryCustomizer; @@ -76,8 +78,10 @@ static class ReactiveManagementWebServerFactoryCustomizer ReactiveManagementWebServerFactoryCustomizer(ListableBeanFactory beanFactory) { super(beanFactory, ReactiveWebServerFactoryCustomizer.class, TomcatWebServerFactoryCustomizer.class, - TomcatReactiveWebServerFactoryCustomizer.class, JettyWebServerFactoryCustomizer.class, - UndertowWebServerFactoryCustomizer.class, NettyWebServerFactoryCustomizer.class); + TomcatReactiveWebServerFactoryCustomizer.class, + TomcatVirtualThreadsWebServerFactoryCustomizer.class, JettyWebServerFactoryCustomizer.class, + JettyVirtualThreadsWebServerFactoryCustomizer.class, UndertowWebServerFactoryCustomizer.class, + NettyWebServerFactoryCustomizer.class); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/server/ChildManagementContextInitializer.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/server/ChildManagementContextInitializer.java index a4d423dfce39..e580d5dfcd1c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/server/ChildManagementContextInitializer.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/server/ChildManagementContextInitializer.java @@ -33,12 +33,14 @@ import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; import org.springframework.boot.context.event.ApplicationFailedEvent; import org.springframework.boot.web.context.ConfigurableWebServerApplicationContext; -import org.springframework.boot.web.context.WebServerInitializedEvent; +import org.springframework.boot.web.context.WebServerApplicationContext; +import org.springframework.boot.web.context.WebServerGracefulShutdownLifecycle; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.SmartLifecycle; import org.springframework.context.annotation.AnnotationConfigRegistry; import org.springframework.context.aot.ApplicationContextAotGenerator; import org.springframework.context.event.ContextClosedEvent; @@ -55,8 +57,7 @@ * @author Andy Wilkinson * @author Phillip Webb */ -class ChildManagementContextInitializer - implements ApplicationListener, BeanRegistrationAotProcessor { +class ChildManagementContextInitializer implements BeanRegistrationAotProcessor, SmartLifecycle { private final ManagementContextFactory managementContextFactory; @@ -64,6 +65,8 @@ class ChildManagementContextInitializer private final ApplicationContextInitializer applicationContextInitializer; + private volatile ConfigurableApplicationContext managementContext; + ChildManagementContextInitializer(ManagementContextFactory managementContextFactory, ApplicationContext parentContext) { this(managementContextFactory, parentContext, null); @@ -79,14 +82,38 @@ private ChildManagementContextInitializer(ManagementContextFactory managementCon } @Override - public void onApplicationEvent(WebServerInitializedEvent event) { - if (event.getApplicationContext().equals(this.parentContext)) { + public void start() { + if (!(this.parentContext instanceof WebServerApplicationContext)) { + return; + } + if (this.managementContext == null) { ConfigurableApplicationContext managementContext = createManagementContext(); registerBeans(managementContext); managementContext.refresh(); + this.managementContext = managementContext; + } + else { + this.managementContext.start(); + } + } + + @Override + public void stop() { + if (this.managementContext != null) { + this.managementContext.stop(); } } + @Override + public boolean isRunning() { + return this.managementContext != null && this.managementContext.isRunning(); + } + + @Override + public int getPhase() { + return WebServerGracefulShutdownLifecycle.SMART_LIFECYCLE_PHASE + 512; + } + @Override public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) { Assert.isInstanceOf(ConfigurableApplicationContext.class, this.parentContext); @@ -217,8 +244,8 @@ private void propagateCloseIfNecessary(ApplicationContext applicationContext) { } static void addIfPossible(ApplicationContext parentContext, ConfigurableApplicationContext childContext) { - if (parentContext instanceof ConfigurableApplicationContext) { - add((ConfigurableApplicationContext) parentContext, childContext); + if (parentContext instanceof ConfigurableApplicationContext configurableApplicationContext) { + add(configurableApplicationContext, childContext); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ManagementErrorEndpoint.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ManagementErrorEndpoint.java index da3da1131938..2759ca4d1345 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ManagementErrorEndpoint.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ManagementErrorEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,6 +36,7 @@ * * @author Dave Syer * @author Scott Frederick + * @author Moritz Halbritter * @since 2.0.0 */ @Controller @@ -72,6 +73,7 @@ private ErrorAttributeOptions getErrorAttributeOptions(ServletWebRequest request if (includeBindingErrors(request)) { options = options.including(Include.BINDING_ERRORS); } + options = includePath(request) ? options.including(Include.PATH) : options.excluding(Include.PATH); return options; } @@ -79,7 +81,7 @@ private boolean includeStackTrace(ServletWebRequest request) { return switch (this.errorProperties.getIncludeStacktrace()) { case ALWAYS -> true; case ON_PARAM -> getBooleanParameter(request, "trace"); - default -> false; + case NEVER -> false; }; } @@ -87,7 +89,7 @@ private boolean includeMessage(ServletWebRequest request) { return switch (this.errorProperties.getIncludeMessage()) { case ALWAYS -> true; case ON_PARAM -> getBooleanParameter(request, "message"); - default -> false; + case NEVER -> false; }; } @@ -95,7 +97,15 @@ private boolean includeBindingErrors(ServletWebRequest request) { return switch (this.errorProperties.getIncludeBindingErrors()) { case ALWAYS -> true; case ON_PARAM -> getBooleanParameter(request, "errors"); - default -> false; + case NEVER -> false; + }; + } + + private boolean includePath(ServletWebRequest request) { + return switch (this.errorProperties.getIncludePath()) { + case ALWAYS -> true; + case ON_PARAM -> getBooleanParameter(request, "path"); + case NEVER -> false; }; } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ServletManagementChildContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ServletManagementChildContextConfiguration.java index 44de3225efc5..71beda39b6ba 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ServletManagementChildContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/web/servlet/ServletManagementChildContextConfiguration.java @@ -39,7 +39,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.autoconfigure.condition.SearchStrategy; import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.autoconfigure.web.embedded.JettyVirtualThreadsWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.embedded.JettyWebServerFactoryCustomizer; +import org.springframework.boot.autoconfigure.web.embedded.TomcatVirtualThreadsWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.embedded.TomcatWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.embedded.UndertowWebServerFactoryCustomizer; import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryCustomizer; @@ -122,7 +124,8 @@ static class ServletManagementWebServerFactoryCustomizer ServletManagementWebServerFactoryCustomizer(ListableBeanFactory beanFactory) { super(beanFactory, ServletWebServerFactoryCustomizer.class, TomcatServletWebServerFactoryCustomizer.class, - TomcatWebServerFactoryCustomizer.class, JettyWebServerFactoryCustomizer.class, + TomcatWebServerFactoryCustomizer.class, TomcatVirtualThreadsWebServerFactoryCustomizer.class, + JettyWebServerFactoryCustomizer.class, JettyVirtualThreadsWebServerFactoryCustomizer.class, UndertowServletWebServerFactoryCustomizer.class, UndertowWebServerFactoryCustomizer.class); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index c13d14622e4b..aa2900f90990 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -309,6 +309,12 @@ "description": "Whether to enable Operating System info.", "defaultValue": false }, + { + "name": "management.info.process.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable process info.", + "defaultValue": false + }, { "name": "management.metrics.binders.files.enabled", "type": "java.lang.Boolean", @@ -1987,11 +1993,19 @@ "reason": "Should be applied at the ObservationRegistry level." } }, + { + "name": "management.metrics.web.client.request.metric-name", + "type": "java.lang.String", + "deprecation": { + "replacement": "management.observations.http.client.requests.name", + "level": "error" + } + }, { "name": "management.metrics.web.client.requests-metric-name", "type": "java.lang.String", "deprecation": { - "replacement": "management.metrics.web.client.request.metric-name", + "replacement": "management.observations.http.client.requests.name", "level": "error" } }, @@ -2037,14 +2051,26 @@ "reason": "Not needed anymore, direct instrumentation in Spring MVC." } }, + { + "name": "management.metrics.web.server.request.metric-name", + "type": "java.lang.String", + "deprecation": { + "replacement": "management.observations.http.server.requests.name", + "level": "error" + } + }, { "name": "management.metrics.web.server.requests-metric-name", "type": "java.lang.String", "deprecation": { - "replacement": "management.metrics.web.server.request.metric-name", + "replacement": "management.observations.http.server.requests.name", "level": "error" } }, + { + "name": "management.otlp.metrics.export.base-time-unit", + "defaultValue": "milliseconds" + }, { "name": "management.otlp.tracing.compression", "defaultValue": "none" @@ -2209,6 +2235,12 @@ "defaultValue": [ "W3C" ] + }, + { + "name": "micrometer.observations.annotations.enabled", + "type": "java.lang.Boolean", + "description": "Whether auto-configuration of Micrometer annotations is enabled.", + "defaultValue": false } ], "hints": [ diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories index 4ecd02ee5689..a32bb38f5a58 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories @@ -2,3 +2,7 @@ org.springframework.boot.diagnostics.FailureAnalyzer=\ org.springframework.boot.actuate.autoconfigure.metrics.ValidationFailureAnalyzer,\ org.springframework.boot.actuate.autoconfigure.health.NoSuchHealthContributorFailureAnalyzer + +# Environment Post Processors +org.springframework.boot.env.EnvironmentPostProcessor=\ +org.springframework.boot.actuate.autoconfigure.tracing.LogCorrelationEnvironmentPostProcessor diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index affbe7607f9e..7801946776fc 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -43,6 +43,7 @@ org.springframework.boot.actuate.autoconfigure.metrics.JvmMetricsAutoConfigurati org.springframework.boot.actuate.autoconfigure.metrics.KafkaMetricsAutoConfiguration org.springframework.boot.actuate.autoconfigure.metrics.Log4J2MetricsAutoConfiguration org.springframework.boot.actuate.autoconfigure.metrics.LogbackMetricsAutoConfiguration +org.springframework.boot.actuate.autoconfigure.metrics.MetricsAspectsAutoConfiguration org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration org.springframework.boot.actuate.autoconfigure.metrics.MetricsEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.metrics.SystemMetricsAutoConfiguration @@ -88,11 +89,14 @@ org.springframework.boot.actuate.autoconfigure.data.mongo.MongoReactiveHealthCon org.springframework.boot.actuate.autoconfigure.neo4j.Neo4jHealthContributorAutoConfiguration org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration org.springframework.boot.actuate.autoconfigure.observation.web.servlet.WebMvcObservationAutoConfiguration +org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration org.springframework.boot.actuate.autoconfigure.quartz.QuartzEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.r2dbc.ConnectionFactoryHealthContributorAutoConfiguration +org.springframework.boot.actuate.autoconfigure.r2dbc.R2dbcObservationAutoConfiguration org.springframework.boot.actuate.autoconfigure.data.redis.RedisHealthContributorAutoConfiguration org.springframework.boot.actuate.autoconfigure.data.redis.RedisReactiveHealthContributorAutoConfiguration org.springframework.boot.actuate.autoconfigure.scheduling.ScheduledTasksEndpointAutoConfiguration +org.springframework.boot.actuate.autoconfigure.scheduling.ScheduledTasksObservabilityAutoConfiguration org.springframework.boot.actuate.autoconfigure.security.reactive.ReactiveManagementWebSecurityAutoConfiguration org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration org.springframework.boot.actuate.autoconfigure.session.SessionsEndpointAutoConfiguration @@ -100,6 +104,7 @@ org.springframework.boot.actuate.autoconfigure.startup.StartupEndpointAutoConfig org.springframework.boot.actuate.autoconfigure.system.DiskSpaceHealthContributorAutoConfiguration org.springframework.boot.actuate.autoconfigure.tracing.BraveAutoConfiguration org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration +org.springframework.boot.actuate.autoconfigure.tracing.NoopTracerAutoConfiguration org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration org.springframework.boot.actuate.autoconfigure.tracing.prometheus.PrometheusExemplarsAutoConfiguration @@ -111,4 +116,4 @@ org.springframework.boot.actuate.autoconfigure.web.exchanges.HttpExchangesEndpoi org.springframework.boot.actuate.autoconfigure.web.mappings.MappingsEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.web.reactive.ReactiveManagementContextAutoConfiguration org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration -org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration \ No newline at end of file +org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java index e6a689d0c48d..59a5b0d87ae0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java @@ -35,10 +35,13 @@ import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; -import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; import static org.assertj.core.api.Assertions.assertThat; @@ -52,15 +55,14 @@ class CloudFoundryReactiveHealthEndpointWebExtensionTests { private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() .withPropertyValues("VCAP_APPLICATION={}") .withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, - ReactiveUserDetailsServiceAutoConfiguration.class, WebFluxAutoConfiguration.class, - JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, - PropertyPlaceholderAutoConfiguration.class, + WebFluxAutoConfiguration.class, JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class, ReactiveCloudFoundryActuatorAutoConfigurationTests.WebClientCustomizerConfig.class, WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class, EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class, HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class, ReactiveCloudFoundryActuatorAutoConfiguration.class)) - .withUserConfiguration(TestHealthIndicator.class); + .withUserConfiguration(TestHealthIndicator.class, UserDetailsServiceConfiguration.class); @Test void healthComponentsAlwaysPresent() { @@ -82,4 +84,15 @@ public Health health() { } + @Configuration(proxyBeanMethods = false) + static class UserDetailsServiceConfiguration { + + @Bean + MapReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsService( + User.withUsername("alice").password("secret").roles("admin").build()); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java index 2f4c99c0bc3a..e9424f872c66 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java @@ -50,7 +50,6 @@ import org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; -import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; @@ -61,6 +60,8 @@ import org.springframework.http.HttpMethod; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.test.util.ReflectionTestUtils; @@ -84,15 +85,16 @@ class ReactiveCloudFoundryActuatorAutoConfigurationTests { private static final String V3_JSON = ApiVersion.V3.getProducedMimeType().toString(); private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, - ReactiveUserDetailsServiceAutoConfiguration.class, WebFluxAutoConfiguration.class, - JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, - PropertyPlaceholderAutoConfiguration.class, WebClientCustomizerConfig.class, - WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class, - EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class, - HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class, - InfoContributorAutoConfiguration.class, InfoEndpointAutoConfiguration.class, - ProjectInfoAutoConfiguration.class, ReactiveCloudFoundryActuatorAutoConfiguration.class)); + .withConfiguration( + AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, WebFluxAutoConfiguration.class, + JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, + PropertyPlaceholderAutoConfiguration.class, WebClientCustomizerConfig.class, + WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class, + EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class, + HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class, + InfoContributorAutoConfiguration.class, InfoEndpointAutoConfiguration.class, + ProjectInfoAutoConfiguration.class, ReactiveCloudFoundryActuatorAutoConfiguration.class)) + .withUserConfiguration(UserDetailsServiceConfiguration.class); private static final String BASE_PATH = "/cloudfoundryapplication"; @@ -358,4 +360,15 @@ WebClientCustomizer webClientCustomizer() { } + @Configuration(proxyBeanMethods = false) + static class UserDetailsServiceConfiguration { + + @Bean + MapReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsService( + User.withUsername("alice").password("secret").roles("admin").build()); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java index 29258b9f20aa..ac73c0fc21ca 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java @@ -51,13 +51,11 @@ class ReactiveCloudFoundrySecurityServiceTests { private MockWebServer server; - private WebClient.Builder builder; - @BeforeEach void setup() { this.server = new MockWebServer(); - this.builder = WebClient.builder().baseUrl(this.server.url("/").toString()); - this.securityService = new ReactiveCloudFoundrySecurityService(this.builder, CLOUD_CONTROLLER, false); + WebClient.Builder builder = WebClient.builder().baseUrl(this.server.url("/").toString()); + this.securityService = new ReactiveCloudFoundrySecurityService(builder, CLOUD_CONTROLLER, false); } @AfterEach @@ -183,7 +181,7 @@ void fetchTokenKeysWhenNoKeysReturnedFromUAA() throws Exception { response.setHeader("Content-Type", "application/json"); }); StepVerifier.create(this.securityService.fetchTokenKeys()) - .consumeNextWith((tokenKeys) -> assertThat(tokenKeys).hasSize(0)) + .consumeNextWith((tokenKeys) -> assertThat(tokenKeys).isEmpty()) .expectComplete() .verify(); expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info")); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java index dbd0b28dd2c5..067eea979426 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java @@ -18,7 +18,9 @@ import java.util.Arrays; import java.util.Collection; +import java.util.List; +import jakarta.servlet.Filter; import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; @@ -43,6 +45,7 @@ import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpMethod; @@ -55,6 +58,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.client.RestTemplate; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.filter.CompositeFilter; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -173,9 +177,7 @@ void cloudFoundryPathsIgnoredBySpringSecurity() { this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new) .withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id") .run((context) -> { - FilterChainProxy securityFilterChain = (FilterChainProxy) context - .getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN); - SecurityFilterChain chain = securityFilterChain.getFilterChains().get(0); + SecurityFilterChain chain = getSecurityFilterChain(context); assertThat(chain.getFilters()).isEmpty(); MockHttpServletRequest request = new MockHttpServletRequest(); testCloudFoundrySecurity(request, BASE_PATH, chain); @@ -189,6 +191,27 @@ void cloudFoundryPathsIgnoredBySpringSecurity() { }); } + private SecurityFilterChain getSecurityFilterChain(AssertableWebApplicationContext context) { + Filter springSecurityFilterChain = context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN, Filter.class); + FilterChainProxy filterChainProxy = getFilterChainProxy(springSecurityFilterChain); + SecurityFilterChain securityFilterChain = filterChainProxy.getFilterChains().get(0); + return securityFilterChain; + } + + private FilterChainProxy getFilterChainProxy(Filter filter) { + if (filter instanceof FilterChainProxy filterChainProxy) { + return filterChainProxy; + } + if (filter instanceof CompositeFilter) { + List filters = (List) ReflectionTestUtils.getField(filter, "filters"); + return (FilterChainProxy) filters.stream() + .filter(FilterChainProxy.class::isInstance) + .findFirst() + .orElseThrow(); + } + throw new IllegalStateException("No FilterChainProxy found"); + } + private static void testCloudFoundrySecurity(MockHttpServletRequest request, String servletPath, SecurityFilterChain chain) { request.setServletPath(servletPath); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/SessionsEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/SessionsEndpointDocumentationTests.java index c33020828cfd..cfdd12e317fb 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/SessionsEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/SessionsEndpointDocumentationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -124,7 +124,7 @@ static class TestConfiguration { @Bean SessionsEndpoint endpoint(FindByIndexNameSessionRepository sessionRepository) { - return new SessionsEndpoint(sessionRepository); + return new SessionsEndpoint(sessionRepository, sessionRepository); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfigurationReflectionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfigurationReflectionTests.java deleted file mode 100644 index c6100f971b7b..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeHealthContributorConfigurationReflectionTests.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.health; - -import org.springframework.boot.actuate.autoconfigure.health.CompositeHealthContributorConfigurationReflectionTests.TestHealthIndicator; -import org.springframework.boot.actuate.health.AbstractHealthIndicator; -import org.springframework.boot.actuate.health.Health.Builder; -import org.springframework.boot.actuate.health.HealthContributor; - -/** - * Tests for {@link CompositeHealthContributorConfiguration} using reflection to create - * indicator instances. - * - * @author Phillip Webb - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class CompositeHealthContributorConfigurationReflectionTests - extends AbstractCompositeHealthContributorConfigurationTests { - - @Override - protected AbstractCompositeHealthContributorConfiguration newComposite() { - return new ReflectiveTestCompositeHealthContributorConfiguration(); - } - - static class ReflectiveTestCompositeHealthContributorConfiguration - extends CompositeHealthContributorConfiguration { - - } - - static class TestHealthIndicator extends AbstractHealthIndicator { - - TestHealthIndicator(TestBean testBean) { - } - - @Override - protected void doHealthCheck(Builder builder) throws Exception { - builder.up(); - } - - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfigurationReflectionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfigurationReflectionTests.java deleted file mode 100644 index 183a3c7bd3a7..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/CompositeReactiveHealthContributorConfigurationReflectionTests.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.health; - -import reactor.core.publisher.Mono; - -import org.springframework.boot.actuate.autoconfigure.health.CompositeReactiveHealthContributorConfigurationReflectionTests.TestReactiveHealthIndicator; -import org.springframework.boot.actuate.health.AbstractReactiveHealthIndicator; -import org.springframework.boot.actuate.health.Health; -import org.springframework.boot.actuate.health.Health.Builder; -import org.springframework.boot.actuate.health.ReactiveHealthContributor; - -/** - * Tests for {@link CompositeReactiveHealthContributorConfiguration} using reflection to - * create indicator instances. - * - * @author Phillip Webb - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class CompositeReactiveHealthContributorConfigurationReflectionTests extends - AbstractCompositeHealthContributorConfigurationTests { - - @Override - protected AbstractCompositeHealthContributorConfiguration newComposite() { - return new TestCompositeReactiveHealthContributorConfiguration(); - } - - static class TestCompositeReactiveHealthContributorConfiguration - extends CompositeReactiveHealthContributorConfiguration { - - } - - static class TestReactiveHealthIndicator extends AbstractReactiveHealthIndicator { - - TestReactiveHealthIndicator(TestBean testBean) { - } - - @Override - protected Mono doHealthCheck(Builder builder) { - return Mono.just(builder.up().build()); - } - - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfigurationTests.java index 4ed923d5041f..64d2757f26dd 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/influx/InfluxDbHealthContributorAutoConfigurationTests.java @@ -32,6 +32,8 @@ * * @author Eddú Meléndez */ +@SuppressWarnings("removal") +@Deprecated(since = "3.2.0", forRemoval = true) class InfluxDbHealthContributorAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfigurationTests.java index 62801fdd6415..c9e5d73b5bdb 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/info/InfoContributorAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,11 +28,13 @@ import org.springframework.boot.actuate.info.InfoContributor; import org.springframework.boot.actuate.info.JavaInfoContributor; import org.springframework.boot.actuate.info.OsInfoContributor; +import org.springframework.boot.actuate.info.ProcessInfoContributor; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.info.BuildProperties; import org.springframework.boot.info.GitProperties; import org.springframework.boot.info.JavaInfo; import org.springframework.boot.info.OsInfo; +import org.springframework.boot.info.ProcessInfo; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -164,6 +166,16 @@ void osInfoContributor() { }); } + @Test + void processInfoContributor() { + this.contextRunner.withPropertyValues("management.info.process.enabled=true").run((context) -> { + assertThat(context).hasSingleBean(ProcessInfoContributor.class); + Map content = invokeContributor(context.getBean(ProcessInfoContributor.class)); + assertThat(content).containsKey("process"); + assertThat(content.get("process")).isInstanceOf(ProcessInfo.class); + }); + } + private Map invokeContributor(InfoContributor contributor) { Info.Builder builder = new Info.Builder(); contributor.contribute(builder); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java index f2d6c56aca3b..c131bd263a4e 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java @@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.actuate.autoconfigure.tracing.BraveAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration; import org.springframework.boot.actuate.health.HealthEndpointWebExtension; import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -40,6 +42,7 @@ import org.springframework.boot.context.annotation.UserConfigurations; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import static org.assertj.core.api.Assertions.assertThat; @@ -57,6 +60,7 @@ void healthEndpointWebExtensionIsAutoConfigured() { } @Test + @ClassPathExclusions({ "spring-security-oauth2-client-*.jar", "spring-security-oauth2-resource-server-*.jar" }) void healthEndpointReactiveWebExtensionIsAutoConfigured() { reactiveWebRunner() .run((context) -> assertThat(context).hasSingleBean(ReactiveHealthEndpointWebExtension.class)); @@ -80,7 +84,8 @@ private ReactiveWebApplicationContextRunner reactiveWebRunner() { MongoReactiveAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class, RepositoryRestMvcAutoConfiguration.class, HazelcastAutoConfiguration.class, ElasticsearchDataAutoConfiguration.class, RedisAutoConfiguration.class, - RedisRepositoriesAutoConfiguration.class }) + RedisRepositoriesAutoConfiguration.class, BraveAutoConfiguration.class, + OpenTelemetryAutoConfiguration.class }) @SpringBootConfiguration static class WebEndpointTestApplication { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfigurationTests.java new file mode 100644 index 000000000000..d2ecaf6f06d2 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAspectsAutoConfigurationTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.metrics; + +import io.micrometer.core.aop.CountedAspect; +import io.micrometer.core.aop.MeterTagAnnotationHandler; +import io.micrometer.core.aop.TimedAspect; +import io.micrometer.core.instrument.MeterRegistry; +import org.aspectj.weaver.Advice; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MetricsAspectsAutoConfiguration}. + * + * @author Jonatan Ivanov + */ +class MetricsAspectsAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().with(MetricsRun.simple()) + .withPropertyValues("micrometer.observations.annotations.enabled=true") + .withConfiguration(AutoConfigurations.of(MetricsAspectsAutoConfiguration.class)); + + @Test + void shouldNotConfigureAspectsByDefault() { + new ApplicationContextRunner().with(MetricsRun.simple()) + .withConfiguration(AutoConfigurations.of(MetricsAspectsAutoConfiguration.class)) + .run((context) -> { + assertThat(context).doesNotHaveBean(CountedAspect.class); + assertThat(context).doesNotHaveBean(TimedAspect.class); + }); + } + + @Test + void shouldConfigureAspects() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(CountedAspect.class); + assertThat(context).hasSingleBean(TimedAspect.class); + }); + } + + @Test + void shouldConfigureMeterTagAnnotationHandler() { + this.contextRunner.withUserConfiguration(MeterTagAnnotationHandlerConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(CountedAspect.class); + assertThat(ReflectionTestUtils.getField(context.getBean(TimedAspect.class), "meterTagAnnotationHandler")) + .isSameAs(context.getBean(MeterTagAnnotationHandler.class)); + }); + } + + @Test + void shouldNotConfigureAspectsIfMicrometerIsMissing() { + this.contextRunner.withClassLoader(new FilteredClassLoader(MeterRegistry.class)).run((context) -> { + assertThat(context).doesNotHaveBean(CountedAspect.class); + assertThat(context).doesNotHaveBean(TimedAspect.class); + }); + } + + @Test + void shouldNotConfigureAspectsIfAspectjIsMissing() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Advice.class)).run((context) -> { + assertThat(context).doesNotHaveBean(CountedAspect.class); + assertThat(context).doesNotHaveBean(TimedAspect.class); + }); + } + + @Test + void shouldNotConfigureAspectsIfMeterRegistryBeanIsMissing() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(MetricsAspectsAutoConfiguration.class)) + .run((context) -> { + assertThat(context).doesNotHaveBean(MeterRegistry.class); + assertThat(context).doesNotHaveBean(CountedAspect.class); + assertThat(context).doesNotHaveBean(TimedAspect.class); + }); + } + + @Test + void shouldBackOffIfAspectBeansExist() { + this.contextRunner.withUserConfiguration(CustomAspectsConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(CountedAspect.class).hasBean("customCountedAspect"); + assertThat(context).hasSingleBean(TimedAspect.class).hasBean("customTimedAspect"); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomAspectsConfiguration { + + @Bean + CountedAspect customCountedAspect(MeterRegistry registry) { + return new CountedAspect(registry); + } + + @Bean + TimedAspect customTimedAspect(MeterRegistry registry) { + return new TimedAspect(registry); + } + + } + + @Configuration(proxyBeanMethods = false) + static class MeterTagAnnotationHandlerConfiguration { + + @Bean + MeterTagAnnotationHandler meterTagAnnotationHandler() { + return new MeterTagAnnotationHandler(null, null); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfigurationTests.java index c04c31435a72..b7f4876bb3fe 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsAutoConfigurationTests.java @@ -25,6 +25,7 @@ import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration.MeterRegistryCloser; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; @@ -40,6 +41,7 @@ * Tests for {@link MetricsAutoConfiguration}. * * @author Andy Wilkinson + * @author Moritz Halbritter */ class MetricsAutoConfigurationTests { @@ -72,6 +74,21 @@ void configuresMeterRegistries() { }); } + @Test + void shouldSupplyMeterRegistryCloser() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(MeterRegistryCloser.class)); + } + + @Test + void meterRegistryCloserShouldCloseRegistryOnShutdown() { + this.contextRunner.withUserConfiguration(MeterRegistryConfiguration.class).run((context) -> { + MeterRegistry meterRegistry = context.getBean(MeterRegistry.class); + assertThat(meterRegistry.isClosed()).isFalse(); + context.close(); + assertThat(meterRegistry.isClosed()).isTrue(); + }); + } + @Configuration(proxyBeanMethods = false) static class CustomClockConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesTests.java index 69f3651bc30b..5a784ead6dd1 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/dynatrace/DynatracePropertiesTests.java @@ -38,6 +38,7 @@ void defaultValuesAreConsistent() { assertThat(properties.getV1().getTechnologyType()).isEqualTo(config.technologyType()); assertThat(properties.getV2().isUseDynatraceSummaryInstruments()) .isEqualTo(config.useDynatraceSummaryInstruments()); + assertThat(properties.getV2().isExportMeterMetadata()).isEqualTo(config.exportMeterMetadata()); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfigurationTests.java index 09752dd0c7ff..f303e4a2cfdc 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsExportAutoConfigurationTests.java @@ -21,6 +21,7 @@ import io.micrometer.registry.otlp.OtlpMeterRegistry; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration.PropertiesOtlpMetricsConnectionDetails; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; @@ -83,6 +84,23 @@ void allowsRegistryToBeCustomized() { .hasBean("customRegistry")); } + @Test + void definesPropertiesBasedConnectionDetailsByDefault() { + this.contextRunner.withUserConfiguration(BaseConfiguration.class) + .run((context) -> assertThat(context).hasSingleBean(PropertiesOtlpMetricsConnectionDetails.class)); + } + + @Test + void testConnectionFactoryWithOverridesWhenUsingCustomConnectionDetails() { + this.contextRunner.withUserConfiguration(BaseConfiguration.class, ConnectionDetailsConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(OtlpMetricsConnectionDetails.class) + .doesNotHaveBean(PropertiesOtlpMetricsConnectionDetails.class); + OtlpConfig config = context.getBean(OtlpConfig.class); + assertThat(config.url()).isEqualTo("http://localhost:12345/v1/metrics"); + }); + } + @Configuration(proxyBeanMethods = false) static class BaseConfiguration { @@ -115,4 +133,14 @@ OtlpMeterRegistry customRegistry(OtlpConfig config, Clock clock) { } + @Configuration(proxyBeanMethods = false) + static class ConnectionDetailsConfiguration { + + @Bean + OtlpMetricsConnectionDetails otlpConnectionDetails() { + return () -> "http://localhost:12345/v1/metrics"; + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapterTests.java index d2fc02a7f412..dd6c3f199204 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapterTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesConfigAdapterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,55 +16,132 @@ package org.springframework.boot.actuate.autoconfigure.metrics.export.otlp; +import java.util.Collections; import java.util.Map; +import java.util.concurrent.TimeUnit; import io.micrometer.registry.otlp.AggregationTemporality; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration.PropertiesOtlpMetricsConnectionDetails; +import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryProperties; +import org.springframework.mock.env.MockEnvironment; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; /** * Tests for {@link OtlpPropertiesConfigAdapter}. * * @author Eddú Meléndez + * @author Moritz Halbritter */ class OtlpPropertiesConfigAdapterTests { + private OtlpProperties properties; + + private OpenTelemetryProperties openTelemetryProperties; + + private MockEnvironment environment; + + private OtlpMetricsConnectionDetails connectionDetails; + + @BeforeEach + void setUp() { + this.properties = new OtlpProperties(); + this.openTelemetryProperties = new OpenTelemetryProperties(); + this.environment = new MockEnvironment(); + this.connectionDetails = new PropertiesOtlpMetricsConnectionDetails(this.properties); + } + @Test void whenPropertiesUrlIsSetAdapterUrlReturnsIt() { - OtlpProperties properties = new OtlpProperties(); - properties.setUrl("http://another-url:4318/v1/metrics"); - assertThat(new OtlpPropertiesConfigAdapter(properties).url()).isEqualTo("http://another-url:4318/v1/metrics"); + this.properties.setUrl("http://another-url:4318/v1/metrics"); + assertThat(createAdapter().url()).isEqualTo("http://another-url:4318/v1/metrics"); } @Test void whenPropertiesAggregationTemporalityIsNotSetAdapterAggregationTemporalityReturnsCumulative() { - OtlpProperties properties = new OtlpProperties(); - assertThat(new OtlpPropertiesConfigAdapter(properties).aggregationTemporality()) - .isSameAs(AggregationTemporality.CUMULATIVE); + assertThat(createAdapter().aggregationTemporality()).isSameAs(AggregationTemporality.CUMULATIVE); } @Test void whenPropertiesAggregationTemporalityIsSetAdapterAggregationTemporalityReturnsIt() { - OtlpProperties properties = new OtlpProperties(); - properties.setAggregationTemporality(AggregationTemporality.DELTA); - assertThat(new OtlpPropertiesConfigAdapter(properties).aggregationTemporality()) - .isSameAs(AggregationTemporality.DELTA); + this.properties.setAggregationTemporality(AggregationTemporality.DELTA); + assertThat(createAdapter().aggregationTemporality()).isSameAs(AggregationTemporality.DELTA); } @Test + @SuppressWarnings("removal") void whenPropertiesResourceAttributesIsSetAdapterResourceAttributesReturnsIt() { - OtlpProperties properties = new OtlpProperties(); - properties.setResourceAttributes(Map.of("service.name", "boot-service")); - assertThat(new OtlpPropertiesConfigAdapter(properties).resourceAttributes()).containsEntry("service.name", - "boot-service"); + this.properties.setResourceAttributes(Map.of("service.name", "boot-service")); + assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "boot-service"); } @Test void whenPropertiesHeadersIsSetAdapterHeadersReturnsIt() { - OtlpProperties properties = new OtlpProperties(); - properties.setHeaders(Map.of("header", "value")); - assertThat(new OtlpPropertiesConfigAdapter(properties).headers()).containsEntry("header", "value"); + this.properties.setHeaders(Map.of("header", "value")); + assertThat(createAdapter().headers()).containsEntry("header", "value"); + } + + @Test + void whenPropertiesBaseTimeUnitIsNotSetAdapterBaseTimeUnitReturnsMillis() { + assertThat(createAdapter().baseTimeUnit()).isSameAs(TimeUnit.MILLISECONDS); + } + + @Test + void whenPropertiesBaseTimeUnitIsSetAdapterBaseTimeUnitReturnsIt() { + this.properties.setBaseTimeUnit(TimeUnit.SECONDS); + assertThat(createAdapter().baseTimeUnit()).isSameAs(TimeUnit.SECONDS); + } + + @Test + @SuppressWarnings("removal") + void openTelemetryPropertiesShouldOverrideOtlpPropertiesIfNotEmpty() { + this.properties.setResourceAttributes(Map.of("a", "alpha")); + this.openTelemetryProperties.setResourceAttributes(Map.of("b", "beta")); + assertThat(createAdapter().resourceAttributes()).contains(entry("b", "beta")); + assertThat(createAdapter().resourceAttributes()).doesNotContain(entry("a", "alpha")); + } + + @Test + @SuppressWarnings("removal") + void openTelemetryPropertiesShouldNotOverrideOtlpPropertiesIfEmpty() { + this.properties.setResourceAttributes(Map.of("a", "alpha")); + this.openTelemetryProperties.setResourceAttributes(Collections.emptyMap()); + assertThat(createAdapter().resourceAttributes()).contains(entry("a", "alpha")); + } + + @Test + @SuppressWarnings("removal") + void serviceNameOverridesApplicationName() { + this.environment.setProperty("spring.application.name", "alpha"); + this.properties.setResourceAttributes(Map.of("service.name", "beta")); + assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "beta"); + } + + @Test + void serviceNameOverridesApplicationNameWhenUsingOtelProperties() { + this.environment.setProperty("spring.application.name", "alpha"); + this.openTelemetryProperties.setResourceAttributes(Map.of("service.name", "beta")); + assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "beta"); + } + + @Test + void shouldUseApplicationNameIfServiceNameIsNotSet() { + this.environment.setProperty("spring.application.name", "alpha"); + assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "alpha"); + } + + @Test + void shouldUseDefaultApplicationNameIfApplicationNameIsNotSet() { + assertThat(createAdapter().resourceAttributes()).containsEntry("service.name", "unknown_service"); + } + + private OtlpPropertiesConfigAdapter createAdapter() { + return new OtlpPropertiesConfigAdapter(this.properties, this.openTelemetryProperties, this.connectionDetails, + this.environment); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesTests.java index 69f945b66ce5..3046e2279dca 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpPropertiesTests.java @@ -37,6 +37,7 @@ void defaultValuesAreConsistent() { assertStepRegistryDefaultValues(properties, config); assertThat(properties.getUrl()).isEqualTo(config.url()); assertThat(properties.getAggregationTemporality()).isSameAs(config.aggregationTemporality()); + assertThat(properties.getBaseTimeUnit()).isSameAs(config.baseTimeUnit()); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapterTests.java index 4fa086069d04..d664a342148a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapterTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/signalfx/SignalFxPropertiesConfigAdapterTests.java @@ -68,7 +68,7 @@ void whenPropertiesSourceIsSetAdapterSourceReturnsIt() { } @Test - void whenPropertiesPublishHistogramTypeIsCumulativePublishCumulativeHistogramReturnsIt() { + void whenPropertiesPublishHistogramTypeIsCumulativeAdapterPublishCumulativeHistogramReturnsIt() { SignalFxProperties properties = createProperties(); properties.setPublishedHistogramType(HistogramType.CUMULATIVE); assertThat(createConfigAdapter(properties).publishCumulativeHistogram()).isTrue(); @@ -76,7 +76,7 @@ void whenPropertiesPublishHistogramTypeIsCumulativePublishCumulativeHistogramRet } @Test - void whenPropertiesPublishHistogramTypeIsDeltaPublishDeltaHistogramReturnsIt() { + void whenPropertiesPublishHistogramTypeIsDeltaAdapterPublishDeltaHistogramReturnsIt() { SignalFxProperties properties = createProperties(); properties.setPublishedHistogramType(HistogramType.DELTA); assertThat(createConfigAdapter(properties).publishDeltaHistogram()).isTrue(); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapterTests.java index 54234f878714..2acd34bbe5bc 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapterTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/export/wavefront/WavefrontPropertiesConfigAdapterTests.java @@ -18,11 +18,15 @@ import java.net.URI; +import com.wavefront.sdk.common.clients.service.token.TokenService.Type; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.PushRegistryPropertiesConfigAdapterTests; import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties; import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties.Metrics.Export; +import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties.TokenType; import static org.assertj.core.api.Assertions.assertThat; @@ -62,7 +66,7 @@ void whenPropertiesGlobalPrefixIsSetAdapterGlobalPrefixReturnsIt() { protected void whenPropertiesBatchSizeIsSetAdapterBatchSizeReturnsIt() { WavefrontProperties properties = new WavefrontProperties(); properties.getSender().setBatchSize(10042); - assertThat(createConfigAdapter(properties.getMetrics().getExport()).batchSize()).isEqualTo(10042); + assertThat(new WavefrontPropertiesConfigAdapter(properties).batchSize()).isEqualTo(10042); } @Test @@ -107,4 +111,20 @@ void whenPropertiesReportDayDistributionIsSetAdapterReportDayDistributionReturns assertThat(createConfigAdapter(properties).reportDayDistribution()).isTrue(); } + @ParameterizedTest + @CsvSource(textBlock = """ + null, WAVEFRONT_API_TOKEN + NO_TOKEN, NO_TOKEN + WAVEFRONT_API_TOKEN, WAVEFRONT_API_TOKEN + CSP_API_TOKEN, CSP_API_TOKEN + CSP_CLIENT_CREDENTIALS, CSP_CLIENT_CREDENTIALS + """) + void whenTokenTypeIsSetAdapterReturnsIt(String property, String wavefront) { + TokenType propertyTokenType = property.equals("null") ? null : TokenType.valueOf(property); + Type wavefrontTokenType = Type.valueOf(wavefront); + WavefrontProperties properties = new WavefrontProperties(); + properties.setApiTokenType(propertyTokenType); + assertThat(new WavefrontPropertiesConfigAdapter(properties).apiTokenType()).isEqualTo(wavefrontTokenType); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/mongo/MongoMetricsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/mongo/MongoMetricsAutoConfigurationTests.java index 7c3d870c3baa..5c5757730d15 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/mongo/MongoMetricsAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/mongo/MongoMetricsAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ import io.micrometer.core.instrument.binder.mongodb.MongoConnectionPoolTagsProvider; import io.micrometer.core.instrument.binder.mongodb.MongoMetricsCommandListener; import io.micrometer.core.instrument.binder.mongodb.MongoMetricsConnectionPoolListener; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; @@ -62,7 +63,7 @@ void whenThereIsAMeterRegistryThenMetricsCommandListenerIsAdded() { assertThat(context).hasSingleBean(MongoMetricsCommandListener.class); assertThat(getActualMongoClientSettingsUsedToConstructClient(context)) .extracting(MongoClientSettings::getCommandListeners) - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .containsExactly(context.getBean(MongoMetricsCommandListener.class)); assertThat(getMongoCommandTagsProviderUsedToConstructListener(context)) .isInstanceOf(DefaultMongoCommandTagsProvider.class); @@ -168,7 +169,7 @@ private ContextConsumer assertThatMetricsCommandLi assertThat(context).doesNotHaveBean(MongoMetricsCommandListener.class); assertThat(getActualMongoClientSettingsUsedToConstructClient(context)) .extracting(MongoClientSettings::getCommandListeners) - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .isEmpty(); }; } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/web/jetty/JettyMetricsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/web/jetty/JettyMetricsAutoConfigurationTests.java index d53632c54410..0e8437a192be 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/web/jetty/JettyMetricsAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/web/jetty/JettyMetricsAutoConfigurationTests.java @@ -31,7 +31,6 @@ import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyReactiveWebServerFactory; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWebServerApplicationContext; @@ -50,7 +49,6 @@ * @author Andy Wilkinson * @author Chris Bono */ -@Servlet5ClassPathOverrides class JettyMetricsAutoConfigurationTests { @Test diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfigurationTests.java index 5d9c1d3466a9..655d2eb0fbf1 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationAutoConfigurationTests.java @@ -34,9 +34,11 @@ import io.micrometer.observation.ObservationHandler.FirstMatchingCompositeObservationHandler; import io.micrometer.observation.ObservationPredicate; import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.aop.ObservedAspect; import io.micrometer.tracing.Tracer; import io.micrometer.tracing.handler.TracingAwareMeterObservationHandler; import io.micrometer.tracing.handler.TracingObservationHandler; +import org.aspectj.weaver.Advice; import org.junit.jupiter.api.Test; import org.mockito.Answers; @@ -58,6 +60,7 @@ * * @author Moritz Halbritter * @author Jonatan Ivanov + * @author Vedran Pavic */ class ObservationAutoConfigurationTests { @@ -77,6 +80,7 @@ void beansShouldNotBeSuppliedWhenMicrometerObservationIsNotOnClassPath() { assertThat(context).hasSingleBean(MeterRegistry.class); assertThat(context).doesNotHaveBean(ObservationRegistry.class); assertThat(context).doesNotHaveBean(ObservationHandler.class); + assertThat(context).doesNotHaveBean(ObservedAspect.class); assertThat(context).doesNotHaveBean(ObservationHandlerGrouping.class); }); } @@ -88,6 +92,7 @@ void supplyObservationRegistryWhenMicrometerCoreAndTracingAreNotOnClassPath() { ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class); Observation.start("test-observation", observationRegistry).stop(); assertThat(context).doesNotHaveBean(ObservationHandler.class); + assertThat(context).hasSingleBean(ObservedAspect.class); assertThat(context).doesNotHaveBean(ObservationHandlerGrouping.class); }); } @@ -99,6 +104,7 @@ void supplyMeterHandlerAndGroupingWhenMicrometerCoreIsOnClassPathButTracingIsNot Observation.start("test-observation", observationRegistry).stop(); assertThat(context).hasSingleBean(ObservationHandler.class); assertThat(context).hasSingleBean(DefaultMeterObservationHandler.class); + assertThat(context).hasSingleBean(ObservedAspect.class); assertThat(context).hasSingleBean(ObservationHandlerGrouping.class); assertThat(context).hasBean("metricsObservationHandlerGrouping"); }); @@ -110,6 +116,7 @@ void supplyOnlyTracingObservationHandlerGroupingWhenMicrometerCoreIsNotOnClassPa ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class); Observation.start("test-observation", observationRegistry).stop(); assertThat(context).doesNotHaveBean(ObservationHandler.class); + assertThat(context).hasSingleBean(ObservedAspect.class); assertThat(context).hasSingleBean(ObservationHandlerGrouping.class); assertThat(context).hasBean("tracingObservationHandlerGrouping"); }); @@ -123,6 +130,7 @@ void supplyMeterHandlerAndGroupingWhenMicrometerCoreAndTracingAreOnClassPath() { // TracingAwareMeterObservationHandler that we don't test here Observation.start("test-observation", observationRegistry); assertThat(context).hasSingleBean(ObservationHandler.class); + assertThat(context).hasSingleBean(ObservedAspect.class); assertThat(context).hasSingleBean(TracingAwareMeterObservationHandler.class); assertThat(context).hasSingleBean(ObservationHandlerGrouping.class); assertThat(context).hasBean("metricsAndTracingObservationHandlerGrouping"); @@ -138,6 +146,7 @@ void supplyMeterHandlerAndGroupingWhenMicrometerCoreAndTracingAreOnClassPathButT Observation.start("test-observation", observationRegistry).stop(); assertThat(context).hasSingleBean(ObservationHandler.class); assertThat(context).hasSingleBean(DefaultMeterObservationHandler.class); + assertThat(context).hasSingleBean(ObservedAspect.class); assertThat(context).hasSingleBean(ObservationHandlerGrouping.class); assertThat(context).hasBean("metricsAndTracingObservationHandlerGrouping"); }); @@ -155,6 +164,7 @@ void autoConfiguresDefaultMeterObservationHandler() { assertThat(meterRegistry.get("test-observation").timer().count()).isOne(); assertThat(context).hasSingleBean(DefaultMeterObservationHandler.class); assertThat(context).hasSingleBean(ObservationHandler.class); + assertThat(context).hasSingleBean(ObservedAspect.class); }); } @@ -164,6 +174,20 @@ void allowsDefaultMeterObservationHandlerToBeDisabled() { .run((context) -> assertThat(context).doesNotHaveBean(ObservationHandler.class)); } + @Test + void allowsObservedAspectToBeDisabled() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Advice.class)) + .run((context) -> assertThat(context).doesNotHaveBean(ObservedAspect.class)); + } + + @Test + void allowsObservedAspectToBeCustomized() { + this.contextRunner.withUserConfiguration(CustomObservedAspectConfiguration.class) + .run((context) -> assertThat(context).hasSingleBean(ObservedAspect.class) + .getBean(ObservedAspect.class) + .isSameAs(context.getBean("customObservedAspect"))); + } + @Test void autoConfiguresObservationPredicates() { this.contextRunner.withUserConfiguration(ObservationPredicates.class).run((context) -> { @@ -189,6 +213,22 @@ void autoConfiguresObservationFilters() { }); } + @Test + void shouldSupplyPropertiesObservationFilterBean() { + this.contextRunner + .run((context) -> assertThat(context).hasSingleBean(PropertiesObservationFilterPredicate.class)); + } + + @Test + void shouldApplyCommonKeyValuesToObservations() { + this.contextRunner.withPropertyValues("management.observations.key-values.a=alpha").run((context) -> { + ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class); + Observation.start("keyvalues", observationRegistry).stop(); + MeterRegistry meterRegistry = context.getBean(MeterRegistry.class); + assertThat(meterRegistry.get("keyvalues").tag("a", "alpha").timer().count()).isOne(); + }); + } + @Test void autoConfiguresGlobalObservationConventions() { this.contextRunner.withUserConfiguration(CustomGlobalObservationConvention.class).run((context) -> { @@ -207,14 +247,13 @@ void autoConfiguresObservationHandlers() { Observation.start("test-observation", observationRegistry).stop(); assertThat(context).doesNotHaveBean(DefaultMeterObservationHandler.class); assertThat(handlers).hasSize(2); - // Regular handlers are registered first - assertThat(handlers.get(0)).isInstanceOf(CustomObservationHandler.class); // Multiple MeterObservationHandler are wrapped in - // FirstMatchingCompositeObservationHandler, which calls only the first - // one - assertThat(handlers.get(1)).isInstanceOf(CustomMeterObservationHandler.class); - assertThat(((CustomMeterObservationHandler) handlers.get(1)).getName()) + // FirstMatchingCompositeObservationHandler, which calls only the first one + assertThat(handlers.get(0)).isInstanceOf(CustomMeterObservationHandler.class); + assertThat(((CustomMeterObservationHandler) handlers.get(0)).getName()) .isEqualTo("customMeterObservationHandler1"); + // Regular handlers are registered last + assertThat(handlers.get(1)).isInstanceOf(CustomObservationHandler.class); assertThat(context).doesNotHaveBean(DefaultMeterObservationHandler.class); assertThat(context).doesNotHaveBean(TracingAwareMeterObservationHandler.class); }); @@ -257,25 +296,44 @@ void autoConfiguresObservationHandlerWhenTracingIsActive() { List> handlers = context.getBean(CalledHandlers.class).getCalledHandlers(); Observation.start("test-observation", observationRegistry).stop(); assertThat(handlers).hasSize(3); - // Regular handlers are registered first - assertThat(handlers.get(0)).isInstanceOf(CustomObservationHandler.class); // Multiple TracingObservationHandler are wrapped in - // FirstMatchingCompositeObservationHandler, which calls only the first - // one - assertThat(handlers.get(1)).isInstanceOf(CustomTracingObservationHandler.class); - assertThat(((CustomTracingObservationHandler) handlers.get(1)).getName()) + // FirstMatchingCompositeObservationHandler, which calls only the first one + assertThat(handlers.get(0)).isInstanceOf(CustomTracingObservationHandler.class); + assertThat(((CustomTracingObservationHandler) handlers.get(0)).getName()) .isEqualTo("customTracingHandler1"); // Multiple MeterObservationHandler are wrapped in - // FirstMatchingCompositeObservationHandler, which calls only the first - // one - assertThat(handlers.get(2)).isInstanceOf(CustomMeterObservationHandler.class); - assertThat(((CustomMeterObservationHandler) handlers.get(2)).getName()) + // FirstMatchingCompositeObservationHandler, which calls only the first one + assertThat(handlers.get(1)).isInstanceOf(CustomMeterObservationHandler.class); + assertThat(((CustomMeterObservationHandler) handlers.get(1)).getName()) .isEqualTo("customMeterObservationHandler1"); + // Regular handlers are registered last + assertThat(handlers.get(2)).isInstanceOf(CustomObservationHandler.class); assertThat(context).doesNotHaveBean(TracingAwareMeterObservationHandler.class); assertThat(context).doesNotHaveBean(DefaultMeterObservationHandler.class); }); } + @Test + void shouldNotDisableSpringSecurityObservationsByDefault() { + this.contextRunner.run((context) -> { + ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class); + Observation.start("spring.security.filterchains", observationRegistry).stop(); + MeterRegistry meterRegistry = context.getBean(MeterRegistry.class); + assertThat(meterRegistry.get("spring.security.filterchains").timer().count()).isOne(); + }); + } + + @Test + void shouldDisableSpringSecurityObservationsIfPropertyIsSet() { + this.contextRunner.withPropertyValues("management.observations.enable.spring.security=false").run((context) -> { + ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class); + Observation.start("spring.security.filterchains", observationRegistry).stop(); + MeterRegistry meterRegistry = context.getBean(MeterRegistry.class); + assertThatExceptionOfType(MeterNotFoundException.class) + .isThrownBy(() -> meterRegistry.get("spring.security.filterchains").timer()); + }); + } + @Configuration(proxyBeanMethods = false) static class ObservationPredicates { @@ -303,6 +361,16 @@ ObservationFilter observationFilterTwo() { } + @Configuration(proxyBeanMethods = false) + static class CustomObservedAspectConfiguration { + + @Bean + ObservedAspect customObservedAspect(ObservationRegistry observationRegistry) { + return new ObservedAspect(observationRegistry); + } + + } + @Configuration(proxyBeanMethods = false) static class CustomGlobalObservationConvention { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGroupingTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGroupingTests.java new file mode 100644 index 000000000000..b42d0a155c81 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/ObservationHandlerGroupingTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.observation; + +import java.lang.reflect.Method; +import java.util.List; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.Observation.Context; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationHandler.FirstMatchingCompositeObservationHandler; +import io.micrometer.observation.ObservationRegistry.ObservationConfig; +import org.junit.jupiter.api.Test; + +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ObservationHandlerGrouping}. + * + * @author Moritz Halbritter + */ +class ObservationHandlerGroupingTests { + + @Test + void shouldGroupCategoriesIntoFirstMatchingHandlerAndRespectCategoryOrder() { + ObservationHandlerGrouping grouping = new ObservationHandlerGrouping( + List.of(ObservationHandlerA.class, ObservationHandlerB.class)); + ObservationConfig config = new ObservationConfig(); + ObservationHandlerA handlerA1 = new ObservationHandlerA("a1"); + ObservationHandlerA handlerA2 = new ObservationHandlerA("a2"); + ObservationHandlerB handlerB1 = new ObservationHandlerB("b1"); + ObservationHandlerB handlerB2 = new ObservationHandlerB("b2"); + grouping.apply(List.of(handlerB1, handlerB2, handlerA1, handlerA2), config); + List> handlers = getObservationHandlers(config); + assertThat(handlers).hasSize(2); + // Category A is first + assertThat(handlers.get(0)).isInstanceOf(FirstMatchingCompositeObservationHandler.class); + FirstMatchingCompositeObservationHandler firstMatching0 = (FirstMatchingCompositeObservationHandler) handlers + .get(0); + assertThat(firstMatching0.getHandlers()).containsExactly(handlerA1, handlerA2); + // Category B is second + assertThat(handlers.get(1)).isInstanceOf(FirstMatchingCompositeObservationHandler.class); + FirstMatchingCompositeObservationHandler firstMatching1 = (FirstMatchingCompositeObservationHandler) handlers + .get(1); + assertThat(firstMatching1.getHandlers()).containsExactly(handlerB1, handlerB2); + } + + @Test + void uncategorizedHandlersShouldBeOrderedAfterCategories() { + ObservationHandlerGrouping grouping = new ObservationHandlerGrouping(ObservationHandlerA.class); + ObservationConfig config = new ObservationConfig(); + ObservationHandlerA handlerA1 = new ObservationHandlerA("a1"); + ObservationHandlerA handlerA2 = new ObservationHandlerA("a2"); + ObservationHandlerB handlerB1 = new ObservationHandlerB("b1"); + grouping.apply(List.of(handlerB1, handlerA1, handlerA2), config); + List> handlers = getObservationHandlers(config); + assertThat(handlers).hasSize(2); + // Category A is first + assertThat(handlers.get(0)).isInstanceOf(FirstMatchingCompositeObservationHandler.class); + FirstMatchingCompositeObservationHandler firstMatching0 = (FirstMatchingCompositeObservationHandler) handlers + .get(0); + // Uncategorized handlers follow + assertThat(firstMatching0.getHandlers()).containsExactly(handlerA1, handlerA2); + assertThat(handlers.get(1)).isEqualTo(handlerB1); + } + + @SuppressWarnings("unchecked") + private static List> getObservationHandlers(ObservationConfig config) { + Method method = ReflectionUtils.findMethod(ObservationConfig.class, "getObservationHandlers"); + ReflectionUtils.makeAccessible(method); + return (List>) ReflectionUtils.invokeMethod(method, config); + } + + private static class NamedObservationHandler implements ObservationHandler { + + private final String name; + + NamedObservationHandler(String name) { + this.name = name; + } + + @Override + public boolean supportsContext(Context context) { + return true; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{name='" + this.name + "'}"; + } + + } + + private static class ObservationHandlerA extends NamedObservationHandler { + + ObservationHandlerA(String name) { + super(name); + } + + } + + private static class ObservationHandlerB extends NamedObservationHandler { + + ObservationHandlerB(String name) { + super(name); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicateTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicateTests.java new file mode 100644 index 000000000000..4afd4f601e67 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/PropertiesObservationFilterPredicateTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation.Context; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertiesObservationFilterPredicate}. + * + * @author Moritz Halbritter + */ +class PropertiesObservationFilterPredicateTests { + + @Test + void shouldDoNothingIfKeyValuesAreEmpty() { + PropertiesObservationFilterPredicate filter = createFilter(); + Context mapped = mapContext(filter, "a", "alpha"); + assertThat(mapped.getLowCardinalityKeyValues()).containsExactly(KeyValue.of("a", "alpha")); + } + + @Test + void shouldAddKeyValues() { + PropertiesObservationFilterPredicate filter = createFilter("b", "beta"); + Context mapped = mapContext(filter, "a", "alpha"); + assertThat(mapped.getLowCardinalityKeyValues()).containsExactly(KeyValue.of("a", "alpha"), + KeyValue.of("b", "beta")); + } + + @Test + void shouldFilter() { + PropertiesObservationFilterPredicate predicate = createPredicate("spring.security"); + Context context = new Context(); + assertThat(predicate.test("spring.security.filterchains", context)).isFalse(); + assertThat(predicate.test("spring.security", context)).isFalse(); + assertThat(predicate.test("spring.data", context)).isTrue(); + assertThat(predicate.test("spring", context)).isTrue(); + } + + @Test + void filterShouldFallbackToAll() { + PropertiesObservationFilterPredicate predicate = createPredicate("all"); + Context context = new Context(); + assertThat(predicate.test("spring.security.filterchains", context)).isFalse(); + assertThat(predicate.test("spring.security", context)).isFalse(); + assertThat(predicate.test("spring.data", context)).isFalse(); + assertThat(predicate.test("spring", context)).isFalse(); + } + + @Test + void shouldNotFilterIfDisabledNamesIsEmpty() { + PropertiesObservationFilterPredicate predicate = createPredicate(); + Context context = new Context(); + assertThat(predicate.test("spring.security.filterchains", context)).isTrue(); + assertThat(predicate.test("spring.security", context)).isTrue(); + assertThat(predicate.test("spring.data", context)).isTrue(); + assertThat(predicate.test("spring", context)).isTrue(); + } + + private static Context mapContext(PropertiesObservationFilterPredicate filter, String... initialKeyValues) { + Context context = new Context(); + context.addLowCardinalityKeyValues(KeyValues.of(initialKeyValues)); + return filter.map(context); + } + + private static PropertiesObservationFilterPredicate createFilter(String... keyValues) { + ObservationProperties properties = new ObservationProperties(); + for (int i = 0; i < keyValues.length; i += 2) { + properties.getKeyValues().put(keyValues[i], keyValues[i + 1]); + } + return new PropertiesObservationFilterPredicate(properties); + } + + private static PropertiesObservationFilterPredicate createPredicate(String... disabledNames) { + ObservationProperties properties = new ObservationProperties(); + for (String name : disabledNames) { + properties.getEnable().put(name, false); + } + return new PropertiesObservationFilterPredicate(properties); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapterTests.java deleted file mode 100644 index 814e71a4c720..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientHttpObservationConventionAdapterTests.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.client; - -import java.net.URI; - -import io.micrometer.common.KeyValue; -import io.micrometer.observation.Observation; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.boot.actuate.metrics.web.client.DefaultRestTemplateExchangeTagsProvider; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.client.ClientHttpRequest; -import org.springframework.http.client.ClientHttpResponse; -import org.springframework.http.client.observation.ClientRequestObservationContext; -import org.springframework.mock.http.client.MockClientHttpRequest; -import org.springframework.mock.http.client.MockClientHttpResponse; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link ClientHttpObservationConventionAdapter}. - * - * @author Brian Clozel - */ -@SuppressWarnings({ "deprecation", "removal" }) -class ClientHttpObservationConventionAdapterTests { - - private static final String TEST_METRIC_NAME = "test.metric.name"; - - private final ClientHttpObservationConventionAdapter convention = new ClientHttpObservationConventionAdapter( - TEST_METRIC_NAME, new DefaultRestTemplateExchangeTagsProvider()); - - private final ClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/resource/test")); - - private final ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.OK); - - private ClientRequestObservationContext context; - - @BeforeEach - void setup() { - this.context = new ClientRequestObservationContext(this.request); - this.context.setResponse(this.response); - this.context.setUriTemplate("/resource/{name}"); - } - - @Test - void shouldUseConfiguredName() { - assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME); - } - - @Test - void shouldOnlySupportClientHttpObservationContext() { - assertThat(this.convention.supportsContext(this.context)).isTrue(); - assertThat(this.convention.supportsContext(new OtherContext())).isFalse(); - } - - @Test - void shouldPushTagsAsLowCardinalityKeyValues() { - assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"), - KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"), - KeyValue.of("method", "GET")); - } - - @Test - void shouldNotPushAnyHighCardinalityKeyValue() { - assertThat(this.convention.getHighCardinalityKeyValues(this.context)).isEmpty(); - } - - static class OtherContext extends Observation.Context { - - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapterTests.java deleted file mode 100644 index cd73e29efba0..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/ClientObservationConventionAdapterTests.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.client; - -import java.net.URI; - -import io.micrometer.common.KeyValue; -import io.micrometer.observation.Observation; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.boot.actuate.metrics.web.reactive.client.DefaultWebClientExchangeTagsProvider; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ClientRequestObservationContext; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClient; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link ClientObservationConventionAdapter}. - * - * @author Brian Clozel - */ -@SuppressWarnings({ "deprecation", "removal" }) -class ClientObservationConventionAdapterTests { - - private static final String TEST_METRIC_NAME = "test.metric.name"; - - private final ClientObservationConventionAdapter convention = new ClientObservationConventionAdapter( - TEST_METRIC_NAME, new DefaultWebClientExchangeTagsProvider()); - - private final ClientRequest.Builder requestBuilder = ClientRequest - .create(HttpMethod.GET, URI.create("/resource/test")) - .attribute(WebClient.class.getName() + ".uriTemplate", "/resource/{name}"); - - private final ClientResponse response = ClientResponse.create(HttpStatus.OK).body("foo").build(); - - private ClientRequestObservationContext context; - - @BeforeEach - void setup() { - this.context = new ClientRequestObservationContext(); - this.context.setCarrier(this.requestBuilder); - this.context.setResponse(this.response); - this.context.setUriTemplate("/resource/{name}"); - } - - @Test - void shouldUseConfiguredName() { - assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME); - } - - @Test - void shouldOnlySupportClientObservationContext() { - assertThat(this.convention.supportsContext(this.context)).isTrue(); - assertThat(this.convention.supportsContext(new OtherContext())).isFalse(); - } - - @Test - void shouldPushTagsAsLowCardinalityKeyValues() { - this.context.setRequest(this.requestBuilder.build()); - assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"), - KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"), - KeyValue.of("method", "GET")); - } - - @Test - void doesNotFailWithEmptyRequest() { - assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"), - KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"), - KeyValue.of("method", "GET")); - } - - @Test - void shouldNotPushAnyHighCardinalityKeyValue() { - assertThat(this.convention.getHighCardinalityKeyValues(this.context)).isEmpty(); - } - - static class OtherContext extends Observation.Context { - - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationTests.java new file mode 100644 index 000000000000..1400c4f6c027 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationTests.java @@ -0,0 +1,175 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.client; + +import io.micrometer.common.KeyValues; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; +import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; +import org.springframework.boot.actuate.metrics.web.client.ObservationRestClientCustomizer; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.test.web.client.MockServerRestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.observation.ClientRequestObservationContext; +import org.springframework.http.client.observation.DefaultClientRequestObservationConvention; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.Builder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +/** + * Tests for {@link RestClientObservationConfiguration}. + * + * @author Brian Clozel + * @author Moritz Halbritter + */ +@ExtendWith(OutputCaptureExtension.class) +class RestClientObservationConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withBean(ObservationRegistry.class, TestObservationRegistry::create) + .withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, RestClientAutoConfiguration.class, + HttpClientObservationsAutoConfiguration.class)); + + @Test + void contributesCustomizerBean() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestClientCustomizer.class)); + } + + @Test + void restClientCreatedWithBuilderIsInstrumented() { + this.contextRunner.run((context) -> { + RestClient restClient = buildRestClient(context); + restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity(); + TestObservationRegistry registry = context.getBean(TestObservationRegistry.class); + TestObservationRegistryAssert.assertThat(registry) + .hasObservationWithNameEqualToIgnoringCase("http.client.requests"); + }); + } + + @Test + void restClientCreatedWithBuilderUsesCustomConventionName() { + final String observationName = "test.metric.name"; + this.contextRunner.withPropertyValues("management.observations.http.client.requests.name=" + observationName) + .run((context) -> { + RestClient restClient = buildRestClient(context); + restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity(); + TestObservationRegistry registry = context.getBean(TestObservationRegistry.class); + TestObservationRegistryAssert.assertThat(registry) + .hasObservationWithNameEqualToIgnoringCase(observationName); + }); + } + + @Test + void restClientCreatedWithBuilderUsesCustomConvention() { + this.contextRunner.withUserConfiguration(CustomConvention.class).run((context) -> { + RestClient restClient = buildRestClient(context); + restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity(); + TestObservationRegistry registry = context.getBean(TestObservationRegistry.class); + TestObservationRegistryAssert.assertThat(registry) + .hasObservationWithNameEqualTo("http.client.requests") + .that() + .hasLowCardinalityKeyValue("project", "spring-boot"); + }); + } + + @Test + void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) { + this.contextRunner.with(MetricsRun.simple()) + .withPropertyValues("management.metrics.web.client.max-uri-tags=2") + .run((context) -> { + RestClientWithMockServer restClientWithMockServer = buildRestClientAndMockServer(context); + MockRestServiceServer server = restClientWithMockServer.mockServer(); + RestClient restClient = restClientWithMockServer.restClient(); + for (int i = 0; i < 3; i++) { + server.expect(requestTo("/test/" + i)).andRespond(withStatus(HttpStatus.OK)); + } + for (int i = 0; i < 3; i++) { + restClient.get().uri("/test/" + i, String.class).retrieve().toBodilessEntity(); + } + TestObservationRegistry registry = context.getBean(TestObservationRegistry.class); + TestObservationRegistryAssert.assertThat(registry) + .hasNumberOfObservationsWithNameEqualTo("http.client.requests", 3); + MeterRegistry meterRegistry = context.getBean(MeterRegistry.class); + assertThat(meterRegistry.find("http.client.requests").timers()).hasSize(2); + assertThat(output).contains("Reached the maximum number of URI tags for 'http.client.requests'.") + .contains("Are you using 'uriVariables'?"); + }); + } + + @Test + void backsOffWhenRestClientBuilderIsMissing() { + new ApplicationContextRunner().with(MetricsRun.simple()) + .withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, + HttpClientObservationsAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(ObservationRestClientCustomizer.class)); + } + + private RestClient buildRestClient(AssertableApplicationContext context) { + RestClientWithMockServer restClientWithMockServer = buildRestClientAndMockServer(context); + restClientWithMockServer.mockServer() + .expect(requestTo("/projects/spring-boot")) + .andRespond(withStatus(HttpStatus.OK)); + return restClientWithMockServer.restClient(); + } + + private RestClientWithMockServer buildRestClientAndMockServer(AssertableApplicationContext context) { + Builder builder = context.getBean(Builder.class); + MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer(); + customizer.customize(builder); + return new RestClientWithMockServer(builder.build(), customizer.getServer()); + } + + private record RestClientWithMockServer(RestClient restClient, MockRestServiceServer mockServer) { + } + + @Configuration(proxyBeanMethods = false) + static class CustomConventionConfiguration { + + @Bean + CustomConvention customConvention() { + return new CustomConvention(); + } + + } + + static class CustomConvention extends DefaultClientRequestObservationConvention { + + @Override + public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) { + return super.getLowCardinalityKeyValues(context).and("project", "spring-boot"); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationWithoutMetricsTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationWithoutMetricsTests.java new file mode 100644 index 000000000000..3aa82c08c26b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestClientObservationConfigurationWithoutMetricsTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.client; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.test.web.client.MockServerRestClientCustomizer; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.Builder; + +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +/** + * Tests for {@link RestClientObservationConfiguration} without Micrometer Metrics. + * + * @author Brian Clozel + * @author Andy Wilkinson + * @author Moritz Halbritter + */ +@ExtendWith(OutputCaptureExtension.class) +@ClassPathExclusions("micrometer-core-*.jar") +class RestClientObservationConfigurationWithoutMetricsTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withBean(ObservationRegistry.class, TestObservationRegistry::create) + .withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, RestClientAutoConfiguration.class, + HttpClientObservationsAutoConfiguration.class)); + + @Test + void restClientCreatedWithBuilderIsInstrumented() { + this.contextRunner.run((context) -> { + RestClient restClient = buildRestClient(context); + restClient.get().uri("/projects/{project}", "spring-boot").retrieve().toBodilessEntity(); + TestObservationRegistry registry = context.getBean(TestObservationRegistry.class); + TestObservationRegistryAssert.assertThat(registry) + .hasObservationWithNameEqualToIgnoringCase("http.client.requests"); + }); + } + + private RestClient buildRestClient(AssertableApplicationContext context) { + Builder builder = context.getBean(Builder.class); + MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer(); + customizer.customize(builder); + customizer.getServer().expect(requestTo("/projects/spring-boot")).andRespond(withStatus(HttpStatus.OK)); + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfigurationTests.java index 6116649a14dc..92b4367cad0c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/RestTemplateObservationConfigurationTests.java @@ -18,8 +18,6 @@ import io.micrometer.common.KeyValues; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; import io.micrometer.observation.ObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; @@ -28,9 +26,7 @@ import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; -import org.springframework.boot.actuate.metrics.web.client.DefaultRestTemplateExchangeTagsProvider; import org.springframework.boot.actuate.metrics.web.client.ObservationRestTemplateCustomizer; -import org.springframework.boot.actuate.metrics.web.client.RestTemplateExchangeTagsProvider; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; @@ -40,9 +36,7 @@ import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpRequest; import org.springframework.http.HttpStatus; -import org.springframework.http.client.ClientHttpResponse; import org.springframework.http.client.observation.ClientRequestObservationContext; import org.springframework.http.client.observation.DefaultClientRequestObservationConvention; import org.springframework.test.web.client.MockRestServiceServer; @@ -58,7 +52,6 @@ * @author Brian Clozel */ @ExtendWith(OutputCaptureExtension.class) -@SuppressWarnings("removal") class RestTemplateObservationConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() @@ -68,8 +61,7 @@ class RestTemplateObservationConfigurationTests { @Test void contributesCustomizerBean() { - this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestTemplateCustomizer.class) - .doesNotHaveBean(DefaultRestTemplateExchangeTagsProvider.class)); + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationRestTemplateCustomizer.class)); } @Test @@ -96,32 +88,6 @@ void restTemplateCreatedWithBuilderUsesCustomConventionName() { }); } - @Test - void restTemplateCreatedWithBuilderUsesCustomMetricName() { - final String metricName = "test.metric.name"; - this.contextRunner.withPropertyValues("management.metrics.web.client.request.metric-name=" + metricName) - .run((context) -> { - RestTemplate restTemplate = buildRestTemplate(context); - restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot"); - TestObservationRegistry registry = context.getBean(TestObservationRegistry.class); - TestObservationRegistryAssert.assertThat(registry) - .hasObservationWithNameEqualToIgnoringCase(metricName); - }); - } - - @Test - void restTemplateCreatedWithBuilderUsesCustomTagsProvider() { - this.contextRunner.withUserConfiguration(CustomTagsConfiguration.class).run((context) -> { - RestTemplate restTemplate = buildRestTemplate(context); - restTemplate.getForEntity("/projects/{project}", Void.class, "spring-boot"); - TestObservationRegistry registry = context.getBean(TestObservationRegistry.class); - TestObservationRegistryAssert.assertThat(registry) - .hasObservationWithNameEqualTo("http.client.requests") - .that() - .hasLowCardinalityKeyValue("project", "spring-boot"); - }); - } - @Test void restTemplateCreatedWithBuilderUsesCustomConvention() { this.contextRunner.withUserConfiguration(CustomConvention.class).run((context) -> { @@ -163,8 +129,7 @@ void backsOffWhenRestTemplateBuilderIsMissing() { new ApplicationContextRunner().with(MetricsRun.simple()) .withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, HttpClientObservationsAutoConfiguration.class)) - .run((context) -> assertThat(context).doesNotHaveBean(DefaultRestTemplateExchangeTagsProvider.class) - .doesNotHaveBean(ObservationRestTemplateCustomizer.class)); + .run((context) -> assertThat(context).doesNotHaveBean(ObservationRestTemplateCustomizer.class)); } private RestTemplate buildRestTemplate(AssertableApplicationContext context) { @@ -174,26 +139,6 @@ private RestTemplate buildRestTemplate(AssertableApplicationContext context) { return restTemplate; } - @Configuration(proxyBeanMethods = false) - static class CustomTagsConfiguration { - - @Bean - CustomTagsProvider customTagsProvider() { - return new CustomTagsProvider(); - } - - } - - @Deprecated(since = "3.0.0", forRemoval = true) - static class CustomTagsProvider implements RestTemplateExchangeTagsProvider { - - @Override - public Iterable getTags(String urlTemplate, HttpRequest request, ClientHttpResponse response) { - return Tags.of("project", "spring-boot"); - } - - } - @Configuration(proxyBeanMethods = false) static class CustomConventionConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfigurationTests.java index 9a94712edc78..8a917298b0e0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/client/WebClientObservationConfigurationTests.java @@ -29,9 +29,7 @@ import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; -import org.springframework.boot.actuate.metrics.web.reactive.client.DefaultWebClientExchangeTagsProvider; import org.springframework.boot.actuate.metrics.web.reactive.client.ObservationWebClientCustomizer; -import org.springframework.boot.actuate.metrics.web.reactive.client.WebClientExchangeTagsProvider; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; @@ -59,7 +57,6 @@ * @author Stephane Nicoll */ @ExtendWith(OutputCaptureExtension.class) -@SuppressWarnings("removal") class WebClientObservationConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().with(MetricsRun.simple()) @@ -69,8 +66,7 @@ class WebClientObservationConfigurationTests { @Test void contributesCustomizerBean() { - this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationWebClientCustomizer.class) - .doesNotHaveBean(DefaultWebClientExchangeTagsProvider.class)); + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ObservationWebClientCustomizer.class)); } @Test @@ -82,14 +78,6 @@ void webClientCreatedWithBuilderIsInstrumented() { }); } - @Test - void shouldNotOverrideCustomTagsProvider() { - this.contextRunner.withUserConfiguration(CustomTagsProviderConfig.class) - .run((context) -> assertThat(context).getBeans(WebClientExchangeTagsProvider.class) - .hasSize(1) - .containsKey("customTagsProvider")); - } - @Test void shouldUseCustomConventionIfAvailable() { this.contextRunner.withUserConfiguration(CustomConvention.class).run((context) -> { @@ -170,16 +158,6 @@ private WebClient mockWebClient(WebClient.Builder builder) { return builder.clientConnector(connector).build(); } - @Configuration(proxyBeanMethods = false) - static class CustomTagsProviderConfig { - - @Bean - WebClientExchangeTagsProvider customTagsProvider() { - return mock(WebClientExchangeTagsProvider.class); - } - - } - @Configuration(proxyBeanMethods = false) static class CustomConventionConfig { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapterTests.java deleted file mode 100644 index 9d32817dc80c..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/ServerRequestObservationConventionAdapterTests.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.reactive; - -import java.util.Map; - -import io.micrometer.common.KeyValue; -import org.junit.jupiter.api.Test; - -import org.springframework.boot.actuate.metrics.web.reactive.server.DefaultWebFluxTagsProvider; -import org.springframework.http.server.reactive.observation.ServerRequestObservationContext; -import org.springframework.mock.http.server.reactive.MockServerHttpRequest; -import org.springframework.mock.http.server.reactive.MockServerHttpResponse; -import org.springframework.web.reactive.HandlerMapping; -import org.springframework.web.util.pattern.PathPatternParser; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link ServerRequestObservationConventionAdapter}. - * - * @author Brian Clozel - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class ServerRequestObservationConventionAdapterTests { - - private static final String TEST_METRIC_NAME = "test.metric.name"; - - private final ServerRequestObservationConventionAdapter convention = new ServerRequestObservationConventionAdapter( - TEST_METRIC_NAME, new DefaultWebFluxTagsProvider()); - - @Test - void shouldUseConfiguredName() { - assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME); - } - - @Test - void shouldPushTagsAsLowCardinalityKeyValues() { - MockServerHttpRequest request = MockServerHttpRequest.get("/resource/test").build(); - MockServerHttpResponse response = new MockServerHttpResponse(); - ServerRequestObservationContext context = new ServerRequestObservationContext(request, response, - Map.of(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, - PathPatternParser.defaultInstance.parse("/resource/{name}"))); - assertThat(this.convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("status", "200"), - KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"), - KeyValue.of("method", "GET")); - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfigurationTests.java index 88609e23f686..384be8d4ae30 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/reactive/WebFluxObservationAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,40 +16,28 @@ package org.springframework.boot.actuate.autoconfigure.observation.web.reactive; -import java.util.List; +import java.time.Duration; +import java.time.temporal.ChronoUnit; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import reactor.core.publisher.Mono; import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; import org.springframework.boot.actuate.autoconfigure.metrics.web.TestController; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; -import org.springframework.boot.actuate.metrics.web.reactive.server.DefaultWebFluxTagsProvider; -import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsContributor; -import org.springframework.boot.actuate.metrics.web.reactive.server.WebFluxTagsProvider; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; import org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention; -import org.springframework.test.web.reactive.server.WebTestClient; -import org.springframework.web.filter.reactive.ServerHttpObservationFilter; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.WebFilter; -import org.springframework.web.server.WebFilterChain; +import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link WebFluxObservationAutoConfiguration} @@ -57,9 +45,9 @@ * @author Brian Clozel * @author Dmytro Nosan * @author Madhura Bhave + * @author Moritz Halbritter */ @ExtendWith(OutputCaptureExtension.class) -@SuppressWarnings("removal") class WebFluxObservationAutoConfigurationTests { private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() @@ -67,53 +55,6 @@ class WebFluxObservationAutoConfigurationTests { .withConfiguration( AutoConfigurations.of(ObservationAutoConfiguration.class, WebFluxObservationAutoConfiguration.class)); - @Test - void shouldProvideWebFluxObservationFilter() { - this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ServerHttpObservationFilter.class)); - } - - @Test - void shouldProvideWebFluxObservationFilterOrdered() { - this.contextRunner.withBean(FirstWebFilter.class).withBean(ThirdWebFilter.class).run((context) -> { - List webFilters = context.getBeanProvider(WebFilter.class).orderedStream().toList(); - assertThat(webFilters.get(0)).isInstanceOf(FirstWebFilter.class); - assertThat(webFilters.get(1)).isInstanceOf(ServerHttpObservationFilter.class); - assertThat(webFilters.get(2)).isInstanceOf(ThirdWebFilter.class); - }); - } - - @Test - void shouldUseConventionAdapterWhenCustomTagsProvider() { - this.contextRunner.withUserConfiguration(CustomTagsProviderConfiguration.class).run((context) -> { - assertThat(context).hasSingleBean(ServerHttpObservationFilter.class); - assertThat(context).hasSingleBean(WebFluxTagsProvider.class); - assertThat(context).getBean(ServerHttpObservationFilter.class) - .extracting("observationConvention") - .isInstanceOf(ServerRequestObservationConventionAdapter.class); - }); - } - - @Test - void shouldUseConventionAdapterWhenCustomTagsContributor() { - this.contextRunner.withUserConfiguration(CustomTagsContributorConfiguration.class).run((context) -> { - assertThat(context).hasSingleBean(ServerHttpObservationFilter.class); - assertThat(context).hasSingleBean(WebFluxTagsContributor.class); - assertThat(context).getBean(ServerHttpObservationFilter.class) - .extracting("observationConvention") - .isInstanceOf(ServerRequestObservationConventionAdapter.class); - }); - } - - @Test - void shouldUseCustomConventionWhenAvailable() { - this.contextRunner.withUserConfiguration(CustomConventionConfiguration.class).run((context) -> { - assertThat(context).hasSingleBean(ServerHttpObservationFilter.class); - assertThat(context).getBean(ServerHttpObservationFilter.class) - .extracting("observationConvention") - .isInstanceOf(CustomConvention.class); - }); - } - @Test void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) { this.contextRunner.withUserConfiguration(TestController.class) @@ -127,21 +68,6 @@ void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) { }); } - @Test - @Deprecated(since = "3.0.0", forRemoval = true) - void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomMetricName(CapturedOutput output) { - this.contextRunner.withUserConfiguration(TestController.class) - .withConfiguration(AutoConfigurations.of(MetricsAutoConfiguration.class, ObservationAutoConfiguration.class, - WebFluxAutoConfiguration.class)) - .withPropertyValues("management.metrics.web.server.max-uri-tags=2", - "management.metrics.web.server.request.metric-name=my.http.server.requests") - .run((context) -> { - MeterRegistry registry = getInitializedMeterRegistry(context); - assertThat(registry.get("my.http.server.requests").meters()).hasSizeLessThanOrEqualTo(2); - assertThat(output).contains("Reached the maximum number of URI tags for 'my.http.server.requests'"); - }); - } - @Test void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomObservationName(CapturedOutput output) { this.contextRunner.withUserConfiguration(TestController.class) @@ -150,7 +76,7 @@ void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomObservationName(Captu .withPropertyValues("management.metrics.web.server.max-uri-tags=2", "management.observations.http.server.requests.name=my.http.server.requests") .run((context) -> { - MeterRegistry registry = getInitializedMeterRegistry(context); + MeterRegistry registry = getInitializedMeterRegistry(context, "my.http.server.requests"); assertThat(registry.get("my.http.server.requests").meters()).hasSizeLessThanOrEqualTo(2); assertThat(output).contains("Reached the maximum number of URI tags for 'my.http.server.requests'"); }); @@ -169,84 +95,39 @@ void shouldNotDenyNorLogIfMaxUrisIsNotReached(CapturedOutput output) { }); } - private MeterRegistry getInitializedMeterRegistry(AssertableReactiveWebApplicationContext context) - throws Exception { - return getInitializedMeterRegistry(context, "/test0", "/test1", "/test2"); - } - - private MeterRegistry getInitializedMeterRegistry(AssertableReactiveWebApplicationContext context, String... urls) - throws Exception { - assertThat(context).hasSingleBean(ServerHttpObservationFilter.class); - WebTestClient client = WebTestClient.bindToApplicationContext(context).build(); - for (String url : urls) { - client.get().uri(url).exchange().expectStatus().isOk(); - } - return context.getBean(MeterRegistry.class); - } - - @Deprecated(since = "3.0.0", forRemoval = true) - @Configuration(proxyBeanMethods = false) - static class CustomTagsProviderConfiguration { - - @Bean - WebFluxTagsProvider tagsProvider() { - return new DefaultWebFluxTagsProvider(); - } - - } - - @Configuration(proxyBeanMethods = false) - static class CustomTagsContributorConfiguration { - - @Bean - WebFluxTagsContributor tagsContributor() { - return new CustomTagsContributor(); - } - - } - - @Deprecated(since = "3.0.0", forRemoval = true) - static class CustomTagsContributor implements WebFluxTagsContributor { - - @Override - public Iterable httpRequestTags(ServerWebExchange exchange, Throwable ex) { - return Tags.of("custom", "testvalue"); - } - - } - - @Configuration(proxyBeanMethods = false) - static class CustomConventionConfiguration { - - @Bean - CustomConvention customConvention() { - return new CustomConvention(); - } - + @Test + void shouldSupplyDefaultServerRequestObservationConvention() { + this.contextRunner.withPropertyValues("management.observations.http.server.requests.name=some-other-name") + .run((context) -> { + assertThat(context).hasSingleBean(DefaultServerRequestObservationConvention.class); + DefaultServerRequestObservationConvention bean = context + .getBean(DefaultServerRequestObservationConvention.class); + assertThat(bean.getName()).isEqualTo("some-other-name"); + }); } - static class CustomConvention extends DefaultServerRequestObservationConvention { - + @Test + void shouldBackOffOnCustomServerRequestObservationConvention() { + this.contextRunner + .withBean("customServerRequestObservationConvention", ServerRequestObservationConvention.class, + () -> mock(ServerRequestObservationConvention.class)) + .run((context) -> { + assertThat(context).hasBean("customServerRequestObservationConvention"); + assertThat(context).hasSingleBean(ServerRequestObservationConvention.class); + }); } - @Order(Ordered.HIGHEST_PRECEDENCE) - static class FirstWebFilter implements WebFilter { - - @Override - public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - return chain.filter(exchange); - } - + private MeterRegistry getInitializedMeterRegistry(AssertableReactiveWebApplicationContext context) { + return getInitializedMeterRegistry(context, "http.server.requests"); } - @Order(Ordered.HIGHEST_PRECEDENCE + 2) - static class ThirdWebFilter implements WebFilter { - - @Override - public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - return chain.filter(exchange); - } - + private MeterRegistry getInitializedMeterRegistry(AssertableReactiveWebApplicationContext context, + String metricName) { + MeterRegistry meterRegistry = context.getBean(MeterRegistry.class); + meterRegistry.timer(metricName, "uri", "/test0").record(Duration.of(500, ChronoUnit.SECONDS)); + meterRegistry.timer(metricName, "uri", "/test1").record(Duration.of(500, ChronoUnit.SECONDS)); + meterRegistry.timer(metricName, "uri", "/test2").record(Duration.of(500, ChronoUnit.SECONDS)); + return meterRegistry; } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapterTests.java deleted file mode 100644 index 9705829af336..000000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/ServerRequestObservationConventionAdapterTests.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.autoconfigure.observation.web.servlet; - -import java.util.Collections; -import java.util.List; - -import io.micrometer.common.KeyValue; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; -import io.micrometer.observation.Observation; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.Test; - -import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider; -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor; -import org.springframework.http.server.observation.ServerRequestObservationContext; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.web.servlet.HandlerMapping; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link ServerRequestObservationConventionAdapter} - * - * @author Brian Clozel - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class ServerRequestObservationConventionAdapterTests { - - private static final String TEST_METRIC_NAME = "test.metric.name"; - - private final ServerRequestObservationConventionAdapter convention = new ServerRequestObservationConventionAdapter( - TEST_METRIC_NAME, new DefaultWebMvcTagsProvider(), Collections.emptyList()); - - private final MockHttpServletRequest request = new MockHttpServletRequest("GET", "/resource/test"); - - private final MockHttpServletResponse response = new MockHttpServletResponse(); - - private final ServerRequestObservationContext context = new ServerRequestObservationContext(this.request, - this.response); - - @Test - void customNameIsUsed() { - assertThat(this.convention.getName()).isEqualTo(TEST_METRIC_NAME); - } - - @Test - void onlySupportServerRequestObservationContext() { - assertThat(this.convention.supportsContext(this.context)).isTrue(); - assertThat(this.convention.supportsContext(new OtherContext())).isFalse(); - } - - @Test - void pushTagsAsLowCardinalityKeyValues() { - this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/resource/{name}"); - this.context.setPathPattern("/resource/{name}"); - assertThat(this.convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("status", "200"), - KeyValue.of("outcome", "SUCCESS"), KeyValue.of("uri", "/resource/{name}"), - KeyValue.of("method", "GET")); - } - - @Test - void doesNotPushAnyHighCardinalityKeyValue() { - assertThat(this.convention.getHighCardinalityKeyValues(this.context)).isEmpty(); - } - - @Test - void pushTagsFromContributors() { - ServerRequestObservationConventionAdapter convention = new ServerRequestObservationConventionAdapter( - TEST_METRIC_NAME, null, List.of(new CustomWebMvcContributor())); - assertThat(convention.getLowCardinalityKeyValues(this.context)).contains(KeyValue.of("custom", "value")); - } - - static class OtherContext extends Observation.Context { - - } - - static class CustomWebMvcContributor implements WebMvcTagsContributor { - - @Override - public Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, - Throwable exception) { - return Tags.of("custom", "value"); - } - - @Override - public Iterable getLongRequestTags(HttpServletRequest request, Object handler) { - return Collections.emptyList(); - } - - } - -} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfigurationTests.java index a8fe9bf9c9f1..3fd1a2b61bef 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/observation/web/servlet/WebMvcObservationAutoConfigurationTests.java @@ -16,16 +16,12 @@ package org.springframework.boot.actuate.autoconfigure.observation.web.servlet; -import java.util.Collections; import java.util.EnumSet; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Tag; import io.micrometer.observation.tck.TestObservationRegistry; import jakarta.servlet.DispatcherType; import jakarta.servlet.Filter; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,9 +29,6 @@ import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; import org.springframework.boot.actuate.autoconfigure.metrics.web.TestController; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; -import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider; -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor; -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsProvider; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; @@ -66,7 +59,6 @@ * @author Chanhyeong LEE */ @ExtendWith(OutputCaptureExtension.class) -@SuppressWarnings("removal") class WebMvcObservationAutoConfigurationTests { private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() @@ -84,21 +76,12 @@ void backsOffWhenMeterRegistryIsMissing() { @Test void definesFilterWhenRegistryIsPresent() { this.contextRunner.run((context) -> { - assertThat(context).doesNotHaveBean(DefaultWebMvcTagsProvider.class); assertThat(context).hasSingleBean(FilterRegistrationBean.class); assertThat(context.getBean(FilterRegistrationBean.class).getFilter()) .isInstanceOf(ServerHttpObservationFilter.class); }); } - @Test - void adapterConventionWhenTagsProviderPresent() { - this.contextRunner.withUserConfiguration(TagsProviderConfiguration.class) - .run((context) -> assertThat(context.getBean(FilterRegistrationBean.class).getFilter()) - .extracting("observationConvention") - .isInstanceOf(ServerRequestObservationConventionAdapter.class)); - } - @Test void customConventionWhenPresent() { this.contextRunner.withUserConfiguration(CustomConventionConfiguration.class) @@ -159,21 +142,6 @@ void afterMaxUrisReachedFurtherUrisAreDenied(CapturedOutput output) { }); } - @Test - @Deprecated(since = "3.0.0", forRemoval = true) - void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomMetricName(CapturedOutput output) { - this.contextRunner.withUserConfiguration(TestController.class) - .withConfiguration(AutoConfigurations.of(MetricsAutoConfiguration.class, ObservationAutoConfiguration.class, - WebMvcAutoConfiguration.class)) - .withPropertyValues("management.metrics.web.server.max-uri-tags=2", - "management.metrics.web.server.request.metric-name=my.http.server.requests") - .run((context) -> { - MeterRegistry registry = getInitializedMeterRegistry(context); - assertThat(registry.get("my.http.server.requests").meters()).hasSizeLessThanOrEqualTo(2); - assertThat(output).contains("Reached the maximum number of URI tags for 'my.http.server.requests'"); - }); - } - @Test void afterMaxUrisReachedFurtherUrisAreDeniedWhenUsingCustomObservationName(CapturedOutput output) { this.contextRunner.withUserConfiguration(TestController.class) @@ -201,14 +169,6 @@ void shouldNotDenyNorLogIfMaxUrisIsNotReached(CapturedOutput output) { }); } - @Test - void whenTagContributorsAreDefinedThenTagsProviderUsesThem() { - this.contextRunner.withUserConfiguration(TagsContributorsConfiguration.class) - .run((context) -> assertThat(context.getBean(FilterRegistrationBean.class).getFilter()) - .extracting("observationConvention") - .isInstanceOf(ServerRequestObservationConventionAdapter.class)); - } - private MeterRegistry getInitializedMeterRegistry(AssertableWebApplicationContext context) throws Exception { return getInitializedMeterRegistry(context, "/test0", "/test1", "/test2"); } @@ -225,47 +185,6 @@ private MeterRegistry getInitializedMeterRegistry(AssertableWebApplicationContex return context.getBean(MeterRegistry.class); } - @Configuration(proxyBeanMethods = false) - static class TagsProviderConfiguration { - - @Bean - TestWebMvcTagsProvider tagsProvider() { - return new TestWebMvcTagsProvider(); - } - - } - - @Configuration(proxyBeanMethods = false) - static class TagsContributorsConfiguration { - - @Bean - WebMvcTagsContributor tagContributorOne() { - return mock(WebMvcTagsContributor.class); - } - - @Bean - WebMvcTagsContributor tagContributorTwo() { - return mock(WebMvcTagsContributor.class); - } - - } - - @Deprecated(since = "3.0.0", forRemoval = true) - private static final class TestWebMvcTagsProvider implements WebMvcTagsProvider { - - @Override - public Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, - Throwable exception) { - return Collections.emptyList(); - } - - @Override - public Iterable getLongRequestTags(HttpServletRequest request, Object handler) { - return Collections.emptyList(); - } - - } - @Configuration(proxyBeanMethods = false) static class TestServerHttpObservationFilterRegistrationConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfigurationTests.java new file mode 100644 index 000000000000..4b84037bbe1b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfigurationTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure.opentelemetry; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.semconv.ResourceAttributes; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OpenTelemetryAutoConfiguration}. + * + * @author Moritz Halbritter + */ +class OpenTelemetryAutoConfigurationTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class)); + + @Test + void isRegisteredInAutoConfigurationImports() { + assertThat(ImportCandidates.load(AutoConfiguration.class, null).getCandidates()) + .contains(OpenTelemetryAutoConfiguration.class.getName()); + } + + @Test + void shouldProvideBeans() { + this.runner.run((context) -> { + assertThat(context).hasSingleBean(OpenTelemetrySdk.class); + assertThat(context).hasSingleBean(Resource.class); + }); + } + + @Test + void shouldBackOffIfOpenTelemetryIsNotOnClasspath() { + this.runner.withClassLoader(new FilteredClassLoader("io.opentelemetry")).run((context) -> { + assertThat(context).doesNotHaveBean(OpenTelemetrySdk.class); + assertThat(context).doesNotHaveBean(Resource.class); + }); + } + + @Test + void backsOffOnUserSuppliedBeans() { + this.runner.withUserConfiguration(UserConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(OpenTelemetry.class); + assertThat(context).hasBean("customOpenTelemetry"); + assertThat(context).hasSingleBean(Resource.class); + assertThat(context).hasBean("customResource"); + }); + } + + @Test + void shouldApplySpringApplicationNameToResource() { + this.runner.withPropertyValues("spring.application.name=my-application").run((context) -> { + Resource resource = context.getBean(Resource.class); + assertThat(resource.getAttributes().asMap()) + .contains(entry(ResourceAttributes.SERVICE_NAME, "my-application")); + }); + } + + @Test + void shouldFallbackToDefaultApplicationNameIfSpringApplicationNameIsNotSet() { + this.runner.run((context) -> { + Resource resource = context.getBean(Resource.class); + assertThat(resource.getAttributes().asMap()) + .contains(entry(ResourceAttributes.SERVICE_NAME, "unknown_service")); + }); + } + + @Test + void shouldApplyResourceAttributesFromProperties() { + this.runner.withPropertyValues("management.opentelemetry.resource-attributes.region=us-west").run((context) -> { + Resource resource = context.getBean(Resource.class); + assertThat(resource.getAttributes().asMap()).contains(entry(AttributeKey.stringKey("region"), "us-west")); + }); + } + + @Test + void shouldRegisterSdkTracerProviderIfAvailable() { + this.runner.withBean(SdkTracerProvider.class, () -> SdkTracerProvider.builder().build()).run((context) -> { + OpenTelemetry openTelemetry = context.getBean(OpenTelemetry.class); + assertThat(openTelemetry.getTracerProvider()).isNotNull(); + }); + } + + @Test + void shouldRegisterContextPropagatorsIfAvailable() { + this.runner.withBean(ContextPropagators.class, ContextPropagators::noop).run((context) -> { + OpenTelemetry openTelemetry = context.getBean(OpenTelemetry.class); + assertThat(openTelemetry.getPropagators()).isNotNull(); + }); + } + + @Test + void shouldRegisterSdkLoggerProviderIfAvailable() { + this.runner.withBean(SdkLoggerProvider.class, () -> SdkLoggerProvider.builder().build()).run((context) -> { + OpenTelemetry openTelemetry = context.getBean(OpenTelemetry.class); + assertThat(openTelemetry.getLogsBridge()).isNotNull(); + }); + } + + @Test + void shouldRegisterSdkMeterProviderIfAvailable() { + this.runner.withBean(SdkMeterProvider.class, () -> SdkMeterProvider.builder().build()).run((context) -> { + OpenTelemetry openTelemetry = context.getBean(OpenTelemetry.class); + assertThat(openTelemetry.getMeterProvider()).isNotNull(); + }); + } + + @Configuration(proxyBeanMethods = false) + private static final class UserConfiguration { + + @Bean + OpenTelemetry customOpenTelemetry() { + return mock(OpenTelemetry.class); + } + + @Bean + Resource customResource() { + return Resource.getDefault(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryPropertiesTests.java new file mode 100644 index 000000000000..d7da608e9804 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryPropertiesTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.opentelemetry; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link OpenTelemetryProperties}. + * + * @author Moritz Halbritter + */ +class OpenTelemetryPropertiesTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner().withPropertyValues( + "management.opentelemetry.resource-attributes.a=alpha", + "management.opentelemetry.resource-attributes.b=beta"); + + @Test + @ClassPathExclusions("opentelemetry-sdk-*") + void shouldNotDependOnOpenTelemetrySdk() { + this.runner.withUserConfiguration(TestConfiguration.class).run((context) -> { + OpenTelemetryProperties properties = context.getBean(OpenTelemetryProperties.class); + assertThat(properties.getResourceAttributes()).containsOnly(entry("a", "alpha"), entry("b", "beta")); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(OpenTelemetryProperties.class) + private static final class TestConfiguration { + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfigurationTests.java new file mode 100644 index 000000000000..e5ae366b49bd --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/r2dbc/R2dbcObservationAutoConfigurationTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.r2dbc; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import io.micrometer.observation.Observation.Context; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; +import io.r2dbc.spi.ConnectionFactory; +import org.awaitility.Awaitility; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.r2dbc.ConnectionFactoryBuilder; +import org.springframework.boot.r2dbc.ConnectionFactoryDecorator; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link R2dbcObservationAutoConfiguration}. + * + * @author Moritz Halbritter + */ +class R2dbcObservationAutoConfigurationTests { + + private final ApplicationContextRunner runnerWithoutObservationRegistry = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(R2dbcObservationAutoConfiguration.class)); + + private final ApplicationContextRunner runner = this.runnerWithoutObservationRegistry + .withBean(ObservationRegistry.class, ObservationRegistry::create); + + @Test + void shouldBeRegisteredInAutoConfigurationImports() { + assertThat(ImportCandidates.load(AutoConfiguration.class, null).getCandidates()) + .contains(R2dbcObservationAutoConfiguration.class.getName()); + } + + @Test + void shouldSupplyConnectionFactoryDecorator() { + this.runner.run((context) -> assertThat(context).hasSingleBean(ConnectionFactoryDecorator.class)); + } + + @Test + void shouldNotSupplyBeansIfR2dbcSpiIsNotOnClasspath() { + this.runner.withClassLoader(new FilteredClassLoader("io.r2dbc.spi")) + .run((context) -> assertThat(context).doesNotHaveBean(ConnectionFactoryDecorator.class)); + } + + @Test + void shouldNotSupplyBeansIfR2dbcProxyIsNotOnClasspath() { + this.runner.withClassLoader(new FilteredClassLoader("io.r2dbc.proxy")) + .run((context) -> assertThat(context).doesNotHaveBean(ConnectionFactoryDecorator.class)); + } + + @Test + void shouldNotSupplyBeansIfObservationRegistryIsNotPresent() { + this.runnerWithoutObservationRegistry + .run((context) -> assertThat(context).doesNotHaveBean(ConnectionFactoryDecorator.class)); + } + + @Test + void decoratorShouldReportObservations() { + this.runner.run((context) -> { + CapturingObservationHandler handler = registerCapturingObservationHandler(context); + ConnectionFactoryDecorator decorator = context.getBean(ConnectionFactoryDecorator.class); + assertThat(decorator).isNotNull(); + ConnectionFactory connectionFactory = ConnectionFactoryBuilder + .withUrl("r2dbc:h2:mem:///" + UUID.randomUUID()) + .build(); + ConnectionFactory decorated = decorator.decorate(connectionFactory); + Mono.from(decorated.create()) + .flatMap((c) -> Mono.from(c.createStatement("SELECT 1;").execute()) + .flatMap((ignore) -> Mono.from(c.close()))) + .block(); + assertThat(handler.awaitContext().getName()).as("context.getName()").isEqualTo("r2dbc.query"); + }); + } + + private static CapturingObservationHandler registerCapturingObservationHandler( + AssertableApplicationContext context) { + ObservationRegistry observationRegistry = context.getBean(ObservationRegistry.class); + assertThat(observationRegistry).isNotNull(); + CapturingObservationHandler handler = new CapturingObservationHandler(); + observationRegistry.observationConfig().observationHandler(handler); + return handler; + } + + private static final class CapturingObservationHandler implements ObservationHandler { + + private final AtomicReference context = new AtomicReference<>(); + + @Override + public boolean supportsContext(Context context) { + return true; + } + + @Override + public void onStart(Context context) { + this.context.set(context); + } + + Context awaitContext() { + return Awaitility.await().untilAtomic(this.context, Matchers.notNullValue()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfigurationTests.java new file mode 100644 index 000000000000..60ef5d14c5cc --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/scheduling/ScheduledTasksObservabilityAutoConfigurationTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.scheduling; + +import java.util.List; + +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.scheduling.ScheduledTasksObservabilityAutoConfiguration.ObservabilitySchedulingConfigurer; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.ImportCandidates; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ScheduledTasksObservabilityAutoConfiguration}. + * + * @author Moritz Halbritter + */ +class ScheduledTasksObservabilityAutoConfigurationTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner().withConfiguration(AutoConfigurations + .of(ObservationAutoConfiguration.class, ScheduledTasksObservabilityAutoConfiguration.class)); + + @Test + void shouldProvideObservabilitySchedulingConfigurer() { + this.runner.run((context) -> assertThat(context).hasSingleBean(ObservabilitySchedulingConfigurer.class)); + } + + @Test + void observabilitySchedulingConfigurerShouldConfigureObservationRegistry() { + ObservationRegistry observationRegistry = ObservationRegistry.create(); + ObservabilitySchedulingConfigurer configurer = new ObservabilitySchedulingConfigurer(observationRegistry); + ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar(); + configurer.configureTasks(registrar); + assertThat(registrar.getObservationRegistry()).isEqualTo(observationRegistry); + } + + @Test + void isRegisteredInAutoConfigurationsFile() { + List configurations = ImportCandidates.load(AutoConfiguration.class, null).getCandidates(); + assertThat(configurations).contains(ScheduledTasksObservabilityAutoConfiguration.class.getName()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfigurationTests.java index fbfe9f23efe9..66b39ded5a73 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/ReactiveManagementWebSecurityAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration; import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; -import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; @@ -48,6 +47,8 @@ import org.springframework.mock.http.server.reactive.MockServerHttpResponse; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.web.server.ServerWebExchange; @@ -70,17 +71,26 @@ class ReactiveManagementWebSecurityAutoConfigurationTests { HealthEndpointAutoConfiguration.class, InfoEndpointAutoConfiguration.class, WebFluxAutoConfiguration.class, EnvironmentEndpointAutoConfiguration.class, EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class, - ReactiveSecurityAutoConfiguration.class, ReactiveUserDetailsServiceAutoConfiguration.class, - ReactiveManagementWebSecurityAutoConfiguration.class)); + ReactiveSecurityAutoConfiguration.class, ReactiveManagementWebSecurityAutoConfiguration.class)); @Test void permitAllForHealth() { - this.contextRunner.run((context) -> assertThat(getAuthenticateHeader(context, "/actuator/health")).isNull()); + this.contextRunner.withUserConfiguration(UserDetailsServiceConfiguration.class) + .run((context) -> assertThat(getAuthenticateHeader(context, "/actuator/health")).isNull()); } @Test void securesEverythingElse() { + this.contextRunner.withUserConfiguration(UserDetailsServiceConfiguration.class).run((context) -> { + assertThat(getAuthenticateHeader(context, "/actuator").get(0)).contains("Basic realm="); + assertThat(getAuthenticateHeader(context, "/foo").toString()).contains("Basic realm="); + }); + } + + @Test + void noExistingAuthenticationManagerOrUserDetailsService() { this.contextRunner.run((context) -> { + assertThat(getAuthenticateHeader(context, "/actuator/health")).isNull(); assertThat(getAuthenticateHeader(context, "/actuator").get(0)).contains("Basic realm="); assertThat(getAuthenticateHeader(context, "/foo").toString()).contains("Basic realm="); }); @@ -88,10 +98,12 @@ void securesEverythingElse() { @Test void usesMatchersBasedOffConfiguredActuatorBasePath() { - this.contextRunner.withPropertyValues("management.endpoints.web.base-path=/").run((context) -> { - assertThat(getAuthenticateHeader(context, "/health")).isNull(); - assertThat(getAuthenticateHeader(context, "/foo").get(0)).contains("Basic realm="); - }); + this.contextRunner.withUserConfiguration(UserDetailsServiceConfiguration.class) + .withPropertyValues("management.endpoints.web.base-path=/") + .run((context) -> { + assertThat(getAuthenticateHeader(context, "/health")).isNull(); + assertThat(getAuthenticateHeader(context, "/foo").get(0)).contains("Basic realm="); + }); } @Test @@ -155,6 +167,17 @@ protected ServerWebExchange createExchange(ServerHttpRequest request, ServerHttp } + @Configuration(proxyBeanMethods = false) + static class UserDetailsServiceConfiguration { + + @Bean + MapReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsService( + User.withUsername("alice").password("secret").roles("admin").build()); + } + + } + @Configuration(proxyBeanMethods = false) static class CustomSecurityConfiguration { @@ -168,6 +191,11 @@ SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { return http.build(); } + @Bean + ReactiveAuthenticationManager authenticationManager() { + return mock(ReactiveAuthenticationManager.class); + } + } @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java index 35236a3c5696..9417437d92f7 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/AbstractEndpointRequestIntegrationTests.java @@ -37,7 +37,6 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; -import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; @@ -45,6 +44,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.test.web.reactive.server.WebTestClient; @@ -100,8 +101,8 @@ protected final WebApplicationContextRunner getContextRunner() { return createContextRunner().withPropertyValues("management.endpoints.web.exposure.include=*") .withUserConfiguration(BaseConfiguration.class, SecurityConfiguration.class) .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, SecurityAutoConfiguration.class, - UserDetailsServiceAutoConfiguration.class, EndpointAutoConfiguration.class, - WebEndpointAutoConfiguration.class, ManagementContextAutoConfiguration.class)); + EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class, + ManagementContextAutoConfiguration.class)); } @@ -189,6 +190,12 @@ public EndpointServlet get() { @Configuration(proxyBeanMethods = false) static class SecurityConfiguration { + @Bean + InMemoryUserDetailsManager userDetailsManager() { + return new InMemoryUserDetailsManager( + User.withUsername("user").password("{noop}password").roles("admin").build()); + } + @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((requests) -> { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/session/SessionsEndpointAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/session/SessionsEndpointAutoConfigurationTests.java index 576fe6bbaee5..491e4e3d6186 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/session/SessionsEndpointAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/session/SessionsEndpointAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,20 @@ package org.springframework.boot.actuate.autoconfigure.session; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.session.ReactiveSessionsEndpoint; import org.springframework.boot.actuate.session.SessionsEndpoint; import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.SessionRepository; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -32,36 +38,117 @@ * Tests for {@link SessionsEndpointAutoConfiguration}. * * @author Vedran Pavic + * @author Moritz Halbritter */ class SessionsEndpointAutoConfigurationTests { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(SessionsEndpointAutoConfiguration.class)) - .withUserConfiguration(SessionConfiguration.class); + @Nested + class ServletSessionEndpointConfigurationTests { - @Test - void runShouldHaveEndpointBean() { - this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=sessions") - .run((context) -> assertThat(context).hasSingleBean(SessionsEndpoint.class)); - } + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SessionsEndpointAutoConfiguration.class)) + .withUserConfiguration(IndexedSessionRepositoryConfiguration.class); - @Test - void runWhenNotExposedShouldNotHaveEndpointBean() { - this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(SessionsEndpoint.class)); - } + @Test + void runShouldHaveEndpointBean() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=sessions") + .run((context) -> assertThat(context).hasSingleBean(SessionsEndpoint.class)); + } + + @Test + void runWhenNoIndexedSessionRepositoryShouldHaveEndpointBean() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SessionsEndpointAutoConfiguration.class)) + .withUserConfiguration(SessionRepositoryConfiguration.class) + .withPropertyValues("management.endpoints.web.exposure.include=sessions") + .run((context) -> assertThat(context).hasSingleBean(SessionsEndpoint.class)); + } + + @Test + void runWhenNotExposedShouldNotHaveEndpointBean() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(SessionsEndpoint.class)); + } + + @Test + void runWhenEnabledPropertyIsFalseShouldNotHaveEndpointBean() { + this.contextRunner.withPropertyValues("management.endpoint.sessions.enabled:false") + .run((context) -> assertThat(context).doesNotHaveBean(SessionsEndpoint.class)); + } + + @Configuration(proxyBeanMethods = false) + static class IndexedSessionRepositoryConfiguration { + + @Bean + FindByIndexNameSessionRepository sessionRepository() { + return mock(FindByIndexNameSessionRepository.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class SessionRepositoryConfiguration { + + @Bean + SessionRepository sessionRepository() { + return mock(SessionRepository.class); + } + + } - @Test - void runWhenEnabledPropertyIsFalseShouldNotHaveEndpointBean() { - this.contextRunner.withPropertyValues("management.endpoint.sessions.enabled:false") - .run((context) -> assertThat(context).doesNotHaveBean(SessionsEndpoint.class)); } - @Configuration(proxyBeanMethods = false) - static class SessionConfiguration { + @Nested + class ReactiveSessionEndpointConfigurationTests { + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SessionsEndpointAutoConfiguration.class)) + .withUserConfiguration(ReactiveSessionRepositoryConfiguration.class, + ReactiveIndexedSessionRepositoryConfiguration.class); + + @Test + void runShouldHaveEndpointBean() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=sessions") + .run((context) -> assertThat(context).hasSingleBean(ReactiveSessionsEndpoint.class)); + } + + @Test + void runWhenNoIndexedSessionRepositoryShouldHaveEndpointBean() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SessionsEndpointAutoConfiguration.class)) + .withUserConfiguration(ReactiveSessionRepositoryConfiguration.class) + .withPropertyValues("management.endpoints.web.exposure.include=sessions") + .run((context) -> assertThat(context).hasSingleBean(ReactiveSessionsEndpoint.class)); + } + + @Test + void runWhenNotExposedShouldNotHaveEndpointBean() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ReactiveSessionsEndpoint.class)); + } + + @Test + void runWhenEnabledPropertyIsFalseShouldNotHaveEndpointBean() { + this.contextRunner.withPropertyValues("management.endpoint.sessions.enabled:false") + .run((context) -> assertThat(context).doesNotHaveBean(ReactiveSessionsEndpoint.class)); + } + + @Configuration(proxyBeanMethods = false) + static class ReactiveIndexedSessionRepositoryConfiguration { + + @Bean + ReactiveFindByIndexNameSessionRepository indexedSessionRepository() { + return mock(ReactiveFindByIndexNameSessionRepository.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class ReactiveSessionRepositoryConfiguration { + + @Bean + ReactiveSessionRepository sessionRepository() { + return mock(ReactiveSessionRepository.class); + } - @Bean - FindByIndexNameSessionRepository sessionRepository() { - return mock(FindByIndexNameSessionRepository.class); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BaggagePropagationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BaggagePropagationIntegrationTests.java index 6a243e92284e..c67725dbf4a4 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BaggagePropagationIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BaggagePropagationIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ import org.junit.jupiter.params.provider.EnumSource; import org.slf4j.MDC; +import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.ApplicationContext; @@ -151,8 +152,9 @@ public ApplicationContextRunner get() { OTEL_DEFAULT { @Override public ApplicationContextRunner get() { - return new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class)) + return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of( + OpenTelemetryAutoConfiguration.class, + org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class)) .withPropertyValues("management.tracing.baggage.remote-fields=x-vcap-request-id,country-code,bp", "management.tracing.baggage.correlation.fields=country-code,bp"); } @@ -172,8 +174,9 @@ public ApplicationContextRunner get() { OTEL_W3C { @Override public ApplicationContextRunner get() { - return new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class)) + return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of( + OpenTelemetryAutoConfiguration.class, + org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class)) .withPropertyValues("management.tracing.propagation.type=W3C", "management.tracing.baggage.remote-fields=x-vcap-request-id,country-code,bp", "management.tracing.baggage.correlation.fields=country-code,bp"); @@ -205,8 +208,9 @@ public ApplicationContextRunner get() { OTEL_B3 { @Override public ApplicationContextRunner get() { - return new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class)) + return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of( + OpenTelemetryAutoConfiguration.class, + org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class)) .withPropertyValues("management.tracing.propagation.type=B3", "management.tracing.baggage.remote-fields=x-vcap-request-id,country-code,bp", "management.tracing.baggage.correlation.fields=country-code,bp"); @@ -216,12 +220,23 @@ public ApplicationContextRunner get() { OTEL_B3_MULTI { @Override public ApplicationContextRunner get() { - return new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class)) + return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of( + OpenTelemetryAutoConfiguration.class, + org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class)) .withPropertyValues("management.tracing.propagation.type=B3_MULTI", "management.tracing.baggage.remote-fields=x-vcap-request-id,country-code,bp", "management.tracing.baggage.correlation.fields=country-code,bp"); } + }, + + BRAVE_LOCAL_FIELDS { + @Override + public ApplicationContextRunner get() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(BraveAutoConfiguration.class)) + .withPropertyValues("management.tracing.baggage.local-fields=country-code,bp", + "management.tracing.baggage.correlation.fields=country-code,bp"); + } } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfigurationTests.java index e882188eea6d..0bc08733ff28 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/BraveAutoConfigurationTests.java @@ -17,7 +17,9 @@ package org.springframework.boot.actuate.autoconfigure.tracing; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import brave.Span; @@ -31,6 +33,7 @@ import brave.propagation.CurrentTraceContext.ScopeDecorator; import brave.propagation.Propagation; import brave.propagation.Propagation.Factory; +import brave.propagation.TraceContext; import brave.sampler.Sampler; import io.micrometer.tracing.brave.bridge.BraveBaggageManager; import io.micrometer.tracing.brave.bridge.BraveSpanCustomizer; @@ -153,9 +156,28 @@ void shouldSupplyB3PropagationFactoryViaProperty() { } @Test - void shouldNotSupplyBeansIfTracingIsDisabled() { - this.contextRunner.withPropertyValues("management.tracing.enabled=false") - .run((context) -> assertThat(context).doesNotHaveBean(BraveAutoConfiguration.class)); + void shouldUseB3SingleWithParentWhenPropagationTypeIsB3() { + this.contextRunner + .withPropertyValues("management.tracing.propagation.type=B3", "management.tracing.sampling.probability=1.0") + .run((context) -> { + Propagation propagation = context.getBean(Factory.class).get(); + Tracer tracer = context.getBean(Tracing.class).tracer(); + Span child; + Span parent = tracer.nextSpan().name("parent"); + try (Tracer.SpanInScope ignored = tracer.withSpanInScope(parent.start())) { + child = tracer.nextSpan().name("child"); + child.start().finish(); + } + finally { + parent.finish(); + } + + Map map = new HashMap<>(); + TraceContext childContext = child.context(); + propagation.injector(this::injectToMap).inject(childContext, map); + assertThat(map).containsExactly(Map.entry("b3", "%s-%s-1-%s".formatted(childContext.traceIdString(), + childContext.spanIdString(), childContext.parentIdString()))); + }); } @Test @@ -311,17 +333,41 @@ void compositeSpanHandlerUsesFilterPredicateAndReportersInOrder() { .getBean(CompositeSpanHandlerComponentsConfiguration.class); CompositeSpanHandler composite = context.getBean(CompositeSpanHandler.class); assertThat(composite).extracting("spanFilters") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .containsExactly(components.filter1, components.filter2); assertThat(composite).extracting("filters") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .containsExactly(components.predicate2, components.predicate1); assertThat(composite).extracting("reporters") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .containsExactly(components.reporter1, components.reporter3, components.reporter2); }); } + @Test + void shouldDisablePropagationIfTracingIsDisabled() { + this.contextRunner.withPropertyValues("management.tracing.enabled=false").run((context) -> { + assertThat(context).hasSingleBean(Factory.class); + Factory factory = context.getBean(Factory.class); + Propagation propagation = factory.get(); + assertThat(propagation.keys()).isEmpty(); + }); + } + + @Test + void shouldConfigureTaggedFields() { + this.contextRunner.withPropertyValues("management.tracing.baggage.tag-fields=t1").run((context) -> { + BraveTracer braveTracer = context.getBean(BraveTracer.class); + assertThat(braveTracer).extracting("braveBaggageManager.tagFields") + .asInstanceOf(InstanceOfAssertFactories.list(String.class)) + .containsExactly("t1"); + }); + } + + private void injectToMap(Map map, String key, String value) { + map.put(key, value); + } + private List getInjectors(Factory factory) { assertThat(factory).as("factory").isNotNull(); if (factory instanceof CompositePropagationFactory compositePropagationFactory) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessorTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessorTests.java new file mode 100644 index 000000000000..3cf84a10db6f --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/LogCorrelationEnvironmentPostProcessorTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.logging.LoggingSystem; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.StandardEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LogCorrelationEnvironmentPostProcessor}. + * + * @author Jonatan Ivanov + * @author Phillip Webb + */ +class LogCorrelationEnvironmentPostProcessorTests { + + private final ConfigurableEnvironment environment = new StandardEnvironment(); + + private final SpringApplication application = new SpringApplication(); + + private final LogCorrelationEnvironmentPostProcessor postProcessor = new LogCorrelationEnvironmentPostProcessor(); + + @Test + void getExpectCorrelationIdPropertyWhenMicrometerTracingPresentReturnsTrue() { + this.postProcessor.postProcessEnvironment(this.environment, this.application); + assertThat(this.environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false)) + .isTrue(); + } + + @Test + @ClassPathExclusions("micrometer-tracing-*.jar") + void getExpectCorrelationIdPropertyWhenMicrometerTracingMissingReturnsFalse() { + this.postProcessor.postProcessEnvironment(this.environment, this.application); + assertThat(this.environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false)) + .isFalse(); + } + + @Test + void getExpectCorrelationIdPropertyWhenTracingDisabledReturnsFalse() { + TestPropertyValues.of("management.tracing.enabled=false").applyTo(this.environment); + this.postProcessor.postProcessEnvironment(this.environment, this.application); + assertThat(this.environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false)) + .isFalse(); + } + + @Test + void postProcessEnvironmentAddsEnumerablePropertySource() { + this.postProcessor.postProcessEnvironment(this.environment, this.application); + PropertySource propertySource = this.environment.getPropertySources().get("logCorrelation"); + assertThat(propertySource).isInstanceOf(EnumerablePropertySource.class); + assertThat(((EnumerablePropertySource) propertySource).getPropertyNames()) + .containsExactly(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfigurationTests.java index bfef8cccd539..c297dee2e1fb 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/MicrometerTracingAutoConfigurationTests.java @@ -18,12 +18,21 @@ import java.util.List; +import io.micrometer.common.annotation.ValueExpressionResolver; +import io.micrometer.common.annotation.ValueResolver; import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.annotation.DefaultNewSpanParser; +import io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor; +import io.micrometer.tracing.annotation.MethodInvocationProcessor; +import io.micrometer.tracing.annotation.NewSpanParser; +import io.micrometer.tracing.annotation.SpanAspect; +import io.micrometer.tracing.annotation.SpanTagAnnotationHandler; import io.micrometer.tracing.handler.DefaultTracingObservationHandler; import io.micrometer.tracing.handler.PropagatingReceiverTracingObservationHandler; import io.micrometer.tracing.handler.PropagatingSenderTracingObservationHandler; import io.micrometer.tracing.handler.TracingObservationHandler; import io.micrometer.tracing.propagation.Propagator; +import org.aspectj.weaver.Advice; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -39,19 +48,27 @@ * Tests for {@link MicrometerTracingAutoConfiguration}. * * @author Moritz Halbritter + * @author Jonatan Ivanov + * @author Brian Clozel */ class MicrometerTracingAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("micrometer.observations.annotations.enabled=true") .withConfiguration(AutoConfigurations.of(MicrometerTracingAutoConfiguration.class)); @Test void shouldSupplyBeans() { this.contextRunner.withUserConfiguration(TracerConfiguration.class, PropagatorConfiguration.class) + .withPropertyValues("micrometer.observations.annotations.enabled=true") .run((context) -> { assertThat(context).hasSingleBean(DefaultTracingObservationHandler.class); assertThat(context).hasSingleBean(PropagatingReceiverTracingObservationHandler.class); assertThat(context).hasSingleBean(PropagatingSenderTracingObservationHandler.class); + assertThat(context).hasSingleBean(DefaultNewSpanParser.class); + assertThat(context).hasSingleBean(ImperativeMethodInvocationProcessor.class); + assertThat(context).hasSingleBean(SpanAspect.class); + assertThat(context).hasSingleBean(SpanTagAnnotationHandler.class); }); } @@ -75,14 +92,23 @@ void shouldSupplyBeansInCorrectOrder() { @Test void shouldBackOffOnCustomBeans() { - this.contextRunner.withUserConfiguration(CustomConfiguration.class).run((context) -> { - assertThat(context).hasBean("customDefaultTracingObservationHandler"); - assertThat(context).hasSingleBean(DefaultTracingObservationHandler.class); - assertThat(context).hasBean("customPropagatingReceiverTracingObservationHandler"); - assertThat(context).hasSingleBean(PropagatingReceiverTracingObservationHandler.class); - assertThat(context).hasBean("customPropagatingSenderTracingObservationHandler"); - assertThat(context).hasSingleBean(PropagatingSenderTracingObservationHandler.class); - }); + this.contextRunner.withUserConfiguration(TracerConfiguration.class, CustomConfiguration.class) + .run((context) -> { + assertThat(context).hasBean("customDefaultTracingObservationHandler"); + assertThat(context).hasSingleBean(DefaultTracingObservationHandler.class); + assertThat(context).hasBean("customPropagatingReceiverTracingObservationHandler"); + assertThat(context).hasSingleBean(PropagatingReceiverTracingObservationHandler.class); + assertThat(context).hasBean("customPropagatingSenderTracingObservationHandler"); + assertThat(context).hasSingleBean(PropagatingSenderTracingObservationHandler.class); + assertThat(context).hasBean("customDefaultNewSpanParser"); + assertThat(context).hasSingleBean(DefaultNewSpanParser.class); + assertThat(context).hasBean("customImperativeMethodInvocationProcessor"); + assertThat(context).hasSingleBean(ImperativeMethodInvocationProcessor.class); + assertThat(context).hasBean("customSpanAspect"); + assertThat(context).hasSingleBean(SpanAspect.class); + assertThat(context).hasBean("customSpanTagAnnotationHandler"); + assertThat(context).hasSingleBean(SpanTagAnnotationHandler.class); + }); } @Test @@ -91,6 +117,9 @@ void shouldNotSupplyBeansIfMicrometerIsMissing() { assertThat(context).doesNotHaveBean(DefaultTracingObservationHandler.class); assertThat(context).doesNotHaveBean(PropagatingReceiverTracingObservationHandler.class); assertThat(context).doesNotHaveBean(PropagatingSenderTracingObservationHandler.class); + assertThat(context).doesNotHaveBean(DefaultNewSpanParser.class); + assertThat(context).doesNotHaveBean(ImperativeMethodInvocationProcessor.class); + assertThat(context).doesNotHaveBean(SpanAspect.class); }); } @@ -100,25 +129,54 @@ void shouldNotSupplyBeansIfTracerIsMissing() { assertThat(context).doesNotHaveBean(DefaultTracingObservationHandler.class); assertThat(context).doesNotHaveBean(PropagatingReceiverTracingObservationHandler.class); assertThat(context).doesNotHaveBean(PropagatingSenderTracingObservationHandler.class); + assertThat(context).doesNotHaveBean(DefaultNewSpanParser.class); + assertThat(context).doesNotHaveBean(ImperativeMethodInvocationProcessor.class); + assertThat(context).doesNotHaveBean(SpanAspect.class); }); } + @Test + void shouldNotSupplyAspectBeansIfPropertyIsDisabled() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class, PropagatorConfiguration.class) + .withPropertyValues("micrometer.observations.annotations.enabled=false") + .run((context) -> { + assertThat(context).doesNotHaveBean(DefaultNewSpanParser.class); + assertThat(context).doesNotHaveBean(ImperativeMethodInvocationProcessor.class); + assertThat(context).doesNotHaveBean(SpanAspect.class); + }); + } + + @Test + void shouldNotSupplyBeansIfAspectjIsMissing() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class) + .withClassLoader(new FilteredClassLoader(Advice.class)) + .run((context) -> { + assertThat(context).doesNotHaveBean(DefaultNewSpanParser.class); + assertThat(context).doesNotHaveBean(ImperativeMethodInvocationProcessor.class); + assertThat(context).doesNotHaveBean(SpanAspect.class); + }); + } + @Test void shouldNotSupplyBeansIfPropagatorIsMissing() { this.contextRunner.withUserConfiguration(TracerConfiguration.class).run((context) -> { assertThat(context).doesNotHaveBean(PropagatingSenderTracingObservationHandler.class); assertThat(context).doesNotHaveBean(PropagatingReceiverTracingObservationHandler.class); + + assertThat(context).hasSingleBean(DefaultNewSpanParser.class); + assertThat(context).hasSingleBean(ImperativeMethodInvocationProcessor.class); + assertThat(context).hasSingleBean(SpanAspect.class); }); } @Test - void shouldNotSupplyBeansIfTracingIsDisabled() { - this.contextRunner.withUserConfiguration(TracerConfiguration.class, PropagatorConfiguration.class) - .withPropertyValues("management.tracing.enabled=false") + void shouldConfigureSpanTagAnnotationHandler() { + this.contextRunner.withUserConfiguration(TracerConfiguration.class, SpanTagAnnotationHandlerConfiguration.class) .run((context) -> { - assertThat(context).doesNotHaveBean(DefaultTracingObservationHandler.class); - assertThat(context).doesNotHaveBean(PropagatingReceiverTracingObservationHandler.class); - assertThat(context).doesNotHaveBean(PropagatingSenderTracingObservationHandler.class); + assertThat(context).hasSingleBean(DefaultNewSpanParser.class); + assertThat(context).hasSingleBean(SpanAspect.class); + assertThat(context.getBean(ImperativeMethodInvocationProcessor.class)).hasFieldOrPropertyWithValue( + "spanTagAnnotationHandler", context.getBean(SpanTagAnnotationHandler.class)); }); } @@ -160,6 +218,38 @@ PropagatingSenderTracingObservationHandler customPropagatingSenderTracingObse return mock(PropagatingSenderTracingObservationHandler.class); } + @Bean + DefaultNewSpanParser customDefaultNewSpanParser() { + return new DefaultNewSpanParser(); + } + + @Bean + ImperativeMethodInvocationProcessor customImperativeMethodInvocationProcessor(NewSpanParser newSpanParser, + Tracer tracer) { + return new ImperativeMethodInvocationProcessor(newSpanParser, tracer); + } + + @Bean + SpanAspect customSpanAspect(MethodInvocationProcessor methodInvocationProcessor) { + return new SpanAspect(methodInvocationProcessor); + } + + @Bean + SpanTagAnnotationHandler customSpanTagAnnotationHandler() { + return new SpanTagAnnotationHandler((aClass) -> mock(ValueResolver.class), + (aClass) -> mock(ValueExpressionResolver.class)); + } + + } + + @Configuration(proxyBeanMethods = false) + private static final class SpanTagAnnotationHandlerConfiguration { + + @Bean + SpanTagAnnotationHandler spanTagAnnotationHandler() { + return new SpanTagAnnotationHandler((valueResolverClass) -> null, (valueExpressionResolverClass) -> null); + } + } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/NoopTracerAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/NoopTracerAutoConfigurationTests.java new file mode 100644 index 000000000000..1677d97fca58 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/NoopTracerAutoConfigurationTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing; + +import io.micrometer.tracing.Tracer; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NoopTracerAutoConfiguration}. + * + * @author Moritz Halbritter + */ +class NoopTracerAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(NoopTracerAutoConfiguration.class)); + + @Test + void shouldSupplyNoopTracer() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(Tracer.class); + Tracer tracer = context.getBean(Tracer.class); + assertThat(tracer).isEqualTo(Tracer.NOOP); + }); + } + + @Test + void shouldBackOffOnCustomTracer() { + this.contextRunner.withUserConfiguration(CustomTracerConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(Tracer.class); + assertThat(context).hasBean("customTracer"); + Tracer tracer = context.getBean(Tracer.class); + assertThat(tracer).isNotEqualTo(Tracer.NOOP); + }); + } + + @Test + void shouldBackOffIfMicrometerTracingIsMissing() { + this.contextRunner.withClassLoader(new FilteredClassLoader("io.micrometer.tracing")) + .run((context) -> assertThat(context).doesNotHaveBean(Tracer.class)); + + } + + @Configuration(proxyBeanMethods = false) + private static final class CustomTracerConfiguration { + + @Bean + Tracer customTracer() { + return mock(Tracer.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfigurationTests.java index 8861c0ef2088..b96ac2da59f3 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/OpenTelemetryAutoConfigurationTests.java @@ -34,9 +34,9 @@ import io.micrometer.tracing.otel.bridge.Slf4JBaggageEventListener; import io.micrometer.tracing.otel.bridge.Slf4JEventListener; import io.micrometer.tracing.otel.propagation.BaggageTextMapPropagator; -import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.MeterProvider; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; import io.opentelemetry.context.propagation.ContextPropagators; @@ -50,10 +50,12 @@ import io.opentelemetry.sdk.trace.data.SpanData; import io.opentelemetry.sdk.trace.export.SpanExporter; import io.opentelemetry.sdk.trace.samplers.Sampler; -import io.opentelemetry.semconv.resource.attributes.ResourceAttributes; +import io.opentelemetry.semconv.ResourceAttributes; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -65,6 +67,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; /** @@ -77,7 +82,9 @@ class OpenTelemetryAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OpenTelemetryAutoConfiguration.class)); + .withConfiguration(AutoConfigurations.of( + org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration.class, + OpenTelemetryAutoConfiguration.class)); @Test void shouldSupplyBeans() { @@ -85,7 +92,6 @@ void shouldSupplyBeans() { assertThat(context).hasSingleBean(OtelTracer.class); assertThat(context).hasSingleBean(EventPublisher.class); assertThat(context).hasSingleBean(OtelCurrentTraceContext.class); - assertThat(context).hasSingleBean(OpenTelemetry.class); assertThat(context).hasSingleBean(SdkTracerProvider.class); assertThat(context).hasSingleBean(ContextPropagators.class); assertThat(context).hasSingleBean(Sampler.class); @@ -96,6 +102,8 @@ void shouldSupplyBeans() { assertThat(context).hasSingleBean(OtelPropagator.class); assertThat(context).hasSingleBean(TextMapPropagator.class); assertThat(context).hasSingleBean(OtelSpanCustomizer.class); + assertThat(context).hasSingleBean(SpanProcessors.class); + assertThat(context).hasSingleBean(SpanExporters.class); }); } @@ -115,7 +123,6 @@ void shouldNotSupplyBeansIfDependencyIsMissing(String packageName) { assertThat(context).doesNotHaveBean(OtelTracer.class); assertThat(context).doesNotHaveBean(EventPublisher.class); assertThat(context).doesNotHaveBean(OtelCurrentTraceContext.class); - assertThat(context).doesNotHaveBean(OpenTelemetry.class); assertThat(context).doesNotHaveBean(SdkTracerProvider.class); assertThat(context).doesNotHaveBean(ContextPropagators.class); assertThat(context).doesNotHaveBean(Sampler.class); @@ -126,6 +133,8 @@ void shouldNotSupplyBeansIfDependencyIsMissing(String packageName) { assertThat(context).doesNotHaveBean(OtelPropagator.class); assertThat(context).doesNotHaveBean(TextMapPropagator.class); assertThat(context).doesNotHaveBean(OtelSpanCustomizer.class); + assertThat(context).doesNotHaveBean(SpanProcessors.class); + assertThat(context).doesNotHaveBean(SpanExporters.class); }); } @@ -138,8 +147,6 @@ void shouldBackOffOnCustomBeans() { assertThat(context).hasSingleBean(EventPublisher.class); assertThat(context).hasBean("customOtelCurrentTraceContext"); assertThat(context).hasSingleBean(OtelCurrentTraceContext.class); - assertThat(context).hasBean("customOpenTelemetry"); - assertThat(context).hasSingleBean(OpenTelemetry.class); assertThat(context).hasBean("customSdkTracerProvider"); assertThat(context).hasSingleBean(SdkTracerProvider.class); assertThat(context).hasBean("customContextPropagators"); @@ -156,6 +163,10 @@ void shouldBackOffOnCustomBeans() { assertThat(context).hasSingleBean(OtelPropagator.class); assertThat(context).hasBean("customSpanCustomizer"); assertThat(context).hasSingleBean(SpanCustomizer.class); + assertThat(context).hasBean("customSpanProcessors"); + assertThat(context).hasSingleBean(SpanProcessors.class); + assertThat(context).hasBean("customSpanExporters"); + assertThat(context).hasSingleBean(SpanExporters.class); }); } @@ -172,7 +183,7 @@ void shouldSetupDefaultResourceAttributes() { exporter.await(Duration.ofSeconds(10)); SpanData spanData = exporter.getExportedSpans().get(0); Map, Object> expectedAttributes = Resource.getDefault() - .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "application"))) + .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "unknown_service"))) .getAttributes() .asMap(); assertThat(spanData.getResource().getAttributes().asMap()).isEqualTo(expectedAttributes); @@ -181,9 +192,22 @@ void shouldSetupDefaultResourceAttributes() { @Test void shouldAllowMultipleSpanProcessors() { - this.contextRunner.withUserConfiguration(CustomConfiguration.class).run((context) -> { + this.contextRunner.withUserConfiguration(AdditionalSpanProcessorConfiguration.class).run((context) -> { assertThat(context.getBeansOfType(SpanProcessor.class)).hasSize(2); assertThat(context).hasBean("customSpanProcessor"); + SpanProcessors spanProcessors = context.getBean(SpanProcessors.class); + assertThat(spanProcessors).hasSize(2); + }); + } + + @Test + void shouldAllowMultipleSpanExporters() { + this.contextRunner.withUserConfiguration(MultipleSpanExporterConfiguration.class).run((context) -> { + assertThat(context.getBeansOfType(SpanExporter.class)).hasSize(2); + assertThat(context).hasBean("spanExporter1"); + assertThat(context).hasBean("spanExporter2"); + SpanExporters spanExporters = context.getBean(SpanExporters.class); + assertThat(spanExporters).hasSize(2); }); } @@ -249,13 +273,20 @@ void shouldSupplyW3CPropagationWithoutBaggageWhenDisabled() { }); } - private List getInjectors(TextMapPropagator propagator) { - assertThat(propagator).as("propagator").isNotNull(); - if (propagator instanceof CompositeTextMapPropagator compositePropagator) { - return compositePropagator.getInjectors().stream().toList(); - } - fail("Expected CompositeTextMapPropagator, found %s".formatted(propagator.getClass())); - throw new AssertionError("Unreachable"); + @Test + void shouldConfigureRemoteAndTaggedFields() { + this.contextRunner + .withPropertyValues("management.tracing.baggage.remote-fields=r1", + "management.tracing.baggage.tag-fields=t1") + .run((context) -> { + CompositeTextMapPropagator propagator = context.getBean(CompositeTextMapPropagator.class); + assertThat(propagator).extracting("baggagePropagator.baggageManager.remoteFields") + .asInstanceOf(InstanceOfAssertFactories.list(String.class)) + .containsExactly("r1"); + assertThat(propagator).extracting("baggagePropagator.baggageManager.tagFields") + .asInstanceOf(InstanceOfAssertFactories.list(String.class)) + .containsExactly("t1"); + }); } @Test @@ -267,9 +298,84 @@ void shouldCustomizeSdkTracerProvider() { }); } + @Test + void defaultSpanProcessorShouldUseMeterProviderIfAvailable() { + this.contextRunner.withUserConfiguration(MeterProviderConfiguration.class).run((context) -> { + MeterProvider meterProvider = context.getBean(MeterProvider.class); + assertThat(Mockito.mockingDetails(meterProvider).isMock()).isTrue(); + then(meterProvider).should().meterBuilder(anyString()); + }); + } + + @Test + void shouldDisablePropagationIfTracingIsDisabled() { + this.contextRunner.withPropertyValues("management.tracing.enabled=false").run((context) -> { + assertThat(context).hasSingleBean(TextMapPropagator.class); + TextMapPropagator propagator = context.getBean(TextMapPropagator.class); + assertThat(propagator.fields()).isEmpty(); + }); + } + + private List getInjectors(TextMapPropagator propagator) { + assertThat(propagator).as("propagator").isNotNull(); + if (propagator instanceof CompositeTextMapPropagator compositePropagator) { + return compositePropagator.getInjectors().stream().toList(); + } + fail("Expected CompositeTextMapPropagator, found %s".formatted(propagator.getClass())); + throw new AssertionError("Unreachable"); + } + + @Configuration(proxyBeanMethods = false) + private static final class MeterProviderConfiguration { + + @Bean + MeterProvider meterProvider() { + MeterProvider mock = mock(MeterProvider.class); + given(mock.meterBuilder(anyString())) + .willAnswer((invocation) -> MeterProvider.noop().meterBuilder(invocation.getArgument(0, String.class))); + return mock; + } + + } + + @Configuration(proxyBeanMethods = false) + private static final class AdditionalSpanProcessorConfiguration { + + @Bean + SpanProcessor customSpanProcessor() { + return mock(SpanProcessor.class); + } + + } + + @Configuration(proxyBeanMethods = false) + private static final class MultipleSpanExporterConfiguration { + + @Bean + SpanExporter spanExporter1() { + return new DummySpanExporter(); + } + + @Bean + SpanExporter spanExporter2() { + return new DummySpanExporter(); + } + + } + @Configuration(proxyBeanMethods = false) private static final class CustomConfiguration { + @Bean + SpanProcessors customSpanProcessors() { + return SpanProcessors.of(mock(SpanProcessor.class)); + } + + @Bean + SpanExporters customSpanExporters() { + return SpanExporters.of(new DummySpanExporter()); + } + @Bean io.micrometer.tracing.Tracer customMicrometerTracer() { return mock(io.micrometer.tracing.Tracer.class); @@ -285,11 +391,6 @@ OtelCurrentTraceContext customOtelCurrentTraceContext() { return mock(OtelCurrentTraceContext.class); } - @Bean - OpenTelemetry customOpenTelemetry() { - return mock(OpenTelemetry.class); - } - @Bean SdkTracerProvider customSdkTracerProvider() { return SdkTracerProvider.builder().build(); @@ -365,6 +466,25 @@ SdkTracerProviderBuilderCustomizer sdkTracerProviderBuilderCustomizerTwo() { } + private static final class DummySpanExporter implements SpanExporter { + + @Override + public CompletableResultCode export(Collection spans) { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + + } + @Configuration(proxyBeanMethods = false) private static final class InMemoryRecordingSpanExporterConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExportersTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExportersTests.java new file mode 100644 index 000000000000..d15f2d1aceb6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanExportersTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing; + +import java.util.List; + +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SpanExporters}. + * + * @author Moritz Halbritter + */ +class SpanExportersTests { + + @Test + void ofList() { + SpanExporter spanExporter1 = mock(SpanExporter.class); + SpanExporter spanExporter2 = mock(SpanExporter.class); + SpanExporters spanExporters = SpanExporters.of(List.of(spanExporter1, spanExporter2)); + assertThat(spanExporters).containsExactly(spanExporter1, spanExporter2); + assertThat(spanExporters.list()).containsExactly(spanExporter1, spanExporter2); + } + + @Test + void ofArray() { + SpanExporter spanExporter1 = mock(SpanExporter.class); + SpanExporter spanExporter2 = mock(SpanExporter.class); + SpanExporters spanExporters = SpanExporters.of(spanExporter1, spanExporter2); + assertThat(spanExporters).containsExactly(spanExporter1, spanExporter2); + assertThat(spanExporters.list()).containsExactly(spanExporter1, spanExporter2); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessorsTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessorsTests.java new file mode 100644 index 000000000000..8a5fa76868de --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/SpanProcessorsTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-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.boot.actuate.autoconfigure.tracing; + +import java.util.List; + +import io.opentelemetry.sdk.trace.SpanProcessor; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SpanProcessors}. + * + * @author Moritz Halbritter + */ +class SpanProcessorsTests { + + @Test + void ofList() { + SpanProcessor spanProcessor1 = mock(SpanProcessor.class); + SpanProcessor spanProcessor2 = mock(SpanProcessor.class); + SpanProcessors spanProcessors = SpanProcessors.of(List.of(spanProcessor1, spanProcessor2)); + assertThat(spanProcessors).containsExactly(spanProcessor1, spanProcessor2); + assertThat(spanProcessors.list()).containsExactly(spanProcessor1, spanProcessor2); + } + + @Test + void ofArray() { + SpanProcessor spanProcessor1 = mock(SpanProcessor.class); + SpanProcessor spanProcessor2 = mock(SpanProcessor.class); + SpanProcessors spanProcessors = SpanProcessors.of(spanProcessor1, spanProcessor2); + assertThat(spanProcessors).containsExactly(spanProcessor1, spanProcessor2); + assertThat(spanProcessors.list()).containsExactly(spanProcessor1, spanProcessor2); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java index 47fbcc824a3c..0fcc77811f95 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationIntegrationTests.java @@ -34,8 +34,8 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration; -import org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -50,9 +50,10 @@ class OtlpAutoConfigurationIntegrationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withPropertyValues("management.tracing.sampling.probability=1.0") - .withConfiguration( - AutoConfigurations.of(ObservationAutoConfiguration.class, MicrometerTracingAutoConfiguration.class, - OpenTelemetryAutoConfiguration.class, OtlpAutoConfiguration.class)); + .withConfiguration(AutoConfigurations.of(ObservationAutoConfiguration.class, + MicrometerTracingAutoConfiguration.class, OpenTelemetryAutoConfiguration.class, + org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration.class, + OtlpAutoConfiguration.class)); private final MockWebServer mockWebServer = new MockWebServer(); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java index b46b6ba153dc..9ebebdabfa7f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/otlp/OtlpAutoConfigurationTests.java @@ -19,8 +19,10 @@ import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; import io.opentelemetry.sdk.trace.export.SpanExporter; +import okhttp3.HttpUrl; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConfigurations.ConnectionDetails.PropertiesOtlpTracingConnectionDetails; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -33,16 +35,27 @@ * Tests for {@link OtlpAutoConfiguration}. * * @author Jonatan Ivanov + * @author Moritz Halbritter + * @author Eddú Meléndez */ class OtlpAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(OtlpAutoConfiguration.class)); + private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner + .withPropertyValues("management.tracing.enabled=false"); + + @Test + void shouldNotSupplyBeansIfPropertyIsNotSet() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class)); + } + @Test void shouldSupplyBeans() { - this.contextRunner.run((context) -> assertThat(context).hasSingleBean(OtlpHttpSpanExporter.class) - .hasSingleBean(SpanExporter.class)); + this.contextRunner.withPropertyValues("management.otlp.tracing.endpoint=http://localhost:4318/v1/traces") + .run((context) -> assertThat(context).hasSingleBean(OtlpHttpSpanExporter.class) + .hasSingleBean(SpanExporter.class)); } @Test @@ -89,6 +102,30 @@ void shouldBackOffWhenCustomGrpcExporterIsDefined() { .hasSingleBean(SpanExporter.class)); } + @Test + void shouldNotSupplyOtlpHttpSpanExporterIfTracingIsDisabled() { + this.tracingDisabledContextRunner + .withPropertyValues("management.otlp.tracing.endpoint=http://localhost:4318/v1/traces") + .run((context) -> assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class)); + } + + @Test + void definesPropertiesBasedConnectionDetailsByDefault() { + this.contextRunner.withPropertyValues("management.otlp.tracing.endpoint=http://localhost:4318/v1/traces") + .run((context) -> assertThat(context).hasSingleBean(PropertiesOtlpTracingConnectionDetails.class)); + } + + @Test + void testConnectionFactoryWithOverridesWhenUsingCustomConnectionDetails() { + this.contextRunner.withUserConfiguration(ConnectionDetailsConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(OtlpTracingConnectionDetails.class) + .doesNotHaveBean(PropertiesOtlpTracingConnectionDetails.class); + OtlpHttpSpanExporter otlpHttpSpanExporter = context.getBean(OtlpHttpSpanExporter.class); + assertThat(otlpHttpSpanExporter).extracting("delegate.httpSender.url") + .isEqualTo(HttpUrl.get("http://localhost:12345/v1/traces")); + }); + } + @Configuration(proxyBeanMethods = false) private static final class CustomHttpExporterConfiguration { @@ -109,4 +146,14 @@ OtlpGrpcSpanExporter customOtlpGrpcSpanExporter() { } + @Configuration(proxyBeanMethods = false) + static class ConnectionDetailsConfiguration { + + @Bean + OtlpTracingConnectionDetails otlpTracingConnectionDetails() { + return () -> "http://localhost:12345/v1/traces"; + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfigurationTests.java index e0b5c51f91b0..e03ce27ae223 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/prometheus/PrometheusExemplarsAutoConfigurationTests.java @@ -16,6 +16,10 @@ package org.springframework.boot.actuate.autoconfigure.tracing.prometheus; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; import io.micrometer.prometheus.PrometheusMeterRegistry; @@ -33,6 +37,7 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -40,10 +45,16 @@ /** * Tests for {@link PrometheusExemplarsAutoConfiguration}. * - * * @author Jonatan Ivanov + * @author Jonatan Ivanov */ class PrometheusExemplarsAutoConfigurationTests { + private static final Pattern BUCKET_TRACE_INFO_PATTERN = Pattern.compile( + "^test_observation_seconds_bucket\\{error=\"none\",le=\".+\"} 1.0 # \\{span_id=\"(\\p{XDigit}+)\",trace_id=\"(\\p{XDigit}+)\"} .+$"); + + private static final Pattern COUNTER_TRACE_INFO_PATTERN = Pattern.compile( + "^test_observation_seconds_count\\{error=\"none\"} 1.0 # \\{span_id=\"(\\p{XDigit}+)\",trace_id=\"(\\p{XDigit}+)\"} .+$"); + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withPropertyValues("management.tracing.sampling.probability=1.0", "management.metrics.distribution.percentiles-histogram.all=true") @@ -52,12 +63,6 @@ class PrometheusExemplarsAutoConfigurationTests { AutoConfigurations.of(PrometheusExemplarsAutoConfiguration.class, ObservationAutoConfiguration.class, BraveAutoConfiguration.class, MicrometerTracingAutoConfiguration.class)); - @Test - void shouldNotSupplyBeansIfTracingIsDisabled() { - this.contextRunner.withPropertyValues("management.tracing.enabled=false") - .run((context) -> assertThat(context).doesNotHaveBean(SpanContextSupplier.class)); - } - @Test void shouldNotSupplyBeansIfPrometheusSupportIsMissing() { this.contextRunner.withClassLoader(new FilteredClassLoader("io.prometheus.client.exemplars")) @@ -86,9 +91,27 @@ void prometheusOpenMetricsOutputShouldContainExemplars() { Observation.start("test.observation", observationRegistry).stop(); PrometheusMeterRegistry prometheusMeterRegistry = context.getBean(PrometheusMeterRegistry.class); String openMetricsOutput = prometheusMeterRegistry.scrape(TextFormat.CONTENT_TYPE_OPENMETRICS_100); - assertThat(openMetricsOutput).contains("test_observation_seconds_bucket") - .containsOnlyOnce("trace_id=") - .containsOnlyOnce("span_id="); + + assertThat(openMetricsOutput).contains("test_observation_seconds_bucket"); + assertThat(openMetricsOutput).containsOnlyOnce("test_observation_seconds_count"); + assertThat(StringUtils.countOccurrencesOf(openMetricsOutput, "span_id")).isEqualTo(2); + assertThat(StringUtils.countOccurrencesOf(openMetricsOutput, "trace_id")).isEqualTo(2); + + Optional bucketTraceInfo = openMetricsOutput.lines() + .filter((line) -> line.contains("test_observation_seconds_bucket") && line.contains("span_id")) + .map(BUCKET_TRACE_INFO_PATTERN::matcher) + .flatMap(Matcher::results) + .map((matchResult) -> new TraceInfo(matchResult.group(2), matchResult.group(1))) + .findFirst(); + + Optional counterTraceInfo = openMetricsOutput.lines() + .filter((line) -> line.contains("test_observation_seconds_count") && line.contains("span_id")) + .map(COUNTER_TRACE_INFO_PATTERN::matcher) + .flatMap(Matcher::results) + .map((matchResult) -> new TraceInfo(matchResult.group(2), matchResult.group(1))) + .findFirst(); + + assertThat(bucketTraceInfo).isNotEmpty().contains(counterTraceInfo.orElse(null)); }); } @@ -104,4 +127,7 @@ SpanContextSupplier customSpanContextSupplier() { } + private record TraceInfo(String traceId, String spanId) { + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfigurationTests.java index 6f9f85069d2f..5b2095344eb7 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/wavefront/WavefrontTracingAutoConfigurationTests.java @@ -47,6 +47,9 @@ class WavefrontTracingAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( AutoConfigurations.of(WavefrontAutoConfiguration.class, WavefrontTracingAutoConfiguration.class)); + private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner + .withPropertyValues("management.tracing.enabled=false"); + @Test void shouldSupplyBeans() { this.contextRunner.withUserConfiguration(WavefrontSenderConfiguration.class).run((context) -> { @@ -83,14 +86,11 @@ void shouldNotSupplyBeansIfMicrometerReporterWavefrontIsMissing() { @Test void shouldNotSupplyBeansIfTracingIsDisabled() { - this.contextRunner.withPropertyValues("management.tracing.enabled=false") - .withUserConfiguration(WavefrontSenderConfiguration.class) - .run((context) -> { - assertThat(context).doesNotHaveBean(WavefrontSpanHandler.class); - assertThat(context).doesNotHaveBean(SpanMetrics.class); - assertThat(context).doesNotHaveBean(WavefrontBraveSpanHandler.class); - assertThat(context).doesNotHaveBean(WavefrontOtelSpanExporter.class); - }); + this.tracingDisabledContextRunner.withUserConfiguration(WavefrontSenderConfiguration.class).run((context) -> { + assertThat(context).doesNotHaveBean(WavefrontSpanHandler.class); + assertThat(context).doesNotHaveBean(WavefrontBraveSpanHandler.class); + assertThat(context).doesNotHaveBean(WavefrontOtelSpanExporter.class); + }); } @Test diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfigurationTests.java index 6453d24137dd..1d744175f26b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinAutoConfigurationTests.java @@ -59,12 +59,6 @@ void shouldBackOffOnCustomBeans() { }); } - @Test - void shouldNotSupplyBeansIfTracingIsDisabled() { - this.contextRunner.withPropertyValues("management.tracing.enabled=false") - .run((context) -> assertThat(context).doesNotHaveBean(BytesEncoder.class)); - } - @Test void definesPropertiesBasedConnectionDetailsByDefault() { this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PropertiesZipkinConnectionDetails.class)); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsBraveConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsBraveConfigurationTests.java index ba3c26c1f91d..7874d5ec7730 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsBraveConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsBraveConfigurationTests.java @@ -42,6 +42,9 @@ class ZipkinConfigurationsBraveConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(BraveConfiguration.class)); + private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner + .withPropertyValues("management.tracing.enabled=false"); + @Test void shouldSupplyBeans() { this.contextRunner.withUserConfiguration(ReporterConfiguration.class) @@ -79,6 +82,12 @@ void shouldSupplyZipkinSpanHandlerWithCustomSpanHandler() { }); } + @Test + void shouldNotSupplyZipkinSpanHandlerIfTracingIsDisabled() { + this.tracingDisabledContextRunner.withUserConfiguration(ReporterConfiguration.class) + .run((context) -> assertThat(context).doesNotHaveBean(ZipkinSpanHandler.class)); + } + @Configuration(proxyBeanMethods = false) private static final class ReporterConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsOpenTelemetryConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsOpenTelemetryConfigurationTests.java index 27fd9be141d1..76d1e3b5d06d 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsOpenTelemetryConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinConfigurationsOpenTelemetryConfigurationTests.java @@ -43,6 +43,9 @@ class ZipkinConfigurationsOpenTelemetryConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(BaseConfiguration.class, OpenTelemetryConfiguration.class)); + private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner + .withPropertyValues("management.tracing.enabled=false"); + @Test void shouldSupplyBeans() { this.contextRunner.withUserConfiguration(SenderConfiguration.class) @@ -70,6 +73,12 @@ void shouldBackOffOnCustomBeans() { }); } + @Test + void shouldNotSupplyZipkinSpanExporterIfTracingIsDisabled() { + this.tracingDisabledContextRunner.withUserConfiguration(SenderConfiguration.class) + .run((context) -> assertThat(context).doesNotHaveBean(ZipkinSpanExporter.class)); + } + @Configuration(proxyBeanMethods = false) private static final class SenderConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinHttpSenderTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinHttpSenderTests.java index 541747968f24..15fd231e4a1c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinHttpSenderTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinHttpSenderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -25,6 +25,7 @@ import java.util.concurrent.atomic.AtomicReference; import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import zipkin2.Callback; @@ -42,20 +43,25 @@ */ abstract class ZipkinHttpSenderTests { - protected Sender sut; + protected Sender sender; - abstract Sender createSut(); + abstract Sender createSender(); @BeforeEach - void setUp() { - this.sut = createSut(); + void beforeEach() throws Exception { + this.sender = createSender(); + } + + @AfterEach + void afterEach() throws IOException { + this.sender.close(); } @Test void sendSpansShouldThrowIfCloseWasCalled() throws IOException { - this.sut.close(); + this.sender.close(); assertThatExceptionOfType(ClosedSenderException.class) - .isThrownBy(() -> this.sut.sendSpans(Collections.emptyList())); + .isThrownBy(() -> this.sender.sendSpans(Collections.emptyList())); } protected void makeRequest(List encodedSpans, boolean async) throws IOException { @@ -69,8 +75,12 @@ protected void makeRequest(List encodedSpans, boolean async) throws IOEx } protected CallbackResult makeAsyncRequest(List encodedSpans) { + return makeAsyncRequest(this.sender, encodedSpans); + } + + protected CallbackResult makeAsyncRequest(Sender sender, List encodedSpans) { AtomicReference callbackResult = new AtomicReference<>(); - this.sut.sendSpans(encodedSpans).enqueue(new Callback<>() { + sender.sendSpans(encodedSpans).enqueue(new Callback<>() { @Override public void onSuccess(Void value) { callbackResult.set(new CallbackResult(true, null)); @@ -85,7 +95,11 @@ public void onError(Throwable t) { } protected void makeSyncRequest(List encodedSpans) throws IOException { - this.sut.sendSpans(encodedSpans).execute(); + makeSyncRequest(this.sender, encodedSpans); + } + + protected void makeSyncRequest(Sender sender, List encodedSpans) throws IOException { + sender.sendSpans(encodedSpans).execute(); } protected byte[] toByteArray(String input) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinRestTemplateSenderTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinRestTemplateSenderTests.java index 195345199065..ad30c2e5eb52 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinRestTemplateSenderTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinRestTemplateSenderTests.java @@ -54,14 +54,16 @@ class ZipkinRestTemplateSenderTests extends ZipkinHttpSenderTests { private MockRestServiceServer mockServer; @Override - Sender createSut() { + Sender createSender() { RestTemplate restTemplate = new RestTemplate(); this.mockServer = MockRestServiceServer.createServer(restTemplate); return new ZipkinRestTemplateSender(ZIPKIN_URL, restTemplate); } @AfterEach - void tearDown() { + @Override + void afterEach() throws IOException { + super.afterEach(); this.mockServer.verify(); } @@ -71,7 +73,7 @@ void checkShouldSendEmptySpanList() { .andExpect(method(HttpMethod.POST)) .andExpect(content().string("[]")) .andRespond(withStatus(HttpStatus.ACCEPTED)); - assertThat(this.sut.check()).isEqualTo(CheckResult.OK); + assertThat(this.sender.check()).isEqualTo(CheckResult.OK); } @Test @@ -79,7 +81,7 @@ void checkShouldNotRaiseException() { this.mockServer.expect(requestTo(ZIPKIN_URL)) .andExpect(method(HttpMethod.POST)) .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); - CheckResult result = this.sut.check(); + CheckResult result = this.sender.check(); assertThat(result.ok()).isFalse(); assertThat(result.error()).hasMessageContaining("500 Internal Server Error"); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSenderTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSenderTests.java index 0b6236585c10..2a2f38198c03 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSenderTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/tracing/zipkin/ZipkinWebClientSenderTests.java @@ -17,16 +17,21 @@ package org.springframework.boot.actuate.autoconfigure.tracing.zipkin; import java.io.IOException; +import java.time.Duration; import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.QueueDispatcher; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -45,33 +50,48 @@ */ class ZipkinWebClientSenderTests extends ZipkinHttpSenderTests { + private static ClearableDispatcher dispatcher; + private static MockWebServer mockBackEnd; private static String ZIPKIN_URL; @BeforeAll static void beforeAll() throws IOException { + dispatcher = new ClearableDispatcher(); mockBackEnd = new MockWebServer(); + mockBackEnd.setDispatcher(dispatcher); mockBackEnd.start(); - ZIPKIN_URL = "http://localhost:%s/api/v2/spans".formatted(mockBackEnd.getPort()); + ZIPKIN_URL = mockBackEnd.url("/api/v2/spans").toString(); } @AfterAll - static void tearDown() throws IOException { + static void afterAll() throws IOException { mockBackEnd.shutdown(); } @Override - Sender createSut() { + @BeforeEach + void beforeEach() throws Exception { + super.beforeEach(); + clearResponses(); + clearRequests(); + } + + @Override + Sender createSender() { + return createSender(Duration.ofSeconds(10)); + } + + Sender createSender(Duration timeout) { WebClient webClient = WebClient.builder().build(); - return new ZipkinWebClientSender(ZIPKIN_URL, webClient); + return new ZipkinWebClientSender(ZIPKIN_URL, webClient, timeout); } @Test void checkShouldSendEmptySpanList() throws InterruptedException { mockBackEnd.enqueue(new MockResponse()); - assertThat(this.sut.check()).isEqualTo(CheckResult.OK); - + assertThat(this.sender.check()).isEqualTo(CheckResult.OK); requestAssertions((request) -> { assertThat(request.getMethod()).isEqualTo("POST"); assertThat(request.getBody().readUtf8()).isEqualTo("[]"); @@ -81,10 +101,9 @@ void checkShouldSendEmptySpanList() throws InterruptedException { @Test void checkShouldNotRaiseException() throws InterruptedException { mockBackEnd.enqueue(new MockResponse().setResponseCode(500)); - CheckResult result = this.sut.check(); + CheckResult result = this.sender.check(); assertThat(result.ok()).isFalse(); assertThat(result.error()).hasMessageContaining("500 Internal Server Error"); - requestAssertions((request) -> assertThat(request.getMethod()).isEqualTo("POST")); } @@ -94,7 +113,6 @@ void sendSpansShouldSendSpansToZipkin(boolean async) throws IOException, Interru mockBackEnd.enqueue(new MockResponse()); List encodedSpans = List.of(toByteArray("span1"), toByteArray("span2")); makeRequest(encodedSpans, async); - requestAssertions((request) -> { assertThat(request.getMethod()).isEqualTo("POST"); assertThat(request.getHeader("Content-Type")).isEqualTo("application/json"); @@ -115,7 +133,6 @@ void sendSpansShouldHandleHttpFailures(boolean async) throws InterruptedExceptio assertThatException().isThrownBy(() -> makeSyncRequest(Collections.emptyList())) .withMessageContaining("500 Internal Server Error"); } - requestAssertions((request) -> assertThat(request.getMethod()).isEqualTo("POST")); } @@ -126,18 +143,31 @@ void sendSpansShouldCompressData(boolean async) throws IOException, InterruptedE // This is gzip compressed 10000 times 'a' byte[] compressed = Base64.getDecoder() .decode("H4sIAAAAAAAA/+3BMQ0AAAwDIKFLj/k3UR8NcA8AAAAAAAAAAAADUsAZfeASJwAA"); - mockBackEnd.enqueue(new MockResponse()); - makeRequest(List.of(toByteArray(uncompressed)), async); - requestAssertions((request) -> { assertThat(request.getMethod()).isEqualTo("POST"); assertThat(request.getHeader("Content-Type")).isEqualTo("application/json"); assertThat(request.getHeader("Content-Encoding")).isEqualTo("gzip"); assertThat(request.getBody().readByteArray()).isEqualTo(compressed); }); + } + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void shouldTimeout(boolean async) { + Sender sender = createSender(Duration.ofMillis(1)); + MockResponse response = new MockResponse().setResponseCode(200).setHeadersDelay(100, TimeUnit.MILLISECONDS); + mockBackEnd.enqueue(response); + if (async) { + CallbackResult callbackResult = makeAsyncRequest(sender, Collections.emptyList()); + assertThat(callbackResult.success()).isFalse(); + assertThat(callbackResult.error()).isInstanceOf(TimeoutException.class); + } + else { + assertThatException().isThrownBy(() -> makeSyncRequest(sender, Collections.emptyList())) + .withCauseInstanceOf(TimeoutException.class); + } } private void requestAssertions(Consumer assertions) throws InterruptedException { @@ -145,4 +175,24 @@ private void requestAssertions(Consumer assertions) throws Inte assertThat(request).satisfies(assertions); } + private static void clearRequests() throws InterruptedException { + RecordedRequest request; + do { + request = mockBackEnd.takeRequest(0, TimeUnit.SECONDS); + } + while (request != null); + } + + private static void clearResponses() { + dispatcher.clear(); + } + + private static final class ClearableDispatcher extends QueueDispatcher { + + void clear() { + getResponseQueue().clear(); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesTests.java index 3e697616afbc..b9aaca914390 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontPropertiesTests.java @@ -18,8 +18,12 @@ import java.net.URI; +import com.wavefront.sdk.common.clients.service.token.TokenService.Type; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties.TokenType; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; import static org.assertj.core.api.Assertions.assertThat; @@ -34,21 +38,54 @@ class WavefrontPropertiesTests { @Test void apiTokenIsOptionalWhenUsingProxy() { - WavefrontProperties sut = new WavefrontProperties(); - sut.setUri(URI.create("proxy://localhost:2878")); - sut.setApiToken(null); - assertThat(sut.getApiTokenOrThrow()).isNull(); - assertThat(sut.getEffectiveUri()).isEqualTo(URI.create("http://localhost:2878")); + WavefrontProperties properties = new WavefrontProperties(); + properties.setUri(URI.create("proxy://localhost:2878")); + properties.setApiToken(null); + assertThat(properties.getApiTokenOrThrow()).isNull(); + assertThat(properties.getEffectiveUri()).isEqualTo(URI.create("http://localhost:2878")); } @Test void apiTokenIsMandatoryWhenNotUsingProxy() { - WavefrontProperties sut = new WavefrontProperties(); - sut.setUri(URI.create("http://localhost:2878")); - sut.setApiToken(null); - assertThat(sut.getEffectiveUri()).isEqualTo(URI.create("http://localhost:2878")); - assertThatExceptionOfType(InvalidConfigurationPropertyValueException.class).isThrownBy(sut::getApiTokenOrThrow) + WavefrontProperties properties = new WavefrontProperties(); + properties.setUri(URI.create("http://localhost:2878")); + properties.setApiToken(null); + assertThat(properties.getEffectiveUri()).isEqualTo(URI.create("http://localhost:2878")); + assertThatExceptionOfType(InvalidConfigurationPropertyValueException.class) + .isThrownBy(properties::getApiTokenOrThrow) .withMessageContaining("management.wavefront.api-token"); } + @Test + void shouldNotFailIfTokenTypeIsSetToNoToken() { + WavefrontProperties properties = new WavefrontProperties(); + properties.setUri(URI.create("http://localhost:2878")); + properties.setApiTokenType(TokenType.NO_TOKEN); + properties.setApiToken(null); + assertThat(properties.getApiTokenOrThrow()).isNull(); + } + + @Test + void wavefrontApiTokenTypeWhenUsingProxy() { + WavefrontProperties properties = new WavefrontProperties(); + properties.setUri(URI.create("proxy://localhost:2878")); + assertThat(properties.getWavefrontApiTokenType()).isEqualTo(Type.NO_TOKEN); + } + + @Test + void wavefrontApiTokenTypeWhenNotUsingProxy() { + WavefrontProperties properties = new WavefrontProperties(); + properties.setUri(URI.create("http://localhost:2878")); + assertThat(properties.getWavefrontApiTokenType()).isEqualTo(Type.WAVEFRONT_API_TOKEN); + } + + @ParameterizedTest + @EnumSource(TokenType.class) + void wavefrontApiTokenMapping(TokenType from) { + WavefrontProperties properties = new WavefrontProperties(); + properties.setApiTokenType(from); + Type expected = Type.valueOf(from.name()); + assertThat(properties.getWavefrontApiTokenType()).isEqualTo(expected); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfigurationTests.java index a75203e4fedf..512fd8311741 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/wavefront/WavefrontSenderConfigurationTests.java @@ -19,6 +19,9 @@ import java.util.concurrent.LinkedBlockingQueue; import com.wavefront.sdk.common.WavefrontSender; +import com.wavefront.sdk.common.clients.service.token.CSPTokenService; +import com.wavefront.sdk.common.clients.service.token.NoopProxyTokenService; +import com.wavefront.sdk.common.clients.service.token.WavefrontTokenService; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; @@ -42,6 +45,17 @@ class WavefrontSenderConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(WavefrontSenderConfiguration.class)); + private final ApplicationContextRunner tracingDisabledContextRunner = this.contextRunner + .withPropertyValues("management.tracing.enabled=false"); + + private final ApplicationContextRunner metricsDisabledContextRunner = this.contextRunner.withPropertyValues( + "management.defaults.metrics.export.enabled=false", "management.simple.metrics.export.enabled=true"); + + // Both metrics and tracing are disabled + private final ApplicationContextRunner observabilityDisabledContextRunner = this.contextRunner.withPropertyValues( + "management.tracing.enabled=false", "management.defaults.metrics.export.enabled=false", + "management.simple.metrics.export.enabled=true"); + @Test void shouldNotFailIfWavefrontIsMissing() { this.contextRunner.withClassLoader(new FilteredClassLoader("com.wavefront")) @@ -83,12 +97,71 @@ void configureWavefrontSender() { }); } + @Test + void shouldNotSupplyWavefrontSenderIfObservabilityIsDisabled() { + this.observabilityDisabledContextRunner.withPropertyValues("management.wavefront.api-token=abcde") + .run((context) -> assertThat(context).doesNotHaveBean(WavefrontSender.class)); + } + + @Test + void shouldSupplyWavefrontSenderIfOnlyTracingIsDisabled() { + this.tracingDisabledContextRunner.withPropertyValues("management.wavefront.api-token=abcde") + .run((context) -> assertThat(context).hasSingleBean(WavefrontSender.class)); + } + + @Test + void shouldSupplyWavefrontSenderIfOnlyMetricsAreDisabled() { + this.metricsDisabledContextRunner.withPropertyValues("management.wavefront.api-token=abcde") + .run((context) -> assertThat(context).hasSingleBean(WavefrontSender.class)); + } + @Test void allowsWavefrontSenderToBeCustomized() { this.contextRunner.withUserConfiguration(CustomSenderConfiguration.class) .run((context) -> assertThat(context).hasSingleBean(WavefrontSender.class).hasBean("customSender")); } + @Test + void shouldApplyTokenTypeWavefrontApiToken() { + this.contextRunner + .withPropertyValues("management.wavefront.api-token-type=WAVEFRONT_API_TOKEN", + "management.wavefront.api-token=abcde") + .run((context) -> { + WavefrontSender sender = context.getBean(WavefrontSender.class); + assertThat(sender).extracting("tokenService").isInstanceOf(WavefrontTokenService.class); + }); + } + + @Test + void shouldApplyTokenTypeCspApiToken() { + this.contextRunner + .withPropertyValues("management.wavefront.api-token-type=CSP_API_TOKEN", + "management.wavefront.api-token=abcde") + .run((context) -> { + WavefrontSender sender = context.getBean(WavefrontSender.class); + assertThat(sender).extracting("tokenService").isInstanceOf(CSPTokenService.class); + }); + } + + @Test + void shouldApplyTokenTypeCspClientCredentials() { + this.contextRunner + .withPropertyValues("management.wavefront.api-token-type=CSP_CLIENT_CREDENTIALS", + "management.wavefront.api-token=clientid=cid,clientsecret=csec") + .run((context) -> { + WavefrontSender sender = context.getBean(WavefrontSender.class); + assertThat(sender).extracting("tokenService").isInstanceOf(CSPTokenService.class); + }); + } + + @Test + void shouldApplyTokenTypeNoToken() { + this.contextRunner.withPropertyValues("management.wavefront.api-token-type=NO_TOKEN").run((context) -> { + WavefrontSender sender = context.getBean(WavefrontSender.class); + assertThat(sender).extracting("tokenService").isInstanceOf(NoopProxyTokenService.class); + }); + } + @Configuration(proxyBeanMethods = false) static class CustomSenderConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/server/ManagementContextAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/server/ManagementContextAutoConfigurationTests.java index 1d4e7c731983..a617750be9ed 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/server/ManagementContextAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/server/ManagementContextAutoConfigurationTests.java @@ -55,6 +55,33 @@ void childManagementContextShouldStartForEmbeddedServer(CapturedOutput output) { .run((context) -> assertThat(output).satisfies(numberOfOccurrences("Tomcat started on port", 2))); } + @Test + void childManagementContextShouldNotStartWithoutEmbeddedServer(CapturedOutput output) { + WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ManagementContextAutoConfiguration.class, + ServletWebServerFactoryAutoConfiguration.class, ServletManagementContextAutoConfiguration.class, + WebEndpointAutoConfiguration.class, EndpointAutoConfiguration.class)); + contextRunner.withPropertyValues("server.port=0", "management.server.port=0").run((context) -> { + assertThat(context).hasNotFailed(); + assertThat(output).doesNotContain("Tomcat started"); + }); + } + + @Test + void childManagementContextShouldRestartWhenParentIsStoppedThenStarted(CapturedOutput output) { + WebApplicationContextRunner contextRunner = new WebApplicationContextRunner( + AnnotationConfigServletWebServerApplicationContext::new) + .withConfiguration(AutoConfigurations.of(ManagementContextAutoConfiguration.class, + ServletWebServerFactoryAutoConfiguration.class, ServletManagementContextAutoConfiguration.class, + WebEndpointAutoConfiguration.class, EndpointAutoConfiguration.class)); + contextRunner.withPropertyValues("server.port=0", "management.server.port=0").run((context) -> { + assertThat(output).satisfies(numberOfOccurrences("Tomcat started on port", 2)); + context.getSourceApplicationContext().stop(); + context.getSourceApplicationContext().start(); + assertThat(output).satisfies(numberOfOccurrences("Tomcat started on port", 4)); + }); + } + @Test void givenSamePortManagementServerWhenManagementServerAddressIsConfiguredThenContextRefreshFails() { WebApplicationContextRunner contextRunner = new WebApplicationContextRunner( diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/servlet/WebMvcEndpointChildContextConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/servlet/WebMvcEndpointChildContextConfigurationIntegrationTests.java index 4dfd5c0732e1..ff43ecea4aa0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/servlet/WebMvcEndpointChildContextConfigurationIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/web/servlet/WebMvcEndpointChildContextConfigurationIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import reactor.core.publisher.Mono; @@ -143,7 +144,8 @@ void errorEndpointIsUsedWithRestControllerEndpointOnBindingError() { (value) -> assertThat(value).asString().contains("MethodArgumentNotValidException")); assertThat(body).hasEntrySatisfying("message", (value) -> assertThat(value).asString().contains("Validation failed")); - assertThat(body).hasEntrySatisfying("errors", (value) -> assertThat(value).asList().isNotEmpty()); + assertThat(body).hasEntrySatisfying("errors", + (value) -> assertThat(value).asInstanceOf(InstanceOfAssertFactories.LIST).isNotEmpty()); })); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/sample.log b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/sample.log index 9b8f1ab7eced..b1f92c2d2c35 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/sample.log +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/resources/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/sample.log @@ -9,7 +9,7 @@ 2017-08-08 17:12:30.910 INFO 19866 --- [ main] s.f.SampleWebFreeMarkerApplication : Starting SampleWebFreeMarkerApplication with PID 19866 2017-08-08 17:12:30.913 INFO 19866 --- [ main] s.f.SampleWebFreeMarkerApplication : No active profile set, falling back to default profiles: default 2017-08-08 17:12:30.952 INFO 19866 --- [ main] ConfigServletWebServerApplicationContext : Refreshing org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@76b10754: startup date [Tue Aug 08 17:12:30 BST 2017]; root of context hierarchy -2017-08-08 17:12:31.878 INFO 19866 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http) +2017-08-08 17:12:31.878 INFO 19866 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http) 2017-08-08 17:12:31.889 INFO 19866 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] 2017-08-08 17:12:31.890 INFO 19866 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet Engine: Apache Tomcat/8.5.16 2017-08-08 17:12:31.978 INFO 19866 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext @@ -27,5 +27,5 @@ 2017-08-08 17:12:32.471 INFO 19866 --- [ main] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler] 2017-08-08 17:12:32.600 INFO 19866 --- [ main] o.s.w.s.v.f.FreeMarkerConfigurer : ClassTemplateLoader for Spring macros added to FreeMarker configuration 2017-08-08 17:12:32.681 INFO 19866 --- [ main] o.s.j.e.a.AnnotationMBeanExporter : Registering beans for JMX exposure on startup -2017-08-08 17:12:32.744 INFO 19866 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) +2017-08-08 17:12:32.744 INFO 19866 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) 2017-08-08 17:12:32.750 INFO 19866 --- [ main] s.f.SampleWebFreeMarkerApplication : Started SampleWebFreeMarkerApplication in 2.172 seconds (JVM running for 2.479) diff --git a/spring-boot-project/spring-boot-actuator/build.gradle b/spring-boot-project/spring-boot-actuator/build.gradle index c4e393059178..37325cfd7e19 100644 --- a/spring-boot-project/spring-boot-actuator/build.gradle +++ b/spring-boot-project/spring-boot-actuator/build.gradle @@ -22,7 +22,7 @@ dependencies { optional("com.zaxxer:HikariCP") optional("io.lettuce:lettuce-core") optional("io.micrometer:micrometer-observation") - optional("io.micrometer:micrometer-core") + optional("io.micrometer:micrometer-jakarta9") optional("io.micrometer:micrometer-tracing") optional("io.micrometer:micrometer-registry-prometheus") optional("io.prometheus:simpleclient_pushgateway") { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/OperationMethodParameter.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/OperationMethodParameter.java index a4fa71ebe628..6b3e038859b0 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/OperationMethodParameter.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/OperationMethodParameter.java @@ -68,7 +68,10 @@ public boolean isMandatory() { if (!ObjectUtils.isEmpty(this.parameter.getAnnotationsByType(Nullable.class))) { return false; } - return (jsr305Present) ? new Jsr305().isMandatory(this.parameter) : true; + if (jsr305Present) { + return new Jsr305().isMandatory(this.parameter); + } + return true; } @Override diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointLinksResolver.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointLinksResolver.java index cba80cde53d6..69b42a5ed9ba 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointLinksResolver.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointLinksResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,7 +55,9 @@ public EndpointLinksResolver(Collection> endpoint public EndpointLinksResolver(Collection> endpoints, String basePath) { this.endpoints = endpoints; if (logger.isInfoEnabled()) { - logger.info("Exposing " + endpoints.size() + " endpoint(s) beneath base path '" + basePath + "'"); + String suffix = (endpoints.size() == 1) ? "" : "s"; + logger + .info("Exposing " + endpoints.size() + " endpoint" + suffix + " beneath base path '" + basePath + "'"); } } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/Health.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/Health.java index 690c75356e52..b0a063414fcc 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/Health.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/Health.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -145,7 +145,7 @@ public static Builder up() { * @param ex the exception * @return a new {@link Builder} instance */ - public static Builder down(Exception ex) { + public static Builder down(Throwable ex) { return down().withException(ex); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicator.java index 4896ff6094e2..f58586fb925c 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicator.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicator.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -29,7 +29,11 @@ * * @author Eddú Meléndez * @since 2.0.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the + * new client and its own + * Spring Boot integration. */ +@Deprecated(since = "3.2.0", forRemoval = true) public class InfluxDbHealthIndicator extends AbstractHealthIndicator { private final InfluxDB influxDb; diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/info/ProcessInfoContributor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/info/ProcessInfoContributor.java new file mode 100644 index 000000000000..1a1cd3d7540b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/info/ProcessInfoContributor.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.info; + +import org.springframework.aot.hint.BindingReflectionHintsRegistrar; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.boot.actuate.info.Info.Builder; +import org.springframework.boot.actuate.info.ProcessInfoContributor.ProcessInfoContributorRuntimeHints; +import org.springframework.boot.info.ProcessInfo; +import org.springframework.context.annotation.ImportRuntimeHints; + +/** + * An {@link InfoContributor} that exposes {@link ProcessInfo}. + * + * @author Jonatan Ivanov + * @since 3.3.0 + */ +@ImportRuntimeHints(ProcessInfoContributorRuntimeHints.class) +public class ProcessInfoContributor implements InfoContributor { + + private final ProcessInfo processInfo; + + public ProcessInfoContributor() { + this.processInfo = new ProcessInfo(); + } + + @Override + public void contribute(Builder builder) { + builder.withDetail("process", this.processInfo); + } + + static class ProcessInfoContributorRuntimeHints implements RuntimeHintsRegistrar { + + private final BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar(); + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + this.bindingRegistrar.registerReflectionHints(hints.reflection(), ProcessInfo.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/jdbc/DataSourceHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/jdbc/DataSourceHealthIndicator.java index b7f86dee2128..c82f42177950 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/jdbc/DataSourceHealthIndicator.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/jdbc/DataSourceHealthIndicator.java @@ -101,7 +101,7 @@ protected void doHealthCheck(Health.Builder builder) throws Exception { } } - private void doDataSourceHealthCheck(Health.Builder builder) throws Exception { + private void doDataSourceHealthCheck(Health.Builder builder) { builder.up().withDetail("database", getProduct()); String validationQuery = this.query; if (StringUtils.hasText(validationQuery)) { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManager.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManager.java index f0a3453e70e1..dc3ebe4d31ac 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManager.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -141,7 +141,7 @@ private void shutdown(ShutdownOperation shutdownOperation) { } this.scheduled.cancel(false); switch (shutdownOperation) { - case PUSH, POST -> post(); + case POST -> post(); case PUT -> put(); case DELETE -> delete(); } @@ -162,13 +162,6 @@ public enum ShutdownOperation { */ POST, - /** - * Perform a POST before shutdown. - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of {@link #POST}. - */ - @Deprecated(since = "3.0.0", forRemoval = true) - PUSH, - /** * Perform a PUT before shutdown. */ diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/jdbc/DataSourcePoolMetrics.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/jdbc/DataSourcePoolMetrics.java index caf29f30ff68..f22023a1ef24 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/jdbc/DataSourcePoolMetrics.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/jdbc/DataSourcePoolMetrics.java @@ -110,12 +110,8 @@ Function getValueFunction(Function this.metadataProvider.getDataSourcePoolMetadata(dataSource)); } } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/DefaultRestTemplateExchangeTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/DefaultRestTemplateExchangeTagsProvider.java deleted file mode 100644 index ace3689e6862..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/DefaultRestTemplateExchangeTagsProvider.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.client; - -import java.util.Arrays; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.http.HttpRequest; -import org.springframework.http.client.ClientHttpResponse; -import org.springframework.util.StringUtils; - -/** - * Default implementation of {@link RestTemplateExchangeTagsProvider}. - * - * @author Jon Schneider - * @author Nishant Raut - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.client.observation.DefaultClientRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -@SuppressWarnings("removal") -public class DefaultRestTemplateExchangeTagsProvider implements RestTemplateExchangeTagsProvider { - - @Override - public Iterable getTags(String urlTemplate, HttpRequest request, ClientHttpResponse response) { - Tag uriTag = (StringUtils.hasText(urlTemplate) ? RestTemplateExchangeTags.uri(urlTemplate) - : RestTemplateExchangeTags.uri(request)); - return Arrays.asList(RestTemplateExchangeTags.method(request), uriTag, - RestTemplateExchangeTags.status(response), RestTemplateExchangeTags.clientName(request), - RestTemplateExchangeTags.outcome(response)); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizer.java new file mode 100644 index 000000000000..904e94260340 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizer.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-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.boot.actuate.metrics.web.client; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.http.client.observation.ClientRequestObservationConvention; +import org.springframework.util.Assert; +import org.springframework.web.client.RestClient.Builder; + +/** + * {@link RestClientCustomizer} that configures the {@link Builder RestClient builder} to + * record request observations. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +public class ObservationRestClientCustomizer implements RestClientCustomizer { + + private final ObservationRegistry observationRegistry; + + private final ClientRequestObservationConvention observationConvention; + + /** + * Create a new {@link ObservationRestClientCustomizer}. + * @param observationRegistry the observation registry + * @param observationConvention the observation convention + */ + public ObservationRestClientCustomizer(ObservationRegistry observationRegistry, + ClientRequestObservationConvention observationConvention) { + Assert.notNull(observationConvention, "ObservationConvention must not be null"); + Assert.notNull(observationRegistry, "ObservationRegistry must not be null"); + this.observationRegistry = observationRegistry; + this.observationConvention = observationConvention; + } + + @Override + public void customize(Builder restClientBuilder) { + restClientBuilder.observationRegistry(this.observationRegistry); + restClientBuilder.observationConvention(this.observationConvention); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTags.java deleted file mode 100644 index 5f17ee3d4f44..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTags.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.client; - -import java.io.IOException; -import java.net.URI; -import java.util.regex.Pattern; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.boot.actuate.metrics.http.Outcome; -import org.springframework.http.HttpRequest; -import org.springframework.http.client.ClientHttpResponse; -import org.springframework.http.client.observation.DefaultClientRequestObservationConvention; -import org.springframework.util.StringUtils; -import org.springframework.web.client.RestTemplate; - -/** - * Factory methods for creating {@link Tag Tags} related to a request-response exchange - * performed by a {@link RestTemplate}. - * - * @author Andy Wilkinson - * @author Jon Schneider - * @author Nishant Raut - * @author Brian Clozel - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link DefaultClientRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -public final class RestTemplateExchangeTags { - - private static final Pattern STRIP_URI_PATTERN = Pattern.compile("^https?://[^/]+/"); - - private RestTemplateExchangeTags() { - } - - /** - * Creates a {@code method} {@code Tag} for the {@link HttpRequest#getMethod() method} - * of the given {@code request}. - * @param request the request - * @return the method tag - */ - public static Tag method(HttpRequest request) { - return Tag.of("method", request.getMethod().name()); - } - - /** - * Creates a {@code uri} {@code Tag} for the URI of the given {@code request}. - * @param request the request - * @return the uri tag - */ - public static Tag uri(HttpRequest request) { - return Tag.of("uri", ensureLeadingSlash(stripUri(request.getURI().toString()))); - } - - /** - * Creates a {@code uri} {@code Tag} from the given {@code uriTemplate}. - * @param uriTemplate the template - * @return the uri tag - */ - public static Tag uri(String uriTemplate) { - String uri = (StringUtils.hasText(uriTemplate) ? uriTemplate : "none"); - return Tag.of("uri", ensureLeadingSlash(stripUri(uri))); - } - - private static String stripUri(String uri) { - return STRIP_URI_PATTERN.matcher(uri).replaceAll(""); - } - - private static String ensureLeadingSlash(String url) { - return (url == null || url.startsWith("/")) ? url : "/" + url; - } - - /** - * Creates a {@code status} {@code Tag} derived from the - * {@link ClientHttpResponse#getStatusCode() status} of the given {@code response}. - * @param response the response - * @return the status tag - */ - public static Tag status(ClientHttpResponse response) { - return Tag.of("status", getStatusMessage(response)); - } - - private static String getStatusMessage(ClientHttpResponse response) { - try { - if (response == null) { - return "CLIENT_ERROR"; - } - return String.valueOf(response.getStatusCode().value()); - } - catch (IOException ex) { - return "IO_ERROR"; - } - } - - /** - * Create a {@code client.name} {@code Tag} derived from the {@link URI#getHost host} - * of the {@link HttpRequest#getURI() URI} of the given {@code request}. - * @param request the request - * @return the client.name tag - */ - public static Tag clientName(HttpRequest request) { - String host = request.getURI().getHost(); - if (host == null) { - host = "none"; - } - return Tag.of("client.name", host); - } - - /** - * Creates an {@code outcome} {@code Tag} derived from the - * {@link ClientHttpResponse#getStatusCode() status} of the given {@code response}. - * @param response the response - * @return the outcome tag - * @since 2.2.0 - */ - public static Tag outcome(ClientHttpResponse response) { - try { - if (response != null) { - return Outcome.forStatus(response.getStatusCode().value()).asTag(); - } - } - catch (IOException ex) { - // Continue - } - return Outcome.UNKNOWN.asTag(); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsProvider.java deleted file mode 100644 index ea3c05360ce3..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsProvider.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.client; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.http.HttpRequest; -import org.springframework.http.client.ClientHttpResponse; -import org.springframework.http.client.observation.ClientRequestObservationConvention; -import org.springframework.web.client.RestTemplate; - -/** - * Provides {@link Tag Tags} for an exchange performed by a {@link RestTemplate}. - * - * @author Jon Schneider - * @author Andy Wilkinson - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link ClientRequestObservationConvention} - */ -@FunctionalInterface -@Deprecated(since = "3.0.0", forRemoval = true) -public interface RestTemplateExchangeTagsProvider { - - /** - * Provides the tags to be associated with metrics that are recorded for the given - * {@code request} and {@code response} exchange. - * @param urlTemplate the source URl template, if available - * @param request the request - * @param response the response (may be {@code null} if the exchange failed) - * @return the tags - */ - Iterable getTags(String urlTemplate, HttpRequest request, ClientHttpResponse response); - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProvider.java deleted file mode 100644 index aeae3222d5ab..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProvider.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.client; - -import java.util.Arrays; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ClientResponse; - -/** - * Default implementation of {@link WebClientExchangeTagsProvider}. - * - * @author Brian Clozel - * @author Nishant Raut - * @since 2.1.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.web.reactive.function.client.ClientRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -@SuppressWarnings("removal") -public class DefaultWebClientExchangeTagsProvider implements WebClientExchangeTagsProvider { - - @Override - public Iterable tags(ClientRequest request, ClientResponse response, Throwable throwable) { - Tag method = WebClientExchangeTags.method(request); - Tag uri = WebClientExchangeTags.uri(request); - Tag clientName = WebClientExchangeTags.clientName(request); - Tag status = WebClientExchangeTags.status(response, throwable); - Tag outcome = WebClientExchangeTags.outcome(response); - return Arrays.asList(method, uri, clientName, status, outcome); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTags.java deleted file mode 100644 index c916188b6846..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTags.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.client; - -import java.io.IOException; -import java.util.regex.Pattern; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.boot.actuate.metrics.http.Outcome; -import org.springframework.http.client.reactive.ClientHttpRequest; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClient; - -/** - * Factory methods for creating {@link Tag Tags} related to a request-response exchange - * performed by a {@link WebClient}. - * - * @author Brian Clozel - * @author Nishant Raut - * @since 2.1.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.web.reactive.function.client.DefaultClientRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -public final class WebClientExchangeTags { - - private static final String URI_TEMPLATE_ATTRIBUTE = WebClient.class.getName() + ".uriTemplate"; - - private static final Tag IO_ERROR = Tag.of("status", "IO_ERROR"); - - private static final Tag CLIENT_ERROR = Tag.of("status", "CLIENT_ERROR"); - - private static final Pattern PATTERN_BEFORE_PATH = Pattern.compile("^https?://[^/]+/"); - - private static final Tag CLIENT_NAME_NONE = Tag.of("client.name", "none"); - - private WebClientExchangeTags() { - } - - /** - * Creates a {@code method} {@code Tag} for the {@link ClientHttpRequest#getMethod() - * method} of the given {@code request}. - * @param request the request - * @return the method tag - */ - public static Tag method(ClientRequest request) { - return Tag.of("method", request.method().name()); - } - - /** - * Creates a {@code uri} {@code Tag} for the URI path of the given {@code request}. - * @param request the request - * @return the uri tag - */ - public static Tag uri(ClientRequest request) { - String uri = (String) request.attribute(URI_TEMPLATE_ATTRIBUTE).orElseGet(() -> request.url().toString()); - return Tag.of("uri", extractPath(uri)); - } - - private static String extractPath(String url) { - String path = PATTERN_BEFORE_PATH.matcher(url).replaceFirst(""); - return (path.startsWith("/") ? path : "/" + path); - } - - /** - * Creates a {@code status} {@code Tag} derived from the - * {@link ClientResponse#statusCode()} of the given {@code response} if available, the - * thrown exception otherwise, or considers the request as Cancelled as a last resort. - * @param response the response - * @param throwable the exception - * @return the status tag - * @since 2.3.0 - */ - public static Tag status(ClientResponse response, Throwable throwable) { - if (response != null) { - return Tag.of("status", String.valueOf(response.statusCode().value())); - } - if (throwable != null) { - return (throwable instanceof IOException) ? IO_ERROR : CLIENT_ERROR; - } - return CLIENT_ERROR; - } - - /** - * Create a {@code client.name} {@code Tag} derived from the - * {@link java.net.URI#getHost host} of the {@link ClientRequest#url() URL} of the - * given {@code request}. - * @param request the request - * @return the client.name tag - */ - public static Tag clientName(ClientRequest request) { - String host = request.url().getHost(); - if (host == null) { - return CLIENT_NAME_NONE; - } - return Tag.of("client.name", host); - } - - /** - * Creates an {@code outcome} {@code Tag} derived from the - * {@link ClientResponse#statusCode() status} of the given {@code response}. - * @param response the response - * @return the outcome tag - * @since 2.2.0 - */ - public static Tag outcome(ClientResponse response) { - Outcome outcome = (response != null) ? Outcome.forStatus(response.statusCode().value()) : Outcome.UNKNOWN; - return outcome.asTag(); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsProvider.java deleted file mode 100644 index 7d522e48d2b3..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsProvider.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.client; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ClientResponse; - -/** - * {@link Tag Tags} provider for an exchange performed by a - * {@link org.springframework.web.reactive.function.client.WebClient}. - * - * @author Brian Clozel - * @since 2.1.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.web.reactive.function.client.ClientRequestObservationConvention} - */ -@FunctionalInterface -@Deprecated(since = "3.0.0", forRemoval = true) -public interface WebClientExchangeTagsProvider { - - /** - * Provide tags to be associated with metrics for the client exchange. - * @param request the client request - * @param response the server response (may be {@code null}) - * @param throwable the exception (may be {@code null}) - * @return tags to associate with metrics for the request and response exchange - */ - Iterable tags(ClientRequest request, ClientResponse response, Throwable throwable); - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProvider.java deleted file mode 100644 index ef319dc065ad..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProvider.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.server; - -import java.util.Collections; -import java.util.List; - -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; - -import org.springframework.web.server.ServerWebExchange; - -/** - * Default implementation of {@link WebFluxTagsProvider}. - * - * @author Jon Schneider - * @author Andy Wilkinson - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.server.reactive.observation.ServerRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -@SuppressWarnings("removal") -public class DefaultWebFluxTagsProvider implements WebFluxTagsProvider { - - private final boolean ignoreTrailingSlash; - - private final List contributors; - - public DefaultWebFluxTagsProvider() { - this(false); - } - - /** - * Creates a new {@link DefaultWebFluxTagsProvider} that will provide tags from the - * given {@code contributors} in addition to its own. - * @param contributors the contributors that will provide additional tags - * @since 2.3.0 - */ - public DefaultWebFluxTagsProvider(List contributors) { - this(false, contributors); - } - - public DefaultWebFluxTagsProvider(boolean ignoreTrailingSlash) { - this(ignoreTrailingSlash, Collections.emptyList()); - } - - /** - * Creates a new {@link DefaultWebFluxTagsProvider} that will provide tags from the - * given {@code contributors} in addition to its own. - * @param ignoreTrailingSlash whether trailing slashes should be ignored when - * determining the {@code uri} tag. - * @param contributors the contributors that will provide additional tags - * @since 2.3.0 - */ - public DefaultWebFluxTagsProvider(boolean ignoreTrailingSlash, List contributors) { - this.ignoreTrailingSlash = ignoreTrailingSlash; - this.contributors = contributors; - } - - @Override - public Iterable httpRequestTags(ServerWebExchange exchange, Throwable exception) { - Tags tags = Tags.empty(); - tags = tags.and(WebFluxTags.method(exchange)); - tags = tags.and(WebFluxTags.uri(exchange, this.ignoreTrailingSlash)); - tags = tags.and(WebFluxTags.exception(exception)); - tags = tags.and(WebFluxTags.status(exchange)); - tags = tags.and(WebFluxTags.outcome(exchange, exception)); - for (WebFluxTagsContributor contributor : this.contributors) { - tags = tags.and(contributor.httpRequestTags(exchange, exception)); - } - return tags; - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTags.java deleted file mode 100644 index ecada43258a4..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTags.java +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.server; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; -import java.util.regex.Pattern; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.boot.actuate.metrics.http.Outcome; -import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; -import org.springframework.util.StringUtils; -import org.springframework.web.reactive.HandlerMapping; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.util.pattern.PathPattern; - -/** - * Factory methods for {@link Tag Tags} associated with a request-response exchange that - * is handled by WebFlux. - * - * @author Jon Schneider - * @author Andy Wilkinson - * @author Michael McFadyen - * @author Brian Clozel - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.server.reactive.observation.ServerRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -public final class WebFluxTags { - - private static final Tag URI_NOT_FOUND = Tag.of("uri", "NOT_FOUND"); - - private static final Tag URI_REDIRECTION = Tag.of("uri", "REDIRECTION"); - - private static final Tag URI_ROOT = Tag.of("uri", "root"); - - private static final Tag URI_UNKNOWN = Tag.of("uri", "UNKNOWN"); - - private static final Tag EXCEPTION_NONE = Tag.of("exception", "None"); - - private static final Pattern FORWARD_SLASHES_PATTERN = Pattern.compile("//+"); - - private static final Set DISCONNECTED_CLIENT_EXCEPTIONS = new HashSet<>( - Arrays.asList("AbortedException", "ClientAbortException", "EOFException", "EofException")); - - private WebFluxTags() { - } - - /** - * Creates a {@code method} tag based on the - * {@link org.springframework.http.server.reactive.ServerHttpRequest#getMethod() - * method} of the {@link ServerWebExchange#getRequest()} request of the given - * {@code exchange}. - * @param exchange the exchange - * @return the method tag whose value is a capitalized method (e.g. GET). - */ - public static Tag method(ServerWebExchange exchange) { - return Tag.of("method", exchange.getRequest().getMethod().name()); - } - - /** - * Creates a {@code status} tag based on the response status of the given - * {@code exchange}. - * @param exchange the exchange - * @return the status tag derived from the response status - */ - public static Tag status(ServerWebExchange exchange) { - HttpStatusCode status = exchange.getResponse().getStatusCode(); - if (status == null) { - status = HttpStatus.OK; - } - return Tag.of("status", String.valueOf(status.value())); - } - - /** - * Creates a {@code uri} tag based on the URI of the given {@code exchange}. Uses the - * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if - * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} - * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN} - * for all other requests. - * @param exchange the exchange - * @return the uri tag derived from the exchange - */ - public static Tag uri(ServerWebExchange exchange) { - return uri(exchange, false); - } - - /** - * Creates a {@code uri} tag based on the URI of the given {@code exchange}. Uses the - * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if - * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} - * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN} - * for all other requests. - * @param exchange the exchange - * @param ignoreTrailingSlash whether to ignore the trailing slash - * @return the uri tag derived from the exchange - */ - public static Tag uri(ServerWebExchange exchange, boolean ignoreTrailingSlash) { - PathPattern pathPattern = exchange.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); - if (pathPattern != null) { - String patternString = pathPattern.getPatternString(); - if (ignoreTrailingSlash && patternString.length() > 1) { - patternString = removeTrailingSlash(patternString); - } - if (patternString.isEmpty()) { - return URI_ROOT; - } - return Tag.of("uri", patternString); - } - HttpStatusCode status = exchange.getResponse().getStatusCode(); - if (status != null) { - if (status.is3xxRedirection()) { - return URI_REDIRECTION; - } - if (status == HttpStatus.NOT_FOUND) { - return URI_NOT_FOUND; - } - } - String path = getPathInfo(exchange); - if (path.isEmpty()) { - return URI_ROOT; - } - return URI_UNKNOWN; - } - - private static String getPathInfo(ServerWebExchange exchange) { - String path = exchange.getRequest().getPath().value(); - String uri = StringUtils.hasText(path) ? path : "/"; - String singleSlashes = FORWARD_SLASHES_PATTERN.matcher(uri).replaceAll("/"); - return removeTrailingSlash(singleSlashes); - } - - private static String removeTrailingSlash(String text) { - if (!StringUtils.hasLength(text)) { - return text; - } - return text.endsWith("/") ? text.substring(0, text.length() - 1) : text; - } - - /** - * Creates an {@code exception} tag based on the {@link Class#getSimpleName() simple - * name} of the class of the given {@code exception}. - * @param exception the exception, may be {@code null} - * @return the exception tag derived from the exception - */ - public static Tag exception(Throwable exception) { - if (exception != null) { - String simpleName = exception.getClass().getSimpleName(); - return Tag.of("exception", StringUtils.hasText(simpleName) ? simpleName : exception.getClass().getName()); - } - return EXCEPTION_NONE; - } - - /** - * Creates an {@code outcome} tag based on the response status of the given - * {@code exchange} and the exception thrown during request processing. - * @param exchange the exchange - * @param exception the termination signal sent by the publisher - * @return the outcome tag derived from the response status - * @since 2.5.0 - */ - public static Tag outcome(ServerWebExchange exchange, Throwable exception) { - if (exception != null) { - if (DISCONNECTED_CLIENT_EXCEPTIONS.contains(exception.getClass().getSimpleName())) { - return Outcome.UNKNOWN.asTag(); - } - } - HttpStatusCode statusCode = exchange.getResponse().getStatusCode(); - Outcome outcome = (statusCode != null) ? Outcome.forStatus(statusCode.value()) : Outcome.SUCCESS; - return outcome.asTag(); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsContributor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsContributor.java deleted file mode 100644 index 6bd9958dfca3..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsContributor.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.server; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.web.server.ServerWebExchange; - -/** - * A contributor of {@link Tag Tags} for WebFlux-based request handling. Typically used by - * a {@link WebFluxTagsProvider} to provide tags in addition to its defaults. - * - * @author Andy Wilkinson - * @since 2.3.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.server.reactive.observation.ServerRequestObservationConvention} - */ -@FunctionalInterface -@Deprecated(since = "3.0.0", forRemoval = true) -public interface WebFluxTagsContributor { - - /** - * Provides tags to be associated with metrics for the given {@code exchange}. - * @param exchange the exchange - * @param ex the current exception (may be {@code null}) - * @return tags to associate with metrics for the request and response exchange - */ - Iterable httpRequestTags(ServerWebExchange exchange, Throwable ex); - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsProvider.java deleted file mode 100644 index 081c9598cc89..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsProvider.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.server; - -import io.micrometer.core.instrument.Tag; - -import org.springframework.web.server.ServerWebExchange; - -/** - * Provides {@link Tag Tags} for WebFlux-based request handling. - * - * @author Jon Schneider - * @author Andy Wilkinson - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.server.reactive.observation.ServerRequestObservationConvention} - */ -@FunctionalInterface -@Deprecated(since = "3.0.0", forRemoval = true) -public interface WebFluxTagsProvider { - - /** - * Provides tags to be associated with metrics for the given {@code exchange}. - * @param exchange the exchange - * @param ex the current exception (may be {@code null}) - * @return tags to associate with metrics for the request and response exchange - */ - Iterable httpRequestTags(ServerWebExchange exchange, Throwable ex); - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/DefaultWebMvcTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/DefaultWebMvcTagsProvider.java deleted file mode 100644 index ac50670ca326..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/DefaultWebMvcTagsProvider.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.servlet; - -import java.util.Collections; -import java.util.List; - -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -/** - * Default implementation of {@link WebMvcTagsProvider}. - * - * @author Jon Schneider - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.server.observation.ServerRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -@SuppressWarnings("removal") -public class DefaultWebMvcTagsProvider implements WebMvcTagsProvider { - - private final boolean ignoreTrailingSlash; - - private final List contributors; - - public DefaultWebMvcTagsProvider() { - this(false); - } - - /** - * Creates a new {@link DefaultWebMvcTagsProvider} that will provide tags from the - * given {@code contributors} in addition to its own. - * @param contributors the contributors that will provide additional tags - * @since 2.3.0 - */ - public DefaultWebMvcTagsProvider(List contributors) { - this(false, contributors); - } - - public DefaultWebMvcTagsProvider(boolean ignoreTrailingSlash) { - this(ignoreTrailingSlash, Collections.emptyList()); - } - - /** - * Creates a new {@link DefaultWebMvcTagsProvider} that will provide tags from the - * given {@code contributors} in addition to its own. - * @param ignoreTrailingSlash whether trailing slashes should be ignored when - * determining the {@code uri} tag. - * @param contributors the contributors that will provide additional tags - * @since 2.3.0 - */ - public DefaultWebMvcTagsProvider(boolean ignoreTrailingSlash, List contributors) { - this.ignoreTrailingSlash = ignoreTrailingSlash; - this.contributors = contributors; - } - - @Override - public Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, - Throwable exception) { - Tags tags = Tags.of(WebMvcTags.method(request), WebMvcTags.uri(request, response, this.ignoreTrailingSlash), - WebMvcTags.exception(exception), WebMvcTags.status(response), WebMvcTags.outcome(response)); - for (WebMvcTagsContributor contributor : this.contributors) { - tags = tags.and(contributor.getTags(request, response, handler, exception)); - } - return tags; - } - - @Override - public Iterable getLongRequestTags(HttpServletRequest request, Object handler) { - Tags tags = Tags.of(WebMvcTags.method(request), WebMvcTags.uri(request, null, this.ignoreTrailingSlash)); - for (WebMvcTagsContributor contributor : this.contributors) { - tags = tags.and(contributor.getLongRequestTags(request, handler)); - } - return tags; - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTags.java deleted file mode 100644 index db5c1f0e49c5..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTags.java +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.servlet; - -import java.util.regex.Pattern; - -import io.micrometer.core.instrument.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import org.springframework.boot.actuate.metrics.http.Outcome; -import org.springframework.http.HttpStatus; -import org.springframework.util.StringUtils; -import org.springframework.web.servlet.HandlerMapping; -import org.springframework.web.util.pattern.PathPattern; - -/** - * Factory methods for {@link Tag Tags} associated with a request-response exchange that - * is handled by Spring MVC. - * - * @author Jon Schneider - * @author Andy Wilkinson - * @author Brian Clozel - * @author Michael McFadyen - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.server.observation.ServerRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -public final class WebMvcTags { - - private static final String DATA_REST_PATH_PATTERN_ATTRIBUTE = "org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping.EFFECTIVE_REPOSITORY_RESOURCE_LOOKUP_PATH"; - - private static final Tag URI_NOT_FOUND = Tag.of("uri", "NOT_FOUND"); - - private static final Tag URI_REDIRECTION = Tag.of("uri", "REDIRECTION"); - - private static final Tag URI_ROOT = Tag.of("uri", "root"); - - private static final Tag URI_UNKNOWN = Tag.of("uri", "UNKNOWN"); - - private static final Tag EXCEPTION_NONE = Tag.of("exception", "None"); - - private static final Tag STATUS_UNKNOWN = Tag.of("status", "UNKNOWN"); - - private static final Tag METHOD_UNKNOWN = Tag.of("method", "UNKNOWN"); - - private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/$"); - - private static final Pattern MULTIPLE_SLASH_PATTERN = Pattern.compile("//+"); - - private WebMvcTags() { - } - - /** - * Creates a {@code method} tag based on the {@link HttpServletRequest#getMethod() - * method} of the given {@code request}. - * @param request the request - * @return the method tag whose value is a capitalized method (e.g. GET). - */ - public static Tag method(HttpServletRequest request) { - return (request != null) ? Tag.of("method", request.getMethod()) : METHOD_UNKNOWN; - } - - /** - * Creates a {@code status} tag based on the status of the given {@code response}. - * @param response the HTTP response - * @return the status tag derived from the status of the response - */ - public static Tag status(HttpServletResponse response) { - return (response != null) ? Tag.of("status", Integer.toString(response.getStatus())) : STATUS_UNKNOWN; - } - - /** - * Creates a {@code uri} tag based on the URI of the given {@code request}. Uses the - * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if - * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} - * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN} - * for all other requests. - * @param request the request - * @param response the response - * @return the uri tag derived from the request - */ - public static Tag uri(HttpServletRequest request, HttpServletResponse response) { - return uri(request, response, false); - } - - /** - * Creates a {@code uri} tag based on the URI of the given {@code request}. Uses the - * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if - * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} - * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN} - * for all other requests. - * @param request the request - * @param response the response - * @param ignoreTrailingSlash whether to ignore the trailing slash - * @return the uri tag derived from the request - */ - public static Tag uri(HttpServletRequest request, HttpServletResponse response, boolean ignoreTrailingSlash) { - if (request != null) { - String pattern = getMatchingPattern(request); - if (pattern != null) { - if (ignoreTrailingSlash && pattern.length() > 1) { - pattern = TRAILING_SLASH_PATTERN.matcher(pattern).replaceAll(""); - } - if (pattern.isEmpty()) { - return URI_ROOT; - } - return Tag.of("uri", pattern); - } - if (response != null) { - HttpStatus status = extractStatus(response); - if (status != null) { - if (status.is3xxRedirection()) { - return URI_REDIRECTION; - } - if (status == HttpStatus.NOT_FOUND) { - return URI_NOT_FOUND; - } - } - } - String pathInfo = getPathInfo(request); - if (pathInfo.isEmpty()) { - return URI_ROOT; - } - } - return URI_UNKNOWN; - } - - private static HttpStatus extractStatus(HttpServletResponse response) { - try { - return HttpStatus.valueOf(response.getStatus()); - } - catch (IllegalArgumentException ex) { - return null; - } - } - - private static String getMatchingPattern(HttpServletRequest request) { - PathPattern dataRestPathPattern = (PathPattern) request.getAttribute(DATA_REST_PATH_PATTERN_ATTRIBUTE); - if (dataRestPathPattern != null) { - return dataRestPathPattern.getPatternString(); - } - return (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); - } - - private static String getPathInfo(HttpServletRequest request) { - String pathInfo = request.getPathInfo(); - String uri = StringUtils.hasText(pathInfo) ? pathInfo : "/"; - uri = MULTIPLE_SLASH_PATTERN.matcher(uri).replaceAll("/"); - return TRAILING_SLASH_PATTERN.matcher(uri).replaceAll(""); - } - - /** - * Creates an {@code exception} tag based on the {@link Class#getSimpleName() simple - * name} of the class of the given {@code exception}. - * @param exception the exception, may be {@code null} - * @return the exception tag derived from the exception - */ - public static Tag exception(Throwable exception) { - if (exception != null) { - String simpleName = exception.getClass().getSimpleName(); - return Tag.of("exception", StringUtils.hasText(simpleName) ? simpleName : exception.getClass().getName()); - } - return EXCEPTION_NONE; - } - - /** - * Creates an {@code outcome} tag based on the status of the given {@code response}. - * @param response the HTTP response - * @return the outcome tag derived from the status of the response - * @since 2.1.0 - */ - public static Tag outcome(HttpServletResponse response) { - Outcome outcome = (response != null) ? Outcome.forStatus(response.getStatus()) : Outcome.UNKNOWN; - return outcome.asTag(); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsContributor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsContributor.java deleted file mode 100644 index f27b2115af67..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsContributor.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.servlet; - -import io.micrometer.core.instrument.LongTaskTimer; -import io.micrometer.core.instrument.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -/** - * A contributor of {@link Tag Tags} for Spring MVC-based request handling. Typically used - * by a {@link WebMvcTagsProvider} to provide tags in addition to its defaults. - * - * @author Andy Wilkinson - * @since 2.3.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.server.observation.ServerRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -public interface WebMvcTagsContributor { - - /** - * Provides tags to be associated with metrics for the given {@code request} and - * {@code response} exchange. - * @param request the request - * @param response the response - * @param handler the handler for the request or {@code null} if the handler is - * unknown - * @param exception the current exception, if any - * @return tags to associate with metrics for the request and response exchange - */ - Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, - Throwable exception); - - /** - * Provides tags to be used by {@link LongTaskTimer long task timers}. - * @param request the HTTP request - * @param handler the handler for the request or {@code null} if the handler is - * unknown - * @return tags to associate with metrics recorded for the request - */ - Iterable getLongRequestTags(HttpServletRequest request, Object handler); - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsProvider.java deleted file mode 100644 index 09206f727b1b..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/WebMvcTagsProvider.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.servlet; - -import io.micrometer.core.instrument.LongTaskTimer; -import io.micrometer.core.instrument.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -/** - * Provides {@link Tag Tags} for Spring MVC-based request handling. - * - * @author Jon Schneider - * @author Andy Wilkinson - * @since 2.0.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.http.server.observation.ServerRequestObservationConvention} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -public interface WebMvcTagsProvider { - - /** - * Provides tags to be associated with metrics for the given {@code request} and - * {@code response} exchange. - * @param request the request - * @param response the response - * @param handler the handler for the request or {@code null} if the handler is - * unknown - * @param exception the current exception, if any - * @return tags to associate with metrics for the request and response exchange - */ - Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, - Throwable exception); - - /** - * Provides tags to be used by {@link LongTaskTimer long task timers}. - * @param request the HTTP request - * @param handler the handler for the request or {@code null} if the handler is - * unknown - * @return tags to associate with metrics recorded for the request - */ - Iterable getLongRequestTags(HttpServletRequest request, Object handler); - -} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpoint.java new file mode 100644 index 000000000000..9ee15d69fc02 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpoint.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.session; + +import reactor.core.publisher.Mono; + +import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.Selector; +import org.springframework.boot.actuate.session.SessionsDescriptor.SessionDescriptor; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; +import org.springframework.util.Assert; + +/** + * {@link Endpoint @Endpoint} to expose information about HTTP {@link Session}s on a + * reactive stack. + * + * @author Vedran Pavic + * @author Moritz Halbritter + * @since 3.3.0 + */ +@Endpoint(id = "sessions") +public class ReactiveSessionsEndpoint { + + private final ReactiveSessionRepository sessionRepository; + + private final ReactiveFindByIndexNameSessionRepository indexedSessionRepository; + + /** + * Create a new {@link ReactiveSessionsEndpoint} instance. + * @param sessionRepository the session repository + * @param indexedSessionRepository the indexed session repository + */ + public ReactiveSessionsEndpoint(ReactiveSessionRepository sessionRepository, + ReactiveFindByIndexNameSessionRepository indexedSessionRepository) { + Assert.notNull(sessionRepository, "ReactiveSessionRepository must not be null"); + this.sessionRepository = sessionRepository; + this.indexedSessionRepository = indexedSessionRepository; + } + + @ReadOperation + public Mono sessionsForUsername(String username) { + if (this.indexedSessionRepository == null) { + return Mono.empty(); + } + return this.indexedSessionRepository.findByPrincipalName(username).map(SessionsDescriptor::new); + } + + @ReadOperation + public Mono getSession(@Selector String sessionId) { + return this.sessionRepository.findById(sessionId).map(SessionDescriptor::new); + } + + @DeleteOperation + public Mono deleteSession(@Selector String sessionId) { + return this.sessionRepository.deleteById(sessionId); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/SessionsDescriptor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/SessionsDescriptor.java new file mode 100644 index 000000000000..24e12de097b8 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/SessionsDescriptor.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.session; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.actuate.endpoint.OperationResponseBody; +import org.springframework.session.Session; + +/** + * Description of user's {@link Session sessions}. + * + * @author Moritz Halbritter + * @since 3.3.0 + */ +public final class SessionsDescriptor implements OperationResponseBody { + + private final List sessions; + + public SessionsDescriptor(Map sessions) { + this.sessions = sessions.values().stream().map(SessionDescriptor::new).toList(); + } + + public List getSessions() { + return this.sessions; + } + + /** + * A description of user's {@link Session session} exposed by {@code sessions} + * endpoint. Primarily intended for serialization to JSON. + */ + public static final class SessionDescriptor { + + private final String id; + + private final Set attributeNames; + + private final Instant creationTime; + + private final Instant lastAccessedTime; + + private final long maxInactiveInterval; + + private final boolean expired; + + SessionDescriptor(Session session) { + this.id = session.getId(); + this.attributeNames = session.getAttributeNames(); + this.creationTime = session.getCreationTime(); + this.lastAccessedTime = session.getLastAccessedTime(); + this.maxInactiveInterval = session.getMaxInactiveInterval().getSeconds(); + this.expired = session.isExpired(); + } + + public String getId() { + return this.id; + } + + public Set getAttributeNames() { + return this.attributeNames; + } + + public Instant getCreationTime() { + return this.creationTime; + } + + public Instant getLastAccessedTime() { + return this.lastAccessedTime; + } + + public long getMaxInactiveInterval() { + return this.maxInactiveInterval; + } + + public boolean isExpired() { + return this.expired; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/SessionsEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/SessionsEndpoint.java index b333d8e23c2b..1b89c83a1132 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/SessionsEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/session/SessionsEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,21 +16,21 @@ package org.springframework.boot.actuate.session; -import java.time.Instant; -import java.util.List; import java.util.Map; -import java.util.Set; -import org.springframework.boot.actuate.endpoint.OperationResponseBody; import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.Selector; +import org.springframework.boot.actuate.session.SessionsDescriptor.SessionDescriptor; import org.springframework.session.FindByIndexNameSessionRepository; import org.springframework.session.Session; +import org.springframework.session.SessionRepository; +import org.springframework.util.Assert; /** - * {@link Endpoint @Endpoint} to expose a user's {@link Session}s. + * {@link Endpoint @Endpoint} to expose information about HTTP {@link Session}s on a + * Servlet stack. * * @author Vedran Pavic * @since 2.0.0 @@ -38,19 +38,40 @@ @Endpoint(id = "sessions") public class SessionsEndpoint { - private final FindByIndexNameSessionRepository sessionRepository; + private final SessionRepository sessionRepository; + + private final FindByIndexNameSessionRepository indexedSessionRepository; /** * Create a new {@link SessionsEndpoint} instance. * @param sessionRepository the session repository + * @deprecated since 3.3.0 for removal in 3.5.0 in favor of + * {@link #SessionsEndpoint(SessionRepository, FindByIndexNameSessionRepository)} */ + @Deprecated(since = "3.3.0", forRemoval = true) public SessionsEndpoint(FindByIndexNameSessionRepository sessionRepository) { + this(sessionRepository, sessionRepository); + } + + /** + * Create a new {@link SessionsEndpoint} instance. + * @param sessionRepository the session repository + * @param indexedSessionRepository the indexed session repository + * @since 3.3.0 + */ + public SessionsEndpoint(SessionRepository sessionRepository, + FindByIndexNameSessionRepository indexedSessionRepository) { + Assert.notNull(sessionRepository, "SessionRepository must not be null"); this.sessionRepository = sessionRepository; + this.indexedSessionRepository = indexedSessionRepository; } @ReadOperation public SessionsDescriptor sessionsForUsername(String username) { - Map sessions = this.sessionRepository.findByPrincipalName(username); + if (this.indexedSessionRepository == null) { + return null; + } + Map sessions = this.indexedSessionRepository.findByPrincipalName(username); return new SessionsDescriptor(sessions); } @@ -68,73 +89,4 @@ public void deleteSession(@Selector String sessionId) { this.sessionRepository.deleteById(sessionId); } - /** - * Description of user's {@link Session sessions}. - */ - public static final class SessionsDescriptor implements OperationResponseBody { - - private final List sessions; - - public SessionsDescriptor(Map sessions) { - this.sessions = sessions.values().stream().map(SessionDescriptor::new).toList(); - } - - public List getSessions() { - return this.sessions; - } - - } - - /** - * Description of user's {@link Session session}. - */ - public static final class SessionDescriptor implements OperationResponseBody { - - private final String id; - - private final Set attributeNames; - - private final Instant creationTime; - - private final Instant lastAccessedTime; - - private final long maxInactiveInterval; - - private final boolean expired; - - public SessionDescriptor(Session session) { - this.id = session.getId(); - this.attributeNames = session.getAttributeNames(); - this.creationTime = session.getCreationTime(); - this.lastAccessedTime = session.getLastAccessedTime(); - this.maxInactiveInterval = session.getMaxInactiveInterval().getSeconds(); - this.expired = session.isExpired(); - } - - public String getId() { - return this.id; - } - - public Set getAttributeNames() { - return this.attributeNames; - } - - public Instant getCreationTime() { - return this.creationTime; - } - - public Instant getLastAccessedTime() { - return this.lastAccessedTime; - } - - public long getMaxInactiveInterval() { - return this.maxInactiveInterval; - } - - public boolean isExpired() { - return this.expired; - } - - } - } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/ShutdownEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/ShutdownEndpointTests.java index 45b37c3e6fb3..018dd3a9059c 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/ShutdownEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/ShutdownEndpointTests.java @@ -60,7 +60,7 @@ void shutdown() { Thread.currentThread().setContextClassLoader(previousTccl); } assertThat(result.getMessage()).startsWith("Shutting down"); - assertThat(((ConfigurableApplicationContext) context).isActive()).isTrue(); + assertThat(context.isActive()).isTrue(); assertThat(config.latch.await(10, TimeUnit.SECONDS)).isTrue(); assertThat(config.threadContextClassLoader).isEqualTo(getClass().getClassLoader()); }); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/convert/ConversionServiceParameterValueMapperTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/convert/ConversionServiceParameterValueMapperTests.java index 3ba4df37a969..c2c1bc8d6ca2 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/convert/ConversionServiceParameterValueMapperTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/convert/ConversionServiceParameterValueMapperTests.java @@ -30,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -55,7 +56,7 @@ void mapParameterShouldDelegateToConversionService() { void mapParameterWhenConversionServiceFailsShouldThrowParameterMappingException() { ConversionService conversionService = mock(ConversionService.class); RuntimeException error = new RuntimeException(); - given(conversionService.convert(any(), any())).willThrow(error); + given(conversionService.convert(any(Object.class), eq(Integer.class))).willThrow(error); ConversionServiceParameterValueMapper mapper = new ConversionServiceParameterValueMapper(conversionService); assertThatExceptionOfType(ParameterMappingException.class) .isThrownBy(() -> mapper.mapParameterValue(new TestOperationParameter(Integer.class), "123")) diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/EndpointMBeanTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/EndpointMBeanTests.java index 5fa8a81dd3b4..442bd367c948 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/EndpointMBeanTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/EndpointMBeanTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 javax.management.MBeanInfo; import javax.management.ReflectionException; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -165,7 +166,7 @@ void invokeWhenFluxResultShouldCollectToMonoListAndBlockOnMono() throws MBeanExc new TestJmxOperation((arguments) -> Flux.just("flux", "result"))); EndpointMBean bean = new EndpointMBean(this.responseMapper, null, endpoint); Object result = bean.invoke("testOperation", NO_PARAMS, NO_SIGNATURE); - assertThat(result).asList().containsExactly("flux", "result"); + assertThat(result).asInstanceOf(InstanceOfAssertFactories.LIST).containsExactly("flux", "result"); } @Test diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/DefaultWebMvcTagsProviderTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/DefaultWebMvcTagsProviderTests.java deleted file mode 100644 index 4bdcb94ce427..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/DefaultWebMvcTagsProviderTests.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.endpoint.web.servlet; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; - -import io.micrometer.core.instrument.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.Test; - -import org.springframework.boot.actuate.metrics.web.servlet.DefaultWebMvcTagsProvider; -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTagsContributor; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.web.servlet.HandlerMapping; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link DefaultWebMvcTagsProvider}. - * - * @author Andy Wilkinson - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class DefaultWebMvcTagsProviderTests { - - @Test - void whenTagsAreProvidedThenDefaultTagsArePresent() { - Map tags = asMap(new DefaultWebMvcTagsProvider().getTags(null, null, null, null)); - assertThat(tags).containsOnlyKeys("exception", "method", "outcome", "status", "uri"); - } - - @Test - void givenSomeContributorsWhenTagsAreProvidedThenDefaultTagsAndContributedTagsArePresent() { - Map tags = asMap( - new DefaultWebMvcTagsProvider(Arrays.asList(new TestWebMvcTagsContributor("alpha"), - new TestWebMvcTagsContributor("bravo", "charlie"))) - .getTags(null, null, null, null)); - assertThat(tags).containsOnlyKeys("exception", "method", "outcome", "status", "uri", "alpha", "bravo", - "charlie"); - } - - @Test - void whenLongRequestTagsAreProvidedThenDefaultTagsArePresent() { - Map tags = asMap(new DefaultWebMvcTagsProvider().getLongRequestTags(null, null)); - assertThat(tags).containsOnlyKeys("method", "uri"); - } - - @Test - void givenSomeContributorsWhenLongRequestTagsAreProvidedThenDefaultTagsAndContributedTagsArePresent() { - Map tags = asMap( - new DefaultWebMvcTagsProvider(Arrays.asList(new TestWebMvcTagsContributor("alpha"), - new TestWebMvcTagsContributor("bravo", "charlie"))) - .getLongRequestTags(null, null)); - assertThat(tags).containsOnlyKeys("method", "uri", "alpha", "bravo", "charlie"); - } - - @Test - void trailingSlashIsIncludedByDefault() { - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/the/uri/"); - request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "{one}/{two}/"); - Map tags = asMap(new DefaultWebMvcTagsProvider().getTags(request, null, null, null)); - assertThat(tags.get("uri").getValue()).isEqualTo("{one}/{two}/"); - } - - @Test - void trailingSlashCanBeIgnored() { - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/the/uri/"); - request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "{one}/{two}/"); - Map tags = asMap(new DefaultWebMvcTagsProvider(true).getTags(request, null, null, null)); - assertThat(tags.get("uri").getValue()).isEqualTo("{one}/{two}"); - } - - private Map asMap(Iterable tags) { - return StreamSupport.stream(tags.spliterator(), false) - .collect(Collectors.toMap(Tag::getKey, Function.identity())); - } - - private static final class TestWebMvcTagsContributor implements WebMvcTagsContributor { - - private final List tagNames; - - private TestWebMvcTagsContributor(String... tagNames) { - this.tagNames = Arrays.asList(tagNames); - } - - @Override - public Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, - Throwable exception) { - return this.tagNames.stream().map((name) -> Tag.of(name, "value")).toList(); - } - - @Override - public Iterable getLongRequestTags(HttpServletRequest request, Object handler) { - return this.tagNames.stream().map((name) -> Tag.of(name, "value")).toList(); - } - - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/WebMvcTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/WebMvcTagsTests.java deleted file mode 100644 index 7097ee9dfd79..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/WebMvcTagsTests.java +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.endpoint.web.servlet; - -import io.micrometer.core.instrument.Tag; -import org.junit.jupiter.api.Test; - -import org.springframework.boot.actuate.metrics.web.servlet.WebMvcTags; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.web.servlet.HandlerMapping; -import org.springframework.web.util.pattern.PathPatternParser; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link WebMvcTags}. - * - * @author Andy Wilkinson - * @author Brian Clozel - * @author Michael McFadyen - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class WebMvcTagsTests { - - private final MockHttpServletRequest request = new MockHttpServletRequest(); - - private final MockHttpServletResponse response = new MockHttpServletResponse(); - - @Test - void uriTagIsDataRestsEffectiveRepositoryLookupPathWhenAvailable() { - this.request.setAttribute( - "org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping.EFFECTIVE_REPOSITORY_RESOURCE_LOOKUP_PATH", - new PathPatternParser().parse("/api/cities")); - this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/{repository}"); - Tag tag = WebMvcTags.uri(this.request, this.response); - assertThat(tag.getValue()).isEqualTo("/api/cities"); - } - - @Test - void uriTagValueIsBestMatchingPatternWhenAvailable() { - this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/spring/"); - this.response.setStatus(301); - Tag tag = WebMvcTags.uri(this.request, this.response); - assertThat(tag.getValue()).isEqualTo("/spring/"); - } - - @Test - void uriTagValueIsRootWhenBestMatchingPatternIsEmpty() { - this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, ""); - this.response.setStatus(301); - Tag tag = WebMvcTags.uri(this.request, this.response); - assertThat(tag.getValue()).isEqualTo("root"); - } - - @Test - void uriTagValueWithBestMatchingPatternAndIgnoreTrailingSlashRemoveTrailingSlash() { - this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/spring/"); - Tag tag = WebMvcTags.uri(this.request, this.response, true); - assertThat(tag.getValue()).isEqualTo("/spring"); - } - - @Test - void uriTagValueWithBestMatchingPatternAndIgnoreTrailingSlashKeepSingleSlash() { - this.request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/"); - Tag tag = WebMvcTags.uri(this.request, this.response, true); - assertThat(tag.getValue()).isEqualTo("/"); - } - - @Test - void uriTagValueIsRootWhenRequestHasNoPatternOrPathInfo() { - assertThat(WebMvcTags.uri(this.request, null).getValue()).isEqualTo("root"); - } - - @Test - void uriTagValueIsRootWhenRequestHasNoPatternAndSlashPathInfo() { - this.request.setPathInfo("/"); - assertThat(WebMvcTags.uri(this.request, null).getValue()).isEqualTo("root"); - } - - @Test - void uriTagValueIsUnknownWhenRequestHasNoPatternAndNonRootPathInfo() { - this.request.setPathInfo("/example"); - assertThat(WebMvcTags.uri(this.request, null).getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void uriTagValueIsRedirectionWhenResponseStatusIs3xx() { - this.response.setStatus(301); - Tag tag = WebMvcTags.uri(this.request, this.response); - assertThat(tag.getValue()).isEqualTo("REDIRECTION"); - } - - @Test - void uriTagValueIsNotFoundWhenResponseStatusIs404() { - this.response.setStatus(404); - Tag tag = WebMvcTags.uri(this.request, this.response); - assertThat(tag.getValue()).isEqualTo("NOT_FOUND"); - } - - @Test - void uriTagToleratesCustomResponseStatus() { - this.response.setStatus(601); - Tag tag = WebMvcTags.uri(this.request, this.response); - assertThat(tag.getValue()).isEqualTo("root"); - } - - @Test - void uriTagIsUnknownWhenRequestIsNull() { - Tag tag = WebMvcTags.uri(null, null); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void outcomeTagIsUnknownWhenResponseIsNull() { - Tag tag = WebMvcTags.outcome(null); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void outcomeTagIsInformationalWhenResponseIs1xx() { - this.response.setStatus(100); - Tag tag = WebMvcTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("INFORMATIONAL"); - } - - @Test - void outcomeTagIsSuccessWhenResponseIs2xx() { - this.response.setStatus(200); - Tag tag = WebMvcTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("SUCCESS"); - } - - @Test - void outcomeTagIsRedirectionWhenResponseIs3xx() { - this.response.setStatus(301); - Tag tag = WebMvcTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("REDIRECTION"); - } - - @Test - void outcomeTagIsClientErrorWhenResponseIs4xx() { - this.response.setStatus(400); - Tag tag = WebMvcTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR"); - } - - @Test - void outcomeTagIsClientErrorWhenResponseIsNonStandardInClientSeries() { - this.response.setStatus(490); - Tag tag = WebMvcTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR"); - } - - @Test - void outcomeTagIsServerErrorWhenResponseIs5xx() { - this.response.setStatus(500); - Tag tag = WebMvcTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("SERVER_ERROR"); - } - - @Test - void outcomeTagIsUnknownWhenResponseStatusIsInUnknownSeries() { - this.response.setStatus(701); - Tag tag = WebMvcTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTest.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTest.java index 395fd4de9ad4..86ffbea6cb08 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTest.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 the original author 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,13 +20,18 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.List; +import java.util.function.Function; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTestInvocationContextProvider.WebEndpointsInvocationContext; +import org.springframework.context.ConfigurableApplicationContext; + /** - * Signals that a test should be performed against all web endpoint implementations - * (Jersey, Web MVC, and WebFlux) + * Signals that a test should be run against one or more of the web endpoint + * infrastructure implementations (Jersey, Web MVC, and WebFlux) * * @author Andy Wilkinson */ @@ -36,4 +41,42 @@ @ExtendWith(WebEndpointTestInvocationContextProvider.class) public @interface WebEndpointTest { + /** + * The infrastructure against which the test should run. + * @return the infrastructure to run the tests against + */ + Infrastructure[] infrastructure() default { Infrastructure.JERSEY, Infrastructure.MVC, Infrastructure.WEBFLUX }; + + enum Infrastructure { + + /** + * Actuator running on the Jersey-based infrastructure. + */ + JERSEY("Jersey", WebEndpointTestInvocationContextProvider::createJerseyContext), + + /** + * Actuator running on the WebMVC-based infrastructure. + */ + MVC("WebMvc", WebEndpointTestInvocationContextProvider::createWebMvcContext), + + /** + * Actuator running on the WebFlux-based infrastructure. + */ + WEBFLUX("WebFlux", WebEndpointTestInvocationContextProvider::createWebFluxContext); + + private final String name; + + private final Function>, ConfigurableApplicationContext> contextFactory; + + Infrastructure(String name, Function>, ConfigurableApplicationContext> contextFactory) { + this.name = name; + this.contextFactory = contextFactory; + } + + WebEndpointsInvocationContext createInvocationContext() { + return new WebEndpointsInvocationContext(this.name, this.contextFactory); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java index debfdb60787b..c0ac708f205e 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,6 +36,7 @@ import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; +import org.junit.platform.commons.util.AnnotationUtils; import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; @@ -45,6 +46,7 @@ import org.springframework.boot.actuate.endpoint.web.jersey.JerseyEndpointResourceFactory; import org.springframework.boot.actuate.endpoint.web.reactive.WebFluxEndpointHandlerMapping; import org.springframework.boot.actuate.endpoint.web.servlet.WebMvcEndpointHandlerMapping; +import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest.Infrastructure; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; @@ -91,16 +93,14 @@ public boolean supportsTestTemplate(ExtensionContext context) { @Override public Stream provideTestTemplateInvocationContexts( ExtensionContext extensionContext) { - return Stream.of( - new WebEndpointsInvocationContext("Jersey", - WebEndpointTestInvocationContextProvider::createJerseyContext), - new WebEndpointsInvocationContext("WebMvc", - WebEndpointTestInvocationContextProvider::createWebMvcContext), - new WebEndpointsInvocationContext("WebFlux", - WebEndpointTestInvocationContextProvider::createWebFluxContext)); + WebEndpointTest webEndpointTest = AnnotationUtils + .findAnnotation(extensionContext.getRequiredTestMethod(), WebEndpointTest.class) + .orElseThrow(() -> new IllegalStateException("Unable to find WebEndpointTest annotation on %s" + .formatted(extensionContext.getRequiredTestMethod()))); + return Stream.of(webEndpointTest.infrastructure()).distinct().map(Infrastructure::createInvocationContext); } - private static ConfigurableApplicationContext createJerseyContext(List> classes) { + static ConfigurableApplicationContext createJerseyContext(List> classes) { AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext(); classes.add(JerseyEndpointConfiguration.class); context.register(ClassUtils.toClassArray(classes)); @@ -108,7 +108,7 @@ private static ConfigurableApplicationContext createJerseyContext(List> return context; } - private static ConfigurableApplicationContext createWebMvcContext(List> classes) { + static ConfigurableApplicationContext createWebMvcContext(List> classes) { AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext(); classes.add(WebMvcEndpointConfiguration.class); context.register(ClassUtils.toClassArray(classes)); @@ -116,7 +116,7 @@ private static ConfigurableApplicationContext createWebMvcContext(List> return context; } - private static ConfigurableApplicationContext createWebFluxContext(List> classes) { + static ConfigurableApplicationContext createWebFluxContext(List> classes) { AnnotationConfigReactiveWebServerApplicationContext context = new AnnotationConfigReactiveWebServerApplicationContext(); classes.add(WebFluxEndpointConfiguration.class); context.register(ClassUtils.toClassArray(classes)); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicatorTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicatorTests.java index 367b1ec99113..f874582108fd 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicatorTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/influx/InfluxDbHealthIndicatorTests.java @@ -36,6 +36,8 @@ * * @author Eddú Meléndez */ +@SuppressWarnings("removal") +@Deprecated(since = "3.2.0", forRemoval = true) class InfluxDbHealthIndicatorTests { @Test diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/info/ProcessInfoContributorTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/info/ProcessInfoContributorTests.java new file mode 100644 index 000000000000..faceb15528ed --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/info/ProcessInfoContributorTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.info; + +import org.junit.jupiter.api.Test; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.boot.actuate.info.ProcessInfoContributor.ProcessInfoContributorRuntimeHints; +import org.springframework.boot.info.ProcessInfo; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ProcessInfoContributor}. + * + * @author Jonatan Ivanov + */ +class ProcessInfoContributorTests { + + @Test + void processInfoShouldBeAdded() { + ProcessInfoContributor processInfoContributor = new ProcessInfoContributor(); + Info.Builder builder = new Info.Builder(); + processInfoContributor.contribute(builder); + Info info = builder.build(); + assertThat(info.get("process")).isInstanceOf(ProcessInfo.class); + } + + @Test + void shouldRegisterHints() { + RuntimeHints runtimeHints = new RuntimeHints(); + new ProcessInfoContributorRuntimeHints().registerHints(runtimeHints, getClass().getClassLoader()); + assertThat(RuntimeHintsPredicates.reflection() + .onType(ProcessInfo.class) + .withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.DECLARED_FIELDS)) + .accepts(runtimeHints); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/mail/MailHealthIndicatorTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/mail/MailHealthIndicatorTests.java index 88fda8cead08..9abfbedd88da 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/mail/MailHealthIndicatorTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/mail/MailHealthIndicatorTests.java @@ -81,7 +81,7 @@ void smtpOnDefaultHostAndPortIsDown() throws MessagingException { assertThat(health.getDetails()).doesNotContainKey("location"); Object errorMessage = health.getDetails().get("error"); assertThat(errorMessage).isNotNull(); - assertThat(errorMessage.toString().contains("A test exception")).isTrue(); + assertThat(errorMessage.toString()).contains("A test exception"); } @Test @@ -104,7 +104,7 @@ void smtpOnDefaultHostAndCustomPortIsDown() throws MessagingException { assertThat(health.getDetails().get("location")).isEqualTo(":1234"); Object errorMessage = health.getDetails().get("error"); assertThat(errorMessage).isNotNull(); - assertThat(errorMessage.toString().contains("A test exception")).isTrue(); + assertThat(errorMessage.toString()).contains("A test exception"); } @Test @@ -125,7 +125,7 @@ void smtpOnDefaultPortIsDown() throws MessagingException { assertThat(health.getDetails()).containsEntry("location", "smtp.acme.org"); Object errorMessage = health.getDetails().get("error"); assertThat(errorMessage).isNotNull(); - assertThat(errorMessage.toString().contains("A test exception")).isTrue(); + assertThat(errorMessage.toString()).contains("A test exception"); } @Test diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManagerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManagerTests.java index ca8d61d3108c..6960ea722752 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManagerTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusPushGatewayManagerTests.java @@ -140,18 +140,6 @@ void shutdownWhenDoesNotOwnSchedulerDoesNotShutdownScheduler() { then(otherScheduler).should(never()).shutdown(); } - @Test - @SuppressWarnings("removal") - @Deprecated(since = "3.0.0", forRemoval = true) - void shutdownWhenShutdownOperationIsPushPerformsPushAddOnShutdown() throws Exception { - givenScheduleAtFixedRateWithReturnFuture(); - PrometheusPushGatewayManager manager = new PrometheusPushGatewayManager(this.pushGateway, this.registry, - this.scheduler, this.pushRate, "job", this.groupingKey, ShutdownOperation.PUSH); - manager.shutdown(); - then(this.future).should().cancel(false); - then(this.pushGateway).should().pushAdd(this.registry, "job", this.groupingKey); - } - @Test void shutdownWhenShutdownOperationIsPostPerformsPushAddOnShutdown() throws Exception { givenScheduleAtFixedRateWithReturnFuture(); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizerTests.java new file mode 100644 index 000000000000..b945b4d7f596 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/ObservationRestClientCustomizerTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-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.boot.actuate.metrics.web.client; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.http.client.observation.DefaultClientRequestObservationConvention; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ObservationRestClientCustomizer}. + * + * @author Brian Clozel + * @author Moritz Halbritter + */ +class ObservationRestClientCustomizerTests { + + private static final String TEST_METRIC_NAME = "http.test.metric.name"; + + private final ObservationRegistry observationRegistry = TestObservationRegistry.create(); + + private final RestClient.Builder restClientBuilder = RestClient.builder(); + + private final ObservationRestClientCustomizer customizer = new ObservationRestClientCustomizer( + this.observationRegistry, new DefaultClientRequestObservationConvention(TEST_METRIC_NAME)); + + @Test + void shouldCustomizeObservationConfiguration() { + this.customizer.customize(this.restClientBuilder); + assertThat(this.restClientBuilder).hasFieldOrPropertyWithValue("observationRegistry", this.observationRegistry); + assertThat(this.restClientBuilder).extracting("observationConvention") + .isInstanceOf(DefaultClientRequestObservationConvention.class) + .hasFieldOrPropertyWithValue("name", TEST_METRIC_NAME); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsTests.java deleted file mode 100644 index 7dd9a2322b37..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/client/RestTemplateExchangeTagsTests.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.client; - -import java.io.IOException; -import java.net.URI; - -import io.micrometer.core.instrument.Tag; -import org.junit.jupiter.api.Test; - -import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.client.ClientHttpRequest; -import org.springframework.http.client.ClientHttpResponse; -import org.springframework.mock.http.client.MockClientHttpResponse; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; - -/** - * Tests for {@link RestTemplateExchangeTags}. - * - * @author Nishant Raut - * @author Brian Clozel - */ -@SuppressWarnings({ "removal" }) -@Deprecated(since = "3.0.0", forRemoval = true) -class RestTemplateExchangeTagsTests { - - @Test - void outcomeTagIsUnknownWhenResponseIsNull() { - Tag tag = RestTemplateExchangeTags.outcome(null); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void outcomeTagIsInformationalWhenResponseIs1xx() { - ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.CONTINUE); - Tag tag = RestTemplateExchangeTags.outcome(response); - assertThat(tag.getValue()).isEqualTo("INFORMATIONAL"); - } - - @Test - void outcomeTagIsSuccessWhenResponseIs2xx() { - ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.OK); - Tag tag = RestTemplateExchangeTags.outcome(response); - assertThat(tag.getValue()).isEqualTo("SUCCESS"); - } - - @Test - void outcomeTagIsRedirectionWhenResponseIs3xx() { - ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.MOVED_PERMANENTLY); - Tag tag = RestTemplateExchangeTags.outcome(response); - assertThat(tag.getValue()).isEqualTo("REDIRECTION"); - } - - @Test - void outcomeTagIsClientErrorWhenResponseIs4xx() { - ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.BAD_REQUEST); - Tag tag = RestTemplateExchangeTags.outcome(response); - assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR"); - } - - @Test - void outcomeTagIsServerErrorWhenResponseIs5xx() { - ClientHttpResponse response = new MockClientHttpResponse("foo".getBytes(), HttpStatus.BAD_GATEWAY); - Tag tag = RestTemplateExchangeTags.outcome(response); - assertThat(tag.getValue()).isEqualTo("SERVER_ERROR"); - } - - @Test - void outcomeTagIsUnknownWhenResponseThrowsIOException() throws Exception { - ClientHttpResponse response = mock(ClientHttpResponse.class); - given(response.getStatusCode()).willThrow(IOException.class); - Tag tag = RestTemplateExchangeTags.outcome(response); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void outcomeTagIsClientErrorWhenResponseIsNonStandardInClientSeries() throws IOException { - ClientHttpResponse response = mock(ClientHttpResponse.class); - given(response.getStatusCode()).willReturn(HttpStatusCode.valueOf(490)); - Tag tag = RestTemplateExchangeTags.outcome(response); - assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR"); - } - - @Test - void outcomeTagIsUnknownWhenResponseStatusIsInUnknownSeries() throws IOException { - ClientHttpResponse response = mock(ClientHttpResponse.class); - given(response.getStatusCode()).willReturn(HttpStatusCode.valueOf(701)); - Tag tag = RestTemplateExchangeTags.outcome(response); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void clientNameTagIsHostOfRequestUri() { - ClientHttpRequest request = mock(ClientHttpRequest.class); - given(request.getURI()).willReturn(URI.create("https://example.org")); - Tag tag = RestTemplateExchangeTags.clientName(request); - assertThat(tag).isEqualTo(Tag.of("client.name", "example.org")); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProviderTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProviderTests.java deleted file mode 100644 index c04846e71696..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/DefaultWebClientExchangeTagsProviderTests.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.client; - -import java.io.IOException; -import java.net.URI; - -import io.micrometer.core.instrument.Tag; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClient; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; - -/** - * Tests for {@link DefaultWebClientExchangeTagsProvider} - * - * @author Brian Clozel - * @author Nishant Raut - */ -@SuppressWarnings({ "deprecation", "removal" }) -class DefaultWebClientExchangeTagsProviderTests { - - private static final String URI_TEMPLATE_ATTRIBUTE = WebClient.class.getName() + ".uriTemplate"; - - private final WebClientExchangeTagsProvider tagsProvider = new DefaultWebClientExchangeTagsProvider(); - - private ClientRequest request; - - private ClientResponse response; - - @BeforeEach - void setup() { - this.request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot")) - .attribute(URI_TEMPLATE_ATTRIBUTE, "https://example.org/projects/{project}") - .build(); - this.response = mock(ClientResponse.class); - given(this.response.statusCode()).willReturn(HttpStatus.OK); - } - - @Test - void tagsShouldBePopulated() { - Iterable tags = this.tagsProvider.tags(this.request, this.response, null); - assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/{project}"), - Tag.of("client.name", "example.org"), Tag.of("status", "200"), Tag.of("outcome", "SUCCESS")); - } - - @Test - void tagsWhenNoUriTemplateShouldProvideUriPath() { - ClientRequest request = ClientRequest - .create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot")) - .build(); - Iterable tags = this.tagsProvider.tags(request, this.response, null); - assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/spring-boot"), - Tag.of("client.name", "example.org"), Tag.of("status", "200"), Tag.of("outcome", "SUCCESS")); - } - - @Test - void tagsWhenIoExceptionShouldReturnIoErrorStatus() { - Iterable tags = this.tagsProvider.tags(this.request, null, new IOException()); - assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/{project}"), - Tag.of("client.name", "example.org"), Tag.of("status", "IO_ERROR"), Tag.of("outcome", "UNKNOWN")); - } - - @Test - void tagsWhenExceptionShouldReturnClientErrorStatus() { - Iterable tags = this.tagsProvider.tags(this.request, null, new IllegalArgumentException()); - assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/{project}"), - Tag.of("client.name", "example.org"), Tag.of("status", "CLIENT_ERROR"), Tag.of("outcome", "UNKNOWN")); - } - - @Test - void tagsWhenCancelledRequestShouldReturnClientErrorStatus() { - Iterable tags = this.tagsProvider.tags(this.request, null, null); - assertThat(tags).containsExactlyInAnyOrder(Tag.of("method", "GET"), Tag.of("uri", "/projects/{project}"), - Tag.of("client.name", "example.org"), Tag.of("status", "CLIENT_ERROR"), Tag.of("outcome", "UNKNOWN")); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsTests.java deleted file mode 100644 index fbc3860fb4bc..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/client/WebClientExchangeTagsTests.java +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.client; - -import java.io.IOException; -import java.net.URI; - -import io.micrometer.core.instrument.Tag; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClient; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; - -/** - * Tests for {@link WebClientExchangeTags}. - * - * @author Brian Clozel - * @author Nishant Raut - */ -@SuppressWarnings({ "deprecation", "removal" }) -class WebClientExchangeTagsTests { - - private static final String URI_TEMPLATE_ATTRIBUTE = WebClient.class.getName() + ".uriTemplate"; - - private ClientRequest request; - - private ClientResponse response; - - @BeforeEach - void setup() { - this.request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot")) - .attribute(URI_TEMPLATE_ATTRIBUTE, "https://example.org/projects/{project}") - .build(); - this.response = mock(ClientResponse.class); - } - - @Test - void method() { - assertThat(WebClientExchangeTags.method(this.request)).isEqualTo(Tag.of("method", "GET")); - } - - @Test - void uriWhenAbsoluteTemplateIsAvailableShouldReturnTemplate() { - assertThat(WebClientExchangeTags.uri(this.request)).isEqualTo(Tag.of("uri", "/projects/{project}")); - } - - @Test - void uriWhenRelativeTemplateIsAvailableShouldReturnTemplate() { - this.request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot")) - .attribute(URI_TEMPLATE_ATTRIBUTE, "/projects/{project}") - .build(); - assertThat(WebClientExchangeTags.uri(this.request)).isEqualTo(Tag.of("uri", "/projects/{project}")); - } - - @Test - void uriWhenTemplateIsMissingShouldReturnPath() { - this.request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot")) - .build(); - assertThat(WebClientExchangeTags.uri(this.request)).isEqualTo(Tag.of("uri", "/projects/spring-boot")); - } - - @Test - void uriWhenTemplateIsMissingShouldReturnPathWithQueryParams() { - this.request = ClientRequest - .create(HttpMethod.GET, URI.create("https://example.org/projects/spring-boot?section=docs")) - .build(); - assertThat(WebClientExchangeTags.uri(this.request)) - .isEqualTo(Tag.of("uri", "/projects/spring-boot?section=docs")); - } - - @Test - void clientName() { - assertThat(WebClientExchangeTags.clientName(this.request)).isEqualTo(Tag.of("client.name", "example.org")); - } - - @Test - void status() { - given(this.response.statusCode()).willReturn(HttpStatus.OK); - assertThat(WebClientExchangeTags.status(this.response, null)).isEqualTo(Tag.of("status", "200")); - } - - @Test - void statusWhenIOException() { - assertThat(WebClientExchangeTags.status(null, new IOException())).isEqualTo(Tag.of("status", "IO_ERROR")); - } - - @Test - void statusWhenClientException() { - assertThat(WebClientExchangeTags.status(null, new IllegalArgumentException())) - .isEqualTo(Tag.of("status", "CLIENT_ERROR")); - } - - @Test - void statusWhenNonStandard() { - given(this.response.statusCode()).willReturn(HttpStatusCode.valueOf(490)); - assertThat(WebClientExchangeTags.status(this.response, null)).isEqualTo(Tag.of("status", "490")); - } - - @Test - void statusWhenCancelled() { - assertThat(WebClientExchangeTags.status(null, null)).isEqualTo(Tag.of("status", "CLIENT_ERROR")); - } - - @Test - void outcomeTagIsUnknownWhenResponseIsNull() { - Tag tag = WebClientExchangeTags.outcome(null); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void outcomeTagIsInformationalWhenResponseIs1xx() { - given(this.response.statusCode()).willReturn(HttpStatus.CONTINUE); - Tag tag = WebClientExchangeTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("INFORMATIONAL"); - } - - @Test - void outcomeTagIsSuccessWhenResponseIs2xx() { - given(this.response.statusCode()).willReturn(HttpStatus.OK); - Tag tag = WebClientExchangeTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("SUCCESS"); - } - - @Test - void outcomeTagIsRedirectionWhenResponseIs3xx() { - given(this.response.statusCode()).willReturn(HttpStatus.MOVED_PERMANENTLY); - Tag tag = WebClientExchangeTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("REDIRECTION"); - } - - @Test - void outcomeTagIsClientErrorWhenResponseIs4xx() { - given(this.response.statusCode()).willReturn(HttpStatus.BAD_REQUEST); - Tag tag = WebClientExchangeTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR"); - } - - @Test - void outcomeTagIsServerErrorWhenResponseIs5xx() { - given(this.response.statusCode()).willReturn(HttpStatus.BAD_GATEWAY); - Tag tag = WebClientExchangeTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("SERVER_ERROR"); - } - - @Test - void outcomeTagIsClientErrorWhenResponseIsNonStandardInClientSeries() { - given(this.response.statusCode()).willReturn(HttpStatusCode.valueOf(490)); - Tag tag = WebClientExchangeTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR"); - } - - @Test - void outcomeTagIsUnknownWhenResponseStatusIsInUnknownSeries() { - given(this.response.statusCode()).willReturn(HttpStatusCode.valueOf(701)); - Tag tag = WebClientExchangeTags.outcome(this.response); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProviderTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProviderTests.java deleted file mode 100644 index 39240b2fd341..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/DefaultWebFluxTagsProviderTests.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.server; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; - -import io.micrometer.core.instrument.Tag; -import org.junit.jupiter.api.Test; - -import org.springframework.mock.http.server.reactive.MockServerHttpRequest; -import org.springframework.mock.web.server.MockServerWebExchange; -import org.springframework.web.server.ServerWebExchange; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link DefaultWebFluxTagsProvider}. - * - * @author Andy Wilkinson - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class DefaultWebFluxTagsProviderTests { - - @Test - void whenTagsAreProvidedThenDefaultTagsArePresent() { - ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test")); - Map tags = asMap(new DefaultWebFluxTagsProvider().httpRequestTags(exchange, null)); - assertThat(tags).containsOnlyKeys("exception", "method", "outcome", "status", "uri"); - } - - @Test - void givenSomeContributorsWhenTagsAreProvidedThenDefaultTagsAndContributedTagsArePresent() { - ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test")); - Map tags = asMap( - new DefaultWebFluxTagsProvider(Arrays.asList(new TestWebFluxTagsContributor("alpha"), - new TestWebFluxTagsContributor("bravo", "charlie"))) - .httpRequestTags(exchange, null)); - assertThat(tags).containsOnlyKeys("exception", "method", "outcome", "status", "uri", "alpha", "bravo", - "charlie"); - } - - private Map asMap(Iterable tags) { - return StreamSupport.stream(tags.spliterator(), false) - .collect(Collectors.toMap(Tag::getKey, Function.identity())); - } - - private static final class TestWebFluxTagsContributor implements WebFluxTagsContributor { - - private final List tagNames; - - private TestWebFluxTagsContributor(String... tagNames) { - this.tagNames = Arrays.asList(tagNames); - } - - @Override - public Iterable httpRequestTags(ServerWebExchange exchange, Throwable ex) { - return this.tagNames.stream().map((name) -> Tag.of(name, "value")).toList(); - } - - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsTests.java deleted file mode 100644 index 03d7a1030258..000000000000 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/web/reactive/server/WebFluxTagsTests.java +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright 2012-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.boot.actuate.metrics.web.reactive.server; - -import java.io.EOFException; - -import io.micrometer.core.instrument.Tag; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.server.reactive.ServerHttpRequest; -import org.springframework.http.server.reactive.ServerHttpResponse; -import org.springframework.mock.http.server.reactive.MockServerHttpRequest; -import org.springframework.mock.web.server.MockServerWebExchange; -import org.springframework.web.reactive.HandlerMapping; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.util.pattern.PathPatternParser; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; - -/** - * Tests for {@link WebFluxTags}. - * - * @author Brian Clozel - * @author Michael McFadyen - * @author Madhura Bhave - * @author Stephane Nicoll - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.0.0", forRemoval = true) -class WebFluxTagsTests { - - private MockServerWebExchange exchange; - - private final PathPatternParser parser = new PathPatternParser(); - - @BeforeEach - void setup() { - this.exchange = MockServerWebExchange.from(MockServerHttpRequest.get("")); - } - - @Test - void uriTagValueIsBestMatchingPatternWhenAvailable() { - this.exchange.getAttributes() - .put(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, this.parser.parse("/spring/")); - this.exchange.getResponse().setStatusCode(HttpStatus.MOVED_PERMANENTLY); - Tag tag = WebFluxTags.uri(this.exchange); - assertThat(tag.getValue()).isEqualTo("/spring/"); - } - - @Test - void uriTagValueIsRootWhenBestMatchingPatternIsEmpty() { - this.exchange.getAttributes().put(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, this.parser.parse("")); - this.exchange.getResponse().setStatusCode(HttpStatus.MOVED_PERMANENTLY); - Tag tag = WebFluxTags.uri(this.exchange); - assertThat(tag.getValue()).isEqualTo("root"); - } - - @Test - void uriTagValueWithBestMatchingPatternAndIgnoreTrailingSlashRemoveTrailingSlash() { - this.exchange.getAttributes() - .put(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, this.parser.parse("/spring/")); - Tag tag = WebFluxTags.uri(this.exchange, true); - assertThat(tag.getValue()).isEqualTo("/spring"); - } - - @Test - void uriTagValueWithBestMatchingPatternAndIgnoreTrailingSlashKeepSingleSlash() { - this.exchange.getAttributes().put(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, this.parser.parse("/")); - Tag tag = WebFluxTags.uri(this.exchange, true); - assertThat(tag.getValue()).isEqualTo("/"); - } - - @Test - void uriTagValueIsRedirectionWhenResponseStatusIs3xx() { - this.exchange.getResponse().setStatusCode(HttpStatus.MOVED_PERMANENTLY); - Tag tag = WebFluxTags.uri(this.exchange); - assertThat(tag.getValue()).isEqualTo("REDIRECTION"); - } - - @Test - void uriTagValueIsNotFoundWhenResponseStatusIs404() { - this.exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); - Tag tag = WebFluxTags.uri(this.exchange); - assertThat(tag.getValue()).isEqualTo("NOT_FOUND"); - } - - @Test - void uriTagToleratesCustomResponseStatus() { - this.exchange.getResponse().setRawStatusCode(601); - Tag tag = WebFluxTags.uri(this.exchange); - assertThat(tag.getValue()).isEqualTo("root"); - } - - @Test - void uriTagValueIsRootWhenRequestHasNoPatternOrPathInfo() { - Tag tag = WebFluxTags.uri(this.exchange); - assertThat(tag.getValue()).isEqualTo("root"); - } - - @Test - void uriTagValueIsRootWhenRequestHasNoPatternAndSlashPathInfo() { - MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); - ServerWebExchange exchange = MockServerWebExchange.from(request); - Tag tag = WebFluxTags.uri(exchange); - assertThat(tag.getValue()).isEqualTo("root"); - } - - @Test - void uriTagValueIsUnknownWhenRequestHasNoPatternAndNonRootPathInfo() { - MockServerHttpRequest request = MockServerHttpRequest.get("/example").build(); - ServerWebExchange exchange = MockServerWebExchange.from(request); - Tag tag = WebFluxTags.uri(exchange); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void methodTagToleratesNonStandardHttpMethods() { - ServerWebExchange exchange = mock(ServerWebExchange.class); - ServerHttpRequest request = mock(ServerHttpRequest.class); - given(exchange.getRequest()).willReturn(request); - given(request.getMethod()).willReturn(HttpMethod.valueOf("CUSTOM")); - Tag tag = WebFluxTags.method(exchange); - assertThat(tag.getValue()).isEqualTo("CUSTOM"); - } - - @Test - void outcomeTagIsSuccessWhenResponseStatusIsNull() { - this.exchange.getResponse().setStatusCode(null); - Tag tag = WebFluxTags.outcome(this.exchange, null); - assertThat(tag.getValue()).isEqualTo("SUCCESS"); - } - - @Test - void outcomeTagIsSuccessWhenResponseStatusIsAvailableFromUnderlyingServer() { - ServerWebExchange exchange = mock(ServerWebExchange.class); - ServerHttpRequest request = mock(ServerHttpRequest.class); - ServerHttpResponse response = mock(ServerHttpResponse.class); - given(response.getStatusCode()).willReturn(HttpStatus.OK); - given(response.getStatusCode().value()).willReturn(null); - given(exchange.getRequest()).willReturn(request); - given(exchange.getResponse()).willReturn(response); - Tag tag = WebFluxTags.outcome(exchange, null); - assertThat(tag.getValue()).isEqualTo("SUCCESS"); - } - - @Test - void outcomeTagIsInformationalWhenResponseIs1xx() { - this.exchange.getResponse().setStatusCode(HttpStatus.CONTINUE); - Tag tag = WebFluxTags.outcome(this.exchange, null); - assertThat(tag.getValue()).isEqualTo("INFORMATIONAL"); - } - - @Test - void outcomeTagIsSuccessWhenResponseIs2xx() { - this.exchange.getResponse().setStatusCode(HttpStatus.OK); - Tag tag = WebFluxTags.outcome(this.exchange, null); - assertThat(tag.getValue()).isEqualTo("SUCCESS"); - } - - @Test - void outcomeTagIsRedirectionWhenResponseIs3xx() { - this.exchange.getResponse().setStatusCode(HttpStatus.MOVED_PERMANENTLY); - Tag tag = WebFluxTags.outcome(this.exchange, null); - assertThat(tag.getValue()).isEqualTo("REDIRECTION"); - } - - @Test - void outcomeTagIsClientErrorWhenResponseIs4xx() { - this.exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST); - Tag tag = WebFluxTags.outcome(this.exchange, null); - assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR"); - } - - @Test - void outcomeTagIsServerErrorWhenResponseIs5xx() { - this.exchange.getResponse().setStatusCode(HttpStatus.BAD_GATEWAY); - Tag tag = WebFluxTags.outcome(this.exchange, null); - assertThat(tag.getValue()).isEqualTo("SERVER_ERROR"); - } - - @Test - void outcomeTagIsClientErrorWhenResponseIsNonStandardInClientSeries() { - this.exchange.getResponse().setRawStatusCode(490); - Tag tag = WebFluxTags.outcome(this.exchange, null); - assertThat(tag.getValue()).isEqualTo("CLIENT_ERROR"); - } - - @Test - void outcomeTagIsUnknownWhenResponseStatusIsInUnknownSeries() { - this.exchange.getResponse().setRawStatusCode(701); - Tag tag = WebFluxTags.outcome(this.exchange, null); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - - @Test - void outcomeTagIsUnknownWhenExceptionIsDisconnectedClient() { - Tag tag = WebFluxTags.outcome(this.exchange, new EOFException("broken pipe")); - assertThat(tag.getValue()).isEqualTo("UNKNOWN"); - } - -} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpointTests.java new file mode 100644 index 000000000000..e5b54926505e --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpointTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.session; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.boot.actuate.session.SessionsDescriptor.SessionDescriptor; +import org.springframework.session.MapSession; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ReactiveSessionsEndpoint}. + * + * @author Vedran Pavic + * @author Moritz Halbritter + */ +class ReactiveSessionsEndpointTests { + + private static final Session session = new MapSession(); + + @SuppressWarnings("unchecked") + private final ReactiveSessionRepository sessionRepository = mock(ReactiveSessionRepository.class); + + @SuppressWarnings("unchecked") + private final ReactiveFindByIndexNameSessionRepository indexedSessionRepository = mock( + ReactiveFindByIndexNameSessionRepository.class); + + private final ReactiveSessionsEndpoint endpoint = new ReactiveSessionsEndpoint(this.sessionRepository, + this.indexedSessionRepository); + + @Test + void sessionsForUsername() { + given(this.indexedSessionRepository.findByPrincipalName("user")) + .willReturn(Mono.just(Collections.singletonMap(session.getId(), session))); + StepVerifier.create(this.endpoint.sessionsForUsername("user")).consumeNextWith((sessions) -> { + List result = sessions.getSessions(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(session.getId()); + assertThat(result.get(0).getAttributeNames()).isEqualTo(session.getAttributeNames()); + assertThat(result.get(0).getCreationTime()).isEqualTo(session.getCreationTime()); + assertThat(result.get(0).getLastAccessedTime()).isEqualTo(session.getLastAccessedTime()); + assertThat(result.get(0).getMaxInactiveInterval()).isEqualTo(session.getMaxInactiveInterval().getSeconds()); + assertThat(result.get(0).isExpired()).isEqualTo(session.isExpired()); + }).expectComplete().verify(Duration.ofSeconds(1)); + then(this.indexedSessionRepository).should().findByPrincipalName("user"); + } + + @Test + void sessionsForUsernameWhenNoIndexedRepository() { + ReactiveSessionsEndpoint endpoint = new ReactiveSessionsEndpoint(this.sessionRepository, null); + StepVerifier.create(endpoint.sessionsForUsername("user")).expectComplete().verify(Duration.ofSeconds(1)); + } + + @Test + void getSession() { + given(this.sessionRepository.findById(session.getId())).willReturn(Mono.just(session)); + StepVerifier.create(this.endpoint.getSession(session.getId())).consumeNextWith((result) -> { + assertThat(result.getId()).isEqualTo(session.getId()); + assertThat(result.getAttributeNames()).isEqualTo(session.getAttributeNames()); + assertThat(result.getCreationTime()).isEqualTo(session.getCreationTime()); + assertThat(result.getLastAccessedTime()).isEqualTo(session.getLastAccessedTime()); + assertThat(result.getMaxInactiveInterval()).isEqualTo(session.getMaxInactiveInterval().getSeconds()); + assertThat(result.isExpired()).isEqualTo(session.isExpired()); + }).expectComplete().verify(Duration.ofSeconds(1)); + then(this.sessionRepository).should().findById(session.getId()); + } + + @Test + void getSessionWithIdNotFound() { + given(this.sessionRepository.findById("not-found")).willReturn(Mono.empty()); + StepVerifier.create(this.endpoint.getSession("not-found")).expectComplete().verify(Duration.ofSeconds(1)); + then(this.sessionRepository).should().findById("not-found"); + } + + @Test + void deleteSession() { + given(this.sessionRepository.deleteById(session.getId())).willReturn(Mono.empty()); + StepVerifier.create(this.endpoint.deleteSession(session.getId())) + .expectComplete() + .verify(Duration.ofSeconds(1)); + then(this.sessionRepository).should().deleteById(session.getId()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpointWebIntegrationTests.java new file mode 100644 index 000000000000..c9b13949916e --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/ReactiveSessionsEndpointWebIntegrationTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.session; + +import java.util.Collections; + +import net.minidev.json.JSONArray; +import reactor.core.publisher.Mono; + +import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest; +import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest.Infrastructure; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.MapSession; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Integration tests for {@link ReactiveSessionsEndpoint} exposed by WebFlux. + * + * @author Vedran Pavic + * @author Moritz Halbritter + */ +class ReactiveSessionsEndpointWebIntegrationTests { + + private static final Session session = new MapSession(); + + @SuppressWarnings("unchecked") + private static final ReactiveSessionRepository sessionRepository = mock(ReactiveSessionRepository.class); + + @SuppressWarnings("unchecked") + private static final ReactiveFindByIndexNameSessionRepository indexedSessionRepository = mock( + ReactiveFindByIndexNameSessionRepository.class); + + @WebEndpointTest(infrastructure = Infrastructure.WEBFLUX) + void sessionsForUsernameWithoutUsernameParam(WebTestClient client) { + client.get() + .uri((builder) -> builder.path("/actuator/sessions").build()) + .exchange() + .expectStatus() + .is5xxServerError(); // https://github.com/spring-projects/spring-boot/issues/39236 + } + + @WebEndpointTest(infrastructure = Infrastructure.WEBFLUX) + void sessionsForUsernameNoResults(WebTestClient client) { + given(indexedSessionRepository.findByPrincipalName("user")).willReturn(Mono.just(Collections.emptyMap())); + client.get() + .uri((builder) -> builder.path("/actuator/sessions").queryParam("username", "user").build()) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("sessions") + .isEmpty(); + } + + @WebEndpointTest(infrastructure = Infrastructure.WEBFLUX) + void sessionsForUsernameFound(WebTestClient client) { + given(indexedSessionRepository.findByPrincipalName("user")) + .willReturn(Mono.just(Collections.singletonMap(session.getId(), session))); + client.get() + .uri((builder) -> builder.path("/actuator/sessions").queryParam("username", "user").build()) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("sessions.[*].id") + .isEqualTo(new JSONArray().appendElement(session.getId())); + } + + @WebEndpointTest(infrastructure = Infrastructure.WEBFLUX) + void sessionForIdFound(WebTestClient client) { + given(sessionRepository.findById(session.getId())).willReturn(Mono.just(session)); + client.get() + .uri((builder) -> builder.path("/actuator/sessions/{id}").build(session.getId())) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("id") + .isEqualTo(session.getId()); + } + + @WebEndpointTest(infrastructure = Infrastructure.WEBFLUX) + void sessionForIdNotFound(WebTestClient client) { + given(sessionRepository.findById("not-found")).willReturn(Mono.empty()); + client.get() + .uri((builder) -> builder.path("/actuator/sessions/not-found").build()) + .exchange() + .expectStatus() + .isNotFound(); + } + + @WebEndpointTest(infrastructure = Infrastructure.WEBFLUX) + void deleteSession(WebTestClient client) { + given(sessionRepository.deleteById(session.getId())).willReturn(Mono.empty()); + client.delete() + .uri((builder) -> builder.path("/actuator/sessions/{id}").build(session.getId())) + .exchange() + .expectStatus() + .isNoContent(); + } + + @Configuration(proxyBeanMethods = false) + static class TestConfiguration { + + @Bean + ReactiveSessionsEndpoint sessionsEndpoint() { + return new ReactiveSessionsEndpoint(sessionRepository, indexedSessionRepository); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/SessionsEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/SessionsEndpointTests.java index eb1647e38910..8047c825aba4 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/SessionsEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/SessionsEndpointTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,11 @@ import org.junit.jupiter.api.Test; -import org.springframework.boot.actuate.session.SessionsEndpoint.SessionDescriptor; +import org.springframework.boot.actuate.session.SessionsDescriptor.SessionDescriptor; import org.springframework.session.FindByIndexNameSessionRepository; import org.springframework.session.MapSession; import org.springframework.session.Session; +import org.springframework.session.SessionRepository; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; @@ -41,13 +42,18 @@ class SessionsEndpointTests { private static final Session session = new MapSession(); @SuppressWarnings("unchecked") - private final FindByIndexNameSessionRepository repository = mock(FindByIndexNameSessionRepository.class); + private final SessionRepository sessionRepository = mock(SessionRepository.class); - private final SessionsEndpoint endpoint = new SessionsEndpoint(this.repository); + @SuppressWarnings("unchecked") + private final FindByIndexNameSessionRepository indexedSessionRepository = mock( + FindByIndexNameSessionRepository.class); + + private final SessionsEndpoint endpoint = new SessionsEndpoint(this.sessionRepository, + this.indexedSessionRepository); @Test void sessionsForUsername() { - given(this.repository.findByPrincipalName("user")) + given(this.indexedSessionRepository.findByPrincipalName("user")) .willReturn(Collections.singletonMap(session.getId(), session)); List result = this.endpoint.sessionsForUsername("user").getSessions(); assertThat(result).hasSize(1); @@ -57,11 +63,18 @@ void sessionsForUsername() { assertThat(result.get(0).getLastAccessedTime()).isEqualTo(session.getLastAccessedTime()); assertThat(result.get(0).getMaxInactiveInterval()).isEqualTo(session.getMaxInactiveInterval().getSeconds()); assertThat(result.get(0).isExpired()).isEqualTo(session.isExpired()); + then(this.indexedSessionRepository).should().findByPrincipalName("user"); + } + + @Test + void sessionsForUsernameWhenNoIndexedRepository() { + SessionsEndpoint endpoint = new SessionsEndpoint(this.sessionRepository, null); + assertThat(endpoint.sessionsForUsername("user")).isNull(); } @Test void getSession() { - given(this.repository.findById(session.getId())).willReturn(session); + given(this.sessionRepository.findById(session.getId())).willReturn(session); SessionDescriptor result = this.endpoint.getSession(session.getId()); assertThat(result.getId()).isEqualTo(session.getId()); assertThat(result.getAttributeNames()).isEqualTo(session.getAttributeNames()); @@ -69,18 +82,20 @@ void getSession() { assertThat(result.getLastAccessedTime()).isEqualTo(session.getLastAccessedTime()); assertThat(result.getMaxInactiveInterval()).isEqualTo(session.getMaxInactiveInterval().getSeconds()); assertThat(result.isExpired()).isEqualTo(session.isExpired()); + then(this.sessionRepository).should().findById(session.getId()); } @Test void getSessionWithIdNotFound() { - given(this.repository.findById("not-found")).willReturn(null); + given(this.sessionRepository.findById("not-found")).willReturn(null); assertThat(this.endpoint.getSession("not-found")).isNull(); + then(this.sessionRepository).should().findById("not-found"); } @Test void deleteSession() { this.endpoint.deleteSession(session.getId()); - then(this.repository).should().deleteById(session.getId()); + then(this.sessionRepository).should().deleteById(session.getId()); } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/SessionsEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/SessionsEndpointWebIntegrationTests.java index 0a6b28dd83b5..fcf8d5e57d83 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/SessionsEndpointWebIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/session/SessionsEndpointWebIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import net.minidev.json.JSONArray; import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest; +import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest.Infrastructure; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.session.FindByIndexNameSessionRepository; @@ -45,7 +46,7 @@ class SessionsEndpointWebIntegrationTests { private static final FindByIndexNameSessionRepository repository = mock( FindByIndexNameSessionRepository.class); - @WebEndpointTest + @WebEndpointTest(infrastructure = { Infrastructure.JERSEY, Infrastructure.MVC }) void sessionsForUsernameWithoutUsernameParam(WebTestClient client) { client.get() .uri((builder) -> builder.path("/actuator/sessions").build()) @@ -54,7 +55,7 @@ void sessionsForUsernameWithoutUsernameParam(WebTestClient client) { .isBadRequest(); } - @WebEndpointTest + @WebEndpointTest(infrastructure = { Infrastructure.JERSEY, Infrastructure.MVC }) void sessionsForUsernameNoResults(WebTestClient client) { given(repository.findByPrincipalName("user")).willReturn(Collections.emptyMap()); client.get() @@ -67,7 +68,7 @@ void sessionsForUsernameNoResults(WebTestClient client) { .isEmpty(); } - @WebEndpointTest + @WebEndpointTest(infrastructure = { Infrastructure.JERSEY, Infrastructure.MVC }) void sessionsForUsernameFound(WebTestClient client) { given(repository.findByPrincipalName("user")).willReturn(Collections.singletonMap(session.getId(), session)); client.get() @@ -80,7 +81,7 @@ void sessionsForUsernameFound(WebTestClient client) { .isEqualTo(new JSONArray().appendElement(session.getId())); } - @WebEndpointTest + @WebEndpointTest(infrastructure = { Infrastructure.JERSEY, Infrastructure.MVC }) void sessionForIdNotFound(WebTestClient client) { client.get() .uri((builder) -> builder.path("/actuator/sessions/session-id-not-found").build()) @@ -89,12 +90,21 @@ void sessionForIdNotFound(WebTestClient client) { .isNotFound(); } + @WebEndpointTest(infrastructure = { Infrastructure.JERSEY, Infrastructure.MVC }) + void deleteSession(WebTestClient client) { + client.delete() + .uri((builder) -> builder.path("/actuator/sessions/{id}").build(session.getId())) + .exchange() + .expectStatus() + .isNoContent(); + } + @Configuration(proxyBeanMethods = false) static class TestConfiguration { @Bean SessionsEndpoint sessionsEndpoint() { - return new SessionsEndpoint(repository); + return new SessionsEndpoint(repository, repository); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/build.gradle b/spring-boot-project/spring-boot-autoconfigure/build.gradle index 3ed2730ddfe7..8ad026ce0127 100644 --- a/spring-boot-project/spring-boot-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-autoconfigure/build.gradle @@ -29,6 +29,7 @@ dependencies { optional("com.nimbusds:oauth2-oidc-sdk") optional("com.oracle.database.jdbc:ojdbc11") optional("com.oracle.database.jdbc:ucp11") + optional("com.querydsl:querydsl-core") optional("com.samskivert:jmustache") optional("io.lettuce:lettuce-core") optional("io.projectreactor.netty:reactor-netty-http") @@ -78,20 +79,10 @@ dependencies { optional("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect") optional("org.aspectj:aspectjweaver") optional("org.cache2k:cache2k-spring") - optional("org.eclipse.jetty:jetty-webapp") { - exclude(group: "org.eclipse.jetty", module: "jetty-jndi") - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - } + optional("org.eclipse.jetty.ee10:jetty-ee10-webapp") optional("org.eclipse.jetty:jetty-reactive-httpclient") - optional("org.eclipse.jetty.websocket:websocket-jakarta-server") { - exclude(group: "org.eclipse.jetty", module: "jetty-jndi") - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-websocket-api") - } - optional("org.eclipse.jetty.websocket:websocket-jetty-server") { - exclude(group: "org.eclipse.jetty", module: "jetty-jndi") - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - } + optional("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-server") + optional("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server") optional("org.ehcache:ehcache") { artifact { classifier = 'jakarta' @@ -104,6 +95,7 @@ dependencies { exclude group: "commons-logging", module: "commons-logging" } optional("org.flywaydb:flyway-core") + optional("org.flywaydb:flyway-database-oracle") optional("org.flywaydb:flyway-sqlserver") optional("org.freemarker:freemarker") optional("org.glassfish.jersey.containers:jersey-container-servlet-core") @@ -178,6 +170,8 @@ dependencies { optional("org.springframework.data:spring-data-redis") optional("org.springframework.graphql:spring-graphql") optional("org.springframework.hateoas:spring-hateoas") + optional("org.springframework.pulsar:spring-pulsar") + optional("org.springframework.pulsar:spring-pulsar-reactive") optional("org.springframework.security:spring-security-acl") optional("org.springframework.security:spring-security-config") optional("org.springframework.security:spring-security-data") { @@ -223,9 +217,9 @@ dependencies { testImplementation("com.ibm.db2:jcc") testImplementation("com.jayway.jsonpath:json-path") testImplementation("com.mysql:mysql-connector-j") - testImplementation("com.querydsl:querydsl-core") testImplementation("com.squareup.okhttp3:mockwebserver") testImplementation("com.sun.xml.messaging.saaj:saaj-impl") + testImplementation("io.micrometer:context-propagation") testImplementation("io.projectreactor:reactor-test") testImplementation("io.r2dbc:r2dbc-h2") testImplementation("jakarta.json:jakarta.json-api") @@ -246,7 +240,10 @@ dependencies { testImplementation("org.springframework:spring-test") testImplementation("org.springframework:spring-core-test") testImplementation("org.springframework.graphql:spring-graphql-test") - testImplementation("org.springframework.kafka:spring-kafka-test") + testImplementation("org.springframework.kafka:spring-kafka-test") { + exclude group: "commons-logging", module: "commons-logging" + } + testImplementation("org.springframework.pulsar:spring-pulsar-cache-provider-caffeine") testImplementation("org.springframework.security:spring-security-test") testImplementation("org.testcontainers:cassandra") testImplementation("org.testcontainers:couchbase") @@ -254,6 +251,7 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:mongodb") testImplementation("org.testcontainers:neo4j") + testImplementation("org.testcontainers:pulsar") testImplementation("org.testcontainers:testcontainers") testImplementation("org.yaml:snakeyaml") diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/BackgroundPreinitializer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/BackgroundPreinitializer.java index 8ddac8fa3631..36ec4abfef6c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/BackgroundPreinitializer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/BackgroundPreinitializer.java @@ -17,11 +17,14 @@ package org.springframework.boot.autoconfigure; import java.nio.charset.StandardCharsets; +import java.time.ZoneId; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import jakarta.validation.Configuration; import jakarta.validation.Validation; +import org.apache.catalina.authenticator.NonLoginAuthenticator; +import org.apache.tomcat.util.http.Rfc6265CookieProcessor; import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; import org.springframework.boot.context.event.ApplicationFailedEvent; @@ -107,6 +110,8 @@ public void run() { runSafely(new JacksonInitializer()); } runSafely(new CharsetInitializer()); + runSafely(new TomcatInitializer()); + runSafely(new JdkInitializer()); preinitializationComplete.countDown(); } @@ -189,4 +194,23 @@ public void run() { } + private static final class TomcatInitializer implements Runnable { + + @Override + public void run() { + new Rfc6265CookieProcessor(); + new NonLoginAuthenticator(); + } + + } + + private static final class JdkInitializer implements Runnable { + + @Override + public void run() { + ZoneId.systemDefault(); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/SharedMetadataReaderFactoryContextInitializer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/SharedMetadataReaderFactoryContextInitializer.java index 8ffcaa58e77b..bdbd781ed1d1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/SharedMetadataReaderFactoryContextInitializer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/SharedMetadataReaderFactoryContextInitializer.java @@ -21,7 +21,6 @@ import org.springframework.aot.AotDetector; import org.springframework.beans.BeansException; import org.springframework.beans.MutablePropertyValues; -import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter; @@ -38,11 +37,13 @@ import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.ResourceLoaderAware; import org.springframework.context.annotation.AnnotationConfigUtils; import org.springframework.context.annotation.ConfigurationClassPostProcessor; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.core.Ordered; import org.springframework.core.PriorityOrdered; +import org.springframework.core.io.ResourceLoader; import org.springframework.core.type.classreading.CachingMetadataReaderFactory; import org.springframework.core.type.classreading.MetadataReaderFactory; @@ -187,14 +188,14 @@ private void configureConfigurationClassPostProcessor(ConfigurationClassPostProc * {@link FactoryBean} to create the shared {@link MetadataReaderFactory}. */ static class SharedMetadataReaderFactoryBean - implements FactoryBean, BeanClassLoaderAware, + implements FactoryBean, ResourceLoaderAware, ApplicationListener { private ConcurrentReferenceCachingMetadataReaderFactory metadataReaderFactory; @Override - public void setBeanClassLoader(ClassLoader classLoader) { - this.metadataReaderFactory = new ConcurrentReferenceCachingMetadataReaderFactory(classLoader); + public void setResourceLoader(ResourceLoader resourceLoader) { + this.metadataReaderFactory = new ConcurrentReferenceCachingMetadataReaderFactory(resourceLoader); } @Override diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/AbstractRabbitListenerContainerFactoryConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/AbstractRabbitListenerContainerFactoryConfigurer.java index feab224f2ca7..dd91f59e90bf 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/AbstractRabbitListenerContainerFactoryConfigurer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/AbstractRabbitListenerContainerFactoryConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.autoconfigure.amqp; import java.util.List; +import java.util.concurrent.Executor; import org.springframework.amqp.rabbit.config.AbstractRabbitListenerContainerFactory; import org.springframework.amqp.rabbit.config.RetryInterceptorBuilder; @@ -47,6 +48,8 @@ public abstract class AbstractRabbitListenerContainerFactoryConfigurer r this.retryTemplateCustomizers = retryTemplateCustomizers; } + /** + * Set the task executor to use. + * @param taskExecutor the task executor + * @since 3.2.0 + */ + public void setTaskExecutor(Executor taskExecutor) { + this.taskExecutor = taskExecutor; + } + protected final RabbitProperties getRabbitProperties() { return this.rabbitProperties; } @@ -118,6 +130,11 @@ protected void configure(T factory, ConnectionFactory connectionFactory, } factory.setMissingQueuesFatal(configuration.isMissingQueuesFatal()); factory.setDeBatchingEnabled(configuration.isDeBatchingEnabled()); + factory.setForceStop(configuration.isForceStop()); + if (this.taskExecutor != null) { + factory.setTaskExecutor(this.taskExecutor); + } + factory.setObservationEnabled(configuration.isObservationEnabled()); ListenerRetry retryConfig = configuration.getRetry(); if (retryConfig.isEnabled()) { RetryInterceptorBuilder builder = (retryConfig.isStateless()) ? RetryInterceptorBuilder.stateless() diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAnnotationDrivenConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAnnotationDrivenConfiguration.java index 51decebff512..0a5331e144b0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAnnotationDrivenConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAnnotationDrivenConfiguration.java @@ -30,14 +30,18 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; +import org.springframework.boot.autoconfigure.thread.Threading; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.VirtualThreadTaskExecutor; /** * Configuration for Spring AMQP annotation driven endpoints. * * @author Stephane Nicoll * @author Josh Thornhill + * @author Moritz Halbritter */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(EnableRabbit.class) @@ -62,12 +66,17 @@ class RabbitAnnotationDrivenConfiguration { @Bean @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.PLATFORM) SimpleRabbitListenerContainerFactoryConfigurer simpleRabbitListenerContainerFactoryConfigurer() { - SimpleRabbitListenerContainerFactoryConfigurer configurer = new SimpleRabbitListenerContainerFactoryConfigurer( - this.properties); - configurer.setMessageConverter(this.messageConverter.getIfUnique()); - configurer.setMessageRecoverer(this.messageRecoverer.getIfUnique()); - configurer.setRetryTemplateCustomizers(this.retryTemplateCustomizers.orderedStream().toList()); + return simpleListenerConfigurer(); + } + + @Bean(name = "simpleRabbitListenerContainerFactoryConfigurer") + @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.VIRTUAL) + SimpleRabbitListenerContainerFactoryConfigurer simpleRabbitListenerContainerFactoryConfigurerVirtualThreads() { + SimpleRabbitListenerContainerFactoryConfigurer configurer = simpleListenerConfigurer(); + configurer.setTaskExecutor(new VirtualThreadTaskExecutor()); return configurer; } @@ -86,12 +95,17 @@ SimpleRabbitListenerContainerFactory simpleRabbitListenerContainerFactory( @Bean @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.PLATFORM) DirectRabbitListenerContainerFactoryConfigurer directRabbitListenerContainerFactoryConfigurer() { - DirectRabbitListenerContainerFactoryConfigurer configurer = new DirectRabbitListenerContainerFactoryConfigurer( - this.properties); - configurer.setMessageConverter(this.messageConverter.getIfUnique()); - configurer.setMessageRecoverer(this.messageRecoverer.getIfUnique()); - configurer.setRetryTemplateCustomizers(this.retryTemplateCustomizers.orderedStream().toList()); + return directListenerConfigurer(); + } + + @Bean(name = "directRabbitListenerContainerFactoryConfigurer") + @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.VIRTUAL) + DirectRabbitListenerContainerFactoryConfigurer directRabbitListenerContainerFactoryConfigurerVirtualThreads() { + DirectRabbitListenerContainerFactoryConfigurer configurer = directListenerConfigurer(); + configurer.setTaskExecutor(new VirtualThreadTaskExecutor()); return configurer; } @@ -107,6 +121,24 @@ DirectRabbitListenerContainerFactory directRabbitListenerContainerFactory( return factory; } + private SimpleRabbitListenerContainerFactoryConfigurer simpleListenerConfigurer() { + SimpleRabbitListenerContainerFactoryConfigurer configurer = new SimpleRabbitListenerContainerFactoryConfigurer( + this.properties); + configurer.setMessageConverter(this.messageConverter.getIfUnique()); + configurer.setMessageRecoverer(this.messageRecoverer.getIfUnique()); + configurer.setRetryTemplateCustomizers(this.retryTemplateCustomizers.orderedStream().toList()); + return configurer; + } + + private DirectRabbitListenerContainerFactoryConfigurer directListenerConfigurer() { + DirectRabbitListenerContainerFactoryConfigurer configurer = new DirectRabbitListenerContainerFactoryConfigurer( + this.properties); + configurer.setMessageConverter(this.messageConverter.getIfUnique()); + configurer.setMessageRecoverer(this.messageRecoverer.getIfUnique()); + configurer.setRetryTemplateCustomizers(this.retryTemplateCustomizers.orderedStream().toList()); + return configurer; + } + @Configuration(proxyBeanMethods = false) @EnableRabbit @ConditionalOnMissingBean(name = RabbitListenerConfigUtils.RABBIT_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfiguration.java index 1d924f8fcb7d..4c56a677c830 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfiguration.java @@ -38,6 +38,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -69,6 +70,7 @@ * @author Chris Bono * @author Moritz Halbritter * @author Andy Wilkinson + * @author Scott Frederick * @since 1.0.0 */ @AutoConfiguration @@ -96,9 +98,10 @@ RabbitConnectionDetails rabbitConnectionDetails() { @ConditionalOnMissingBean RabbitConnectionFactoryBeanConfigurer rabbitConnectionFactoryBeanConfigurer(ResourceLoader resourceLoader, RabbitConnectionDetails connectionDetails, ObjectProvider credentialsProvider, - ObjectProvider credentialsRefreshService) { + ObjectProvider credentialsRefreshService, + ObjectProvider sslBundles) { RabbitConnectionFactoryBeanConfigurer configurer = new RabbitConnectionFactoryBeanConfigurer(resourceLoader, - this.properties, connectionDetails); + this.properties, connectionDetails, sslBundles.getIfAvailable()); configurer.setCredentialsProvider(credentialsProvider.getIfUnique()); configurer.setCredentialsRefreshService(credentialsRefreshService.getIfUnique()); return configurer; @@ -121,7 +124,7 @@ CachingConnectionFactory rabbitConnectionFactory( CachingConnectionFactoryConfigurer rabbitCachingConnectionFactoryConfigurer, ObjectProvider connectionFactoryCustomizers) throws Exception { - RabbitConnectionFactoryBean connectionFactoryBean = new RabbitConnectionFactoryBean(); + RabbitConnectionFactoryBean connectionFactoryBean = new SslBundleRabbitConnectionFactoryBean(); rabbitConnectionFactoryBeanConfigurer.configure(connectionFactoryBean); connectionFactoryBean.afterPropertiesSet(); com.rabbitmq.client.ConnectionFactory connectionFactory = connectionFactoryBean.getObject(); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitConnectionFactoryBeanConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitConnectionFactoryBeanConfigurer.java index f54e91ace5c2..2f59e2d8faed 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitConnectionFactoryBeanConfigurer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitConnectionFactoryBeanConfigurer.java @@ -24,8 +24,11 @@ import org.springframework.amqp.rabbit.connection.RabbitConnectionFactoryBean; import org.springframework.boot.autoconfigure.amqp.RabbitConnectionDetails.Address; import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; import org.springframework.core.io.ResourceLoader; import org.springframework.util.Assert; +import org.springframework.util.unit.DataSize; /** * Configures {@link RabbitConnectionFactoryBean} with sensible defaults. @@ -34,6 +37,7 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Scott Frederick * @since 2.6.0 */ public class RabbitConnectionFactoryBeanConfigurer { @@ -44,6 +48,8 @@ public class RabbitConnectionFactoryBeanConfigurer { private final RabbitConnectionDetails connectionDetails; + private final SslBundles sslBundles; + private CredentialsProvider credentialsProvider; private CredentialsRefreshService credentialsRefreshService; @@ -64,17 +70,33 @@ public RabbitConnectionFactoryBeanConfigurer(ResourceLoader resourceLoader, Rabb * priority over the properties. * @param resourceLoader the resource loader * @param properties the properties - * @param connectionDetails the connection details. + * @param connectionDetails the connection details * @since 3.1.0 */ public RabbitConnectionFactoryBeanConfigurer(ResourceLoader resourceLoader, RabbitProperties properties, RabbitConnectionDetails connectionDetails) { + this(resourceLoader, properties, connectionDetails, null); + } + + /** + * Creates a new configurer that will use the given {@code resourceLoader}, + * {@code properties}, {@code connectionDetails}, and {@code sslBundles}. The + * connection details have priority over the properties. + * @param resourceLoader the resource loader + * @param properties the properties + * @param connectionDetails the connection details + * @param sslBundles the SSL bundles + * @since 3.2.0 + */ + public RabbitConnectionFactoryBeanConfigurer(ResourceLoader resourceLoader, RabbitProperties properties, + RabbitConnectionDetails connectionDetails, SslBundles sslBundles) { Assert.notNull(resourceLoader, "ResourceLoader must not be null"); Assert.notNull(properties, "Properties must not be null"); Assert.notNull(connectionDetails, "ConnectionDetails must not be null"); this.resourceLoader = resourceLoader; this.rabbitProperties = properties; this.connectionDetails = connectionDetails; + this.sslBundles = sslBundles; } public void setCredentialsProvider(CredentialsProvider credentialsProvider) { @@ -110,15 +132,23 @@ public void configure(RabbitConnectionFactoryBean factory) { RabbitProperties.Ssl ssl = this.rabbitProperties.getSsl(); if (ssl.determineEnabled()) { factory.setUseSSL(true); - map.from(ssl::getAlgorithm).whenNonNull().to(factory::setSslAlgorithm); - map.from(ssl::getKeyStoreType).to(factory::setKeyStoreType); - map.from(ssl::getKeyStore).to(factory::setKeyStore); - map.from(ssl::getKeyStorePassword).to(factory::setKeyStorePassphrase); - map.from(ssl::getKeyStoreAlgorithm).whenNonNull().to(factory::setKeyStoreAlgorithm); - map.from(ssl::getTrustStoreType).to(factory::setTrustStoreType); - map.from(ssl::getTrustStore).to(factory::setTrustStore); - map.from(ssl::getTrustStorePassword).to(factory::setTrustStorePassphrase); - map.from(ssl::getTrustStoreAlgorithm).whenNonNull().to(factory::setTrustStoreAlgorithm); + if (ssl.getBundle() != null) { + SslBundle bundle = this.sslBundles.getBundle(ssl.getBundle()); + if (factory instanceof SslBundleRabbitConnectionFactoryBean sslFactory) { + sslFactory.setSslBundle(bundle); + } + } + else { + map.from(ssl::getAlgorithm).whenNonNull().to(factory::setSslAlgorithm); + map.from(ssl::getKeyStoreType).to(factory::setKeyStoreType); + map.from(ssl::getKeyStore).to(factory::setKeyStore); + map.from(ssl::getKeyStorePassword).to(factory::setKeyStorePassphrase); + map.from(ssl::getKeyStoreAlgorithm).whenNonNull().to(factory::setKeyStoreAlgorithm); + map.from(ssl::getTrustStoreType).to(factory::setTrustStoreType); + map.from(ssl::getTrustStore).to(factory::setTrustStore); + map.from(ssl::getTrustStorePassword).to(factory::setTrustStorePassphrase); + map.from(ssl::getTrustStoreAlgorithm).whenNonNull().to(factory::setTrustStoreAlgorithm); + } map.from(ssl::isValidateServerCertificate) .to((validate) -> factory.setSkipServerCertificateValidation(!validate)); map.from(ssl::getVerifyHostname).to(factory::setEnableHostnameVerification); @@ -133,6 +163,10 @@ public void configure(RabbitConnectionFactoryBean factory) { .to(factory::setChannelRpcTimeout); map.from(this.credentialsProvider).whenNonNull().to(factory::setCredentialsProvider); map.from(this.credentialsRefreshService).whenNonNull().to(factory::setCredentialsRefreshService); + map.from(this.rabbitProperties.getMaxInboundMessageBodySize()) + .whenNonNull() + .asInt(DataSize::toBytes) + .to(factory::setMaxInboundMessageBodySize); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitProperties.java index 53e88d0ac741..d4e744b522b3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitProperties.java @@ -31,6 +31,7 @@ import org.springframework.boot.convert.DurationUnit; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import org.springframework.util.unit.DataSize; /** * Configuration properties for Rabbit. @@ -45,6 +46,7 @@ * @author Franjo Zilic * @author Eddú Meléndez * @author Rafael Carvalho + * @author Scott Frederick * @author Lasse Wulff * @since 1.0.0 */ @@ -131,6 +133,11 @@ public class RabbitProperties { */ private Duration channelRpcTimeout = Duration.ofMinutes(10); + /** + * Maximum size of the body of inbound (received) messages. + */ + private DataSize maxInboundMessageBodySize = DataSize.ofMegabytes(64); + /** * Cache configuration. */ @@ -361,6 +368,14 @@ public void setChannelRpcTimeout(Duration channelRpcTimeout) { this.channelRpcTimeout = channelRpcTimeout; } + public DataSize getMaxInboundMessageBodySize() { + return this.maxInboundMessageBodySize; + } + + public void setMaxInboundMessageBodySize(DataSize maxInboundMessageBodySize) { + this.maxInboundMessageBodySize = maxInboundMessageBodySize; + } + public Cache getCache() { return this.cache; } @@ -387,6 +402,11 @@ public class Ssl { */ private Boolean enabled; + /** + * SSL bundle name. + */ + private String bundle; + /** * Path to the key store that holds the SSL certificate. */ @@ -454,7 +474,7 @@ public Boolean getEnabled() { * @see #getEnabled() () */ public boolean determineEnabled() { - boolean defaultEnabled = Optional.ofNullable(getEnabled()).orElse(false); + boolean defaultEnabled = Optional.ofNullable(getEnabled()).orElse(false) || this.bundle != null; if (CollectionUtils.isEmpty(RabbitProperties.this.parsedAddresses)) { return defaultEnabled; } @@ -466,6 +486,14 @@ public void setEnabled(Boolean enabled) { this.enabled = enabled; } + public String getBundle() { + return this.bundle; + } + + public void setBundle(String bundle) { + this.bundle = bundle; + } + public String getKeyStore() { return this.keyStore; } @@ -691,6 +719,19 @@ public StreamContainer getStream() { public abstract static class BaseContainer { + /** + * Whether to enable observation. + */ + private boolean observationEnabled; + + public boolean isObservationEnabled() { + return this.observationEnabled; + } + + public void setObservationEnabled(boolean observationEnabled) { + this.observationEnabled = observationEnabled; + } + } public abstract static class AmqpContainer extends BaseContainer { @@ -727,6 +768,12 @@ public abstract static class AmqpContainer extends BaseContainer { */ private boolean deBatchingEnabled = true; + /** + * Whether the container (when stopped) should stop immediately after processing + * the current message or stop after processing all pre-fetched messages. + */ + private boolean forceStop; + /** * Optional properties for a retry interceptor. */ @@ -782,6 +829,14 @@ public void setDeBatchingEnabled(boolean deBatchingEnabled) { this.deBatchingEnabled = deBatchingEnabled; } + public boolean isForceStop() { + return this.forceStop; + } + + public void setForceStop(boolean forceStop) { + this.forceStop = forceStop; + } + public ListenerRetry getRetry() { return this.retry; } @@ -955,6 +1010,11 @@ public static class Template { */ private String defaultReceiveQueue; + /** + * Whether to enable observation. + */ + private boolean observationEnabled; + public Retry getRetry() { return this.retry; } @@ -1007,6 +1067,14 @@ public void setDefaultReceiveQueue(String defaultReceiveQueue) { this.defaultReceiveQueue = defaultReceiveQueue; } + public boolean isObservationEnabled() { + return this.observationEnabled; + } + + public void setObservationEnabled(boolean observationEnabled) { + this.observationEnabled = observationEnabled; + } + } public static class Retry { @@ -1193,6 +1261,12 @@ public static final class Stream { */ private int port = DEFAULT_STREAM_PORT; + /** + * Virtual host of a RabbitMQ instance with the Stream plugin enabled. When not + * set, spring.rabbitmq.virtual-host is used. + */ + private String virtualHost; + /** * Login user to authenticate to the broker. When not set, * spring.rabbitmq.username is used. @@ -1226,6 +1300,14 @@ public void setPort(int port) { this.port = port; } + public String getVirtualHost() { + return this.virtualHost; + } + + public void setVirtualHost(String virtualHost) { + this.virtualHost = virtualHost; + } + public String getUsername() { return this.username; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfiguration.java index 6547cfdc4e9c..fa2ec57e7ff2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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.amqp.rabbit.config.ContainerCustomizer; import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.amqp.RabbitProperties.StreamContainer; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -57,7 +58,9 @@ StreamRabbitListenerContainerFactory streamRabbitListenerContainerFactory(Enviro ObjectProvider> containerCustomizer) { StreamRabbitListenerContainerFactory factory = new StreamRabbitListenerContainerFactory( rabbitStreamEnvironment); - factory.setNativeListener(properties.getListener().getStream().isNativeListener()); + StreamContainer stream = properties.getListener().getStream(); + factory.setObservationEnabled(stream.isObservationEnabled()); + factory.setNativeListener(stream.isNativeListener()); consumerCustomizer.ifUnique(factory::setConsumerCustomizer); containerCustomizer.ifUnique(factory::setContainerCustomizer); return factory; @@ -102,6 +105,10 @@ static EnvironmentBuilder configure(EnvironmentBuilder builder, RabbitProperties PropertyMapper map = PropertyMapper.get(); map.from(stream.getHost()).to(builder::host); map.from(stream.getPort()).to(builder::port); + map.from(stream.getVirtualHost()) + .as(withFallback(properties::getVirtualHost)) + .whenNonNull() + .to(builder::virtualHost); map.from(stream.getUsername()).as(withFallback(properties::getUsername)).whenNonNull().to(builder::username); map.from(stream.getPassword()).as(withFallback(properties::getPassword)).whenNonNull().to(builder::password); return builder; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitTemplateConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitTemplateConfigurer.java index 6d20e66c7b4c..b51443f87c45 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitTemplateConfigurer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/RabbitTemplateConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -101,6 +101,7 @@ public void configure(RabbitTemplate template, ConnectionFactory connectionFacto map.from(templateProperties::getExchange).to(template::setExchange); map.from(templateProperties::getRoutingKey).to(template::setRoutingKey); map.from(templateProperties::getDefaultReceiveQueue).whenNonNull().to(template::setDefaultReceiveQueue); + map.from(templateProperties::isObservationEnabled).to(template::setObservationEnabled); } private boolean determineMandatoryFlag() { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/SslBundleRabbitConnectionFactoryBean.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/SslBundleRabbitConnectionFactoryBean.java new file mode 100644 index 000000000000..526a187dd428 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/amqp/SslBundleRabbitConnectionFactoryBean.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-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.boot.autoconfigure.amqp; + +import org.springframework.amqp.rabbit.connection.RabbitConnectionFactoryBean; +import org.springframework.boot.ssl.SslBundle; + +/** + * A {@link RabbitConnectionFactoryBean} that can be configured with custom SSL trust + * material from an {@link SslBundle}. + * + * @author Scott Frederick + */ +class SslBundleRabbitConnectionFactoryBean extends RabbitConnectionFactoryBean { + + private SslBundle sslBundle; + + private boolean enableHostnameVerification; + + @Override + protected void setUpSSL() { + if (this.sslBundle != null) { + this.connectionFactory.useSslProtocol(this.sslBundle.createSslContext()); + if (this.enableHostnameVerification) { + this.connectionFactory.enableHostnameVerification(); + } + } + else { + super.setUpSSL(); + } + } + + void setSslBundle(SslBundle sslBundle) { + this.sslBundle = sslBundle; + } + + @Override + public void setEnableHostnameVerification(boolean enable) { + this.enableHostnameVerification = enable; + super.setEnableHostnameVerification(enable); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfiguration.java index 7f31db1fb7a5..22d3ffb19b34 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,9 @@ import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration; import org.springframework.batch.core.explore.JobExplorer; import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.ExecutionContextSerializer; import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.repository.dao.DefaultExecutionContextSerializer; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.ExitCodeGenerator; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -62,6 +64,7 @@ * @author Eddú Meléndez * @author Kazuki Shimizu * @author Mahmoud Ben Hassine + * @author Lars Uffmann * @since 1.0.0 */ @AutoConfiguration(after = { HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class }) @@ -102,13 +105,18 @@ static class SpringBootBatchConfiguration extends DefaultBatchConfiguration { private final List batchConversionServiceCustomizers; + private final ExecutionContextSerializer executionContextSerializer; + SpringBootBatchConfiguration(DataSource dataSource, @BatchDataSource ObjectProvider batchDataSource, PlatformTransactionManager transactionManager, BatchProperties properties, - ObjectProvider batchConversionServiceCustomizers) { + ObjectProvider batchConversionServiceCustomizers, + ObjectProvider executionContextSerializer) { this.dataSource = batchDataSource.getIfAvailable(() -> dataSource); this.transactionManager = transactionManager; this.properties = properties; this.batchConversionServiceCustomizers = batchConversionServiceCustomizers.orderedStream().toList(); + this.executionContextSerializer = executionContextSerializer + .getIfAvailable(DefaultExecutionContextSerializer::new); } @Override @@ -142,6 +150,11 @@ protected ConfigurableConversionService getConversionService() { return conversionService; } + @Override + protected ExecutionContextSerializer getExecutionContextSerializer() { + return this.executionContextSerializer; + } + } @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/JobLauncherApplicationRunner.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/JobLauncherApplicationRunner.java index b4415ac365ef..a343346eb3e6 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/JobLauncherApplicationRunner.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/batch/JobLauncherApplicationRunner.java @@ -19,7 +19,6 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; import java.util.Properties; @@ -230,7 +229,8 @@ private JobParameters getNextJobParameters(Job job, JobParameters jobParameters) private JobParameters getNextJobParametersForExisting(Job job, JobParameters jobParameters) { JobExecution lastExecution = this.jobRepository.getLastJobExecution(job.getName(), jobParameters); if (isStoppedOrFailed(lastExecution) && job.isRestartable()) { - JobParameters previousIdentifyingParameters = getGetIdentifying(lastExecution.getJobParameters()); + JobParameters previousIdentifyingParameters = new JobParameters( + lastExecution.getJobParameters().getIdentifyingParameters()); return merge(previousIdentifyingParameters, jobParameters); } return jobParameters; @@ -241,16 +241,6 @@ private boolean isStoppedOrFailed(JobExecution execution) { return (status == BatchStatus.STOPPED || status == BatchStatus.FAILED); } - private JobParameters getGetIdentifying(JobParameters parameters) { - HashMap> nonIdentifying = new LinkedHashMap<>(parameters.getParameters().size()); - parameters.getParameters().forEach((key, value) -> { - if (value.isIdentifying()) { - nonIdentifying.put(key, value); - } - }); - return new JobParameters(nonIdentifying); - } - private JobParameters merge(JobParameters parameters, JobParameters additionals) { Map> merged = new LinkedHashMap<>(); merged.putAll(parameters.getParameters()); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/cassandra/CassandraProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/cassandra/CassandraProperties.java index bd25ed980f89..f2f8cae7e39c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/cassandra/CassandraProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/cassandra/CassandraProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -482,7 +482,7 @@ public enum Compression { /** * No compression. */ - NONE; + NONE } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReport.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReport.java index 6d9e727e8b3c..3126167f106e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReport.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReport.java @@ -80,10 +80,7 @@ public void recordConditionEvaluation(String source, Condition condition, Condit Assert.notNull(condition, "Condition must not be null"); Assert.notNull(outcome, "Outcome must not be null"); this.unconditionalClasses.remove(source); - if (!this.outcomes.containsKey(source)) { - this.outcomes.put(source, new ConditionAndOutcomes()); - } - this.outcomes.get(source).add(condition, outcome); + this.outcomes.computeIfAbsent(source, (key) -> new ConditionAndOutcomes()).add(condition, outcome); this.addedAncestorOutcomes = false; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/Servlet5ClassPathOverrides.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestore.java similarity index 68% rename from spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/Servlet5ClassPathOverrides.java rename to spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestore.java index 0ee82323d9aa..2505d4930ffc 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/web/servlet/Servlet5ClassPathOverrides.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestore.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.testsupport.web.servlet; +package org.springframework.boot.autoconfigure.condition; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -22,19 +22,19 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.boot.testsupport.classpath.ClassPathExclusions; -import org.springframework.boot.testsupport.classpath.ClassPathOverrides; +import org.springframework.context.annotation.Conditional; /** - * Annotation to downgrade to Servlet 5.0. + * {@link Conditional @Conditional} that only matches when coordinated restore at + * checkpoint is to be used. * - * @author Phillip Webb + * @author Andy Wilkinson + * @since 3.2.0 */ -@Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) @Documented -@ClassPathExclusions("jakarta.servlet-api-6*.jar") -@ClassPathOverrides("jakarta.servlet:jakarta.servlet-api:5.0.0") -public @interface Servlet5ClassPathOverrides { +@ConditionalOnClass(name = "org.crac.Resource") +public @interface ConditionalOnCheckpointRestore { } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetrics.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreading.java similarity index 54% rename from spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetrics.java rename to spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreading.java index 44ec896236d4..18da6ceddbda 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetrics.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreading.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -14,32 +14,33 @@ * limitations under the License. */ -package org.springframework.boot.test.autoconfigure.actuate.metrics; +package org.springframework.boot.autoconfigure.condition; import java.lang.annotation.Documented; 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.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability; +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.context.annotation.Conditional; /** - * Annotation that can be applied to a test class to enable auto-configuration for metrics - * exporters. + * {@link Conditional @Conditional} that matches when the specified threading is active. * - * @author Chris Bono - * @since 2.4.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link AutoConfigureObservability @AutoConfigureObservability} + * @author Moritz Halbritter + * @since 3.2.0 */ -@Target(ElementType.TYPE) +@Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented -@Inherited -@Deprecated(since = "3.0.0", forRemoval = true) -@AutoConfigureObservability(tracing = false) -public @interface AutoConfigureMetrics { +@Conditional(OnThreadingCondition.class) +public @interface ConditionalOnThreading { + + /** + * The {@link Threading threading} that must be active. + * @return the expected threading + */ + Threading value(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnThreadingCondition.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnThreadingCondition.java new file mode 100644 index 000000000000..7856a63431a6 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnThreadingCondition.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-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.boot.autoconfigure.condition; + +import java.util.Map; + +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * {@link Condition} that checks for a required {@link Threading}. + * + * @author Moritz Halbritter + * @see ConditionalOnThreading + */ +class OnThreadingCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + Map attributes = metadata.getAnnotationAttributes(ConditionalOnThreading.class.getName()); + Threading threading = (Threading) attributes.get("value"); + return getMatchOutcome(context.getEnvironment(), threading); + } + + private ConditionOutcome getMatchOutcome(Environment environment, Threading threading) { + String name = threading.name(); + ConditionMessage.Builder message = ConditionMessage.forCondition(ConditionalOnThreading.class); + if (threading.isActive(environment)) { + return ConditionOutcome.match(message.foundExactly(name)); + } + return ConditionOutcome.noMatch(message.didNotFind(name).atAll()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfiguration.java index ed306db9882e..0ec92b6568b9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfiguration.java @@ -18,6 +18,8 @@ import java.time.Duration; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureOrder; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -26,6 +28,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.SearchStrategy; import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration.MessageSourceRuntimeHints; import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration.ResourceBundleCondition; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -33,6 +36,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ConditionContext; import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.ImportRuntimeHints; import org.springframework.context.support.AbstractApplicationContext; import org.springframework.context.support.ResourceBundleMessageSource; import org.springframework.core.Ordered; @@ -48,6 +52,7 @@ * @author Dave Syer * @author Phillip Webb * @author Eddú Meléndez + * @author Marc Becker * @since 1.5.0 */ @AutoConfiguration @@ -55,6 +60,7 @@ @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) @Conditional(ResourceBundleCondition.class) @EnableConfigurationProperties +@ImportRuntimeHints(MessageSourceRuntimeHints.class) public class MessageSourceAutoConfiguration { private static final Resource[] NO_RESOURCES = {}; @@ -125,4 +131,13 @@ private Resource[] getResources(ClassLoader classLoader, String name) { } + static class MessageSourceRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.resources().registerPattern("messages.properties").registerPattern("messages_*.properties"); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfiguration.java index 77a6dc070f37..5b72723e1cb1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,6 @@ package org.springframework.boot.autoconfigure.couchbase; -import java.io.InputStream; -import java.net.URL; -import java.security.KeyStore; - -import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.TrustManagerFactory; import com.couchbase.client.java.Cluster; @@ -52,7 +47,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.util.Assert; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** @@ -134,45 +128,17 @@ private void configureSsl(Builder builder, SslBundles sslBundles) { "SSL Options cannot be specified with Couchbase"); builder.securityConfig((config) -> { config.enableTls(true); - TrustManagerFactory trustManagerFactory = getTrustManagerFactory(sslProperties, sslBundle); + TrustManagerFactory trustManagerFactory = getTrustManagerFactory(sslBundle); if (trustManagerFactory != null) { config.trustManagerFactory(trustManagerFactory); } }); } - @SuppressWarnings("removal") - private TrustManagerFactory getTrustManagerFactory(CouchbaseProperties.Ssl sslProperties, SslBundle sslBundle) { - if (sslProperties.getKeyStore() != null) { - return loadTrustManagerFactory(sslProperties); - } + private TrustManagerFactory getTrustManagerFactory(SslBundle sslBundle) { return (sslBundle != null) ? sslBundle.getManagers().getTrustManagerFactory() : null; } - @SuppressWarnings("removal") - private TrustManagerFactory loadTrustManagerFactory(CouchbaseProperties.Ssl ssl) { - String resource = ssl.getKeyStore(); - try { - TrustManagerFactory trustManagerFactory = TrustManagerFactory - .getInstance(KeyManagerFactory.getDefaultAlgorithm()); - KeyStore keyStore = loadKeyStore(resource, ssl.getKeyStorePassword()); - trustManagerFactory.init(keyStore); - return trustManagerFactory; - } - catch (Exception ex) { - throw new IllegalStateException("Could not load Couchbase key store '" + resource + "'", ex); - } - } - - private KeyStore loadKeyStore(String resource, String keyStorePassword) throws Exception { - KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); - URL url = ResourceUtils.getURL(resource); - try (InputStream stream = url.openStream()) { - store.load(stream, (keyStorePassword != null) ? keyStorePassword.toCharArray() : null); - } - return store; - } - @Configuration(proxyBeanMethods = false) @ConditionalOnClass(ObjectMapper.class) static class JacksonConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java index 9b5d36215b1b..fbe2d5878eaa 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; import org.springframework.util.StringUtils; /** @@ -149,59 +148,24 @@ public void setIdleHttpConnectionTimeout(Duration idleHttpConnectionTimeout) { public static class Ssl { /** - * Whether to enable SSL support. Enabled automatically if a "keyStore" or - * "bundle" is provided unless specified otherwise. + * Whether to enable SSL support. Enabled automatically if a "bundle" is provided + * unless specified otherwise. */ private Boolean enabled; - /** - * Path to the JVM key store that holds the certificates. - */ - private String keyStore; - - /** - * Password used to access the key store. - */ - private String keyStorePassword; - /** * SSL bundle name. */ private String bundle; public Boolean getEnabled() { - return (this.enabled != null) ? this.enabled - : StringUtils.hasText(this.keyStore) || StringUtils.hasText(this.bundle); + return (this.enabled != null) ? this.enabled : StringUtils.hasText(this.bundle); } public void setEnabled(Boolean enabled) { this.enabled = enabled; } - @Deprecated(since = "3.1.0", forRemoval = true) - @DeprecatedConfigurationProperty( - reason = "SSL bundle support with spring.ssl.bundle and spring.couchbase.env.ssl.bundle should be used instead") - public String getKeyStore() { - return this.keyStore; - } - - @Deprecated(since = "3.1.0", forRemoval = true) - public void setKeyStore(String keyStore) { - this.keyStore = keyStore; - } - - @Deprecated(since = "3.1.0", forRemoval = true) - @DeprecatedConfigurationProperty( - reason = "SSL bundle support with spring.ssl.bundle and spring.couchbase.env.ssl.bundle should be used instead") - public String getKeyStorePassword() { - return this.keyStorePassword; - } - - @Deprecated(since = "3.1.0", forRemoval = true) - public void setKeyStorePassword(String keyStorePassword) { - this.keyStorePassword = keyStorePassword; - } - public String getBundle() { return this.bundle; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/couchbase/CouchbaseDataConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/couchbase/CouchbaseDataConfiguration.java index 20826c607ba9..1b408f876620 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/couchbase/CouchbaseDataConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/couchbase/CouchbaseDataConfiguration.java @@ -61,7 +61,7 @@ TranslationService couchbaseTranslationService() { @ConditionalOnMissingBean(name = BeanNames.COUCHBASE_MAPPING_CONTEXT) CouchbaseMappingContext couchbaseMappingContext(CouchbaseDataProperties properties, ApplicationContext applicationContext, CouchbaseCustomConversions couchbaseCustomConversions) - throws Exception { + throws ClassNotFoundException { CouchbaseMappingContext mappingContext = new CouchbaseMappingContext(); mappingContext.setInitialEntitySet(new EntityScanner(applicationContext).scan(Document.class)); mappingContext.setSimpleTypeHolder(couchbaseCustomConversions.getSimpleTypeHolder()); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java index 742577d68ab1..38cad56487c8 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/neo4j/Neo4jDataAutoConfiguration.java @@ -29,6 +29,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScanner; import org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration; import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration; import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; @@ -58,7 +59,8 @@ * @author Michael J. Simons * @since 1.4.0 */ -@AutoConfiguration(before = TransactionAutoConfiguration.class, after = Neo4jAutoConfiguration.class) +@AutoConfiguration(before = TransactionAutoConfiguration.class, + after = { Neo4jAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class }) @ConditionalOnClass({ Driver.class, Neo4jTransactionManager.class, PlatformTransactionManager.class }) @EnableConfigurationProperties(Neo4jDataProperties.class) @ConditionalOnBean(Driver.class) @@ -111,7 +113,7 @@ public Neo4jTemplate neo4jTemplate(Neo4jClient neo4jClient, Neo4jMappingContext public Neo4jTransactionManager transactionManager(Driver driver, DatabaseSelectionProvider databaseNameProvider, ObjectProvider optionalCustomizers) { Neo4jTransactionManager transactionManager = new Neo4jTransactionManager(driver, databaseNameProvider); - optionalCustomizers.ifAvailable((customizer) -> customizer.customize(transactionManager)); + optionalCustomizers.ifAvailable((customizer) -> customizer.customize((TransactionManager) transactionManager)); return transactionManager; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/JedisConnectionConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/JedisConnectionConfiguration.java index 80bcde71166a..c1c6fbb6e811 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/JedisConnectionConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/JedisConnectionConfiguration.java @@ -26,12 +26,15 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; +import org.springframework.boot.autoconfigure.thread.Threading; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.ssl.SslOptions; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.data.redis.connection.RedisClusterConfiguration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisSentinelConfiguration; @@ -69,11 +72,23 @@ class JedisConnectionConfiguration extends RedisConnectionConfiguration { } @Bean + @ConditionalOnThreading(Threading.PLATFORM) JedisConnectionFactory redisConnectionFactory( ObjectProvider builderCustomizers) { return createJedisConnectionFactory(builderCustomizers); } + @Bean + @ConditionalOnThreading(Threading.VIRTUAL) + JedisConnectionFactory redisConnectionFactoryVirtualThreads( + ObjectProvider builderCustomizers) { + JedisConnectionFactory factory = createJedisConnectionFactory(builderCustomizers); + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("redis-"); + executor.setVirtualThreads(true); + factory.setExecutor(executor); + return factory; + } + private JedisConnectionFactory createJedisConnectionFactory( ObjectProvider builderCustomizers) { JedisClientConfiguration clientConfiguration = getJedisClientConfiguration(builderCustomizers); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java index 9fbdcfabee0c..c987df24b8e7 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.java @@ -33,13 +33,16 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Lettuce.Cluster.Refresh; import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Pool; +import org.springframework.boot.autoconfigure.thread.Threading; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.ssl.SslOptions; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.data.redis.connection.RedisClusterConfiguration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisSentinelConfiguration; @@ -83,9 +86,29 @@ DefaultClientResources lettuceClientResources(ObjectProvider builderCustomizers, ClientResources clientResources) { + return createConnectionFactory(builderCustomizers, clientResources); + } + + @Bean + @ConditionalOnMissingBean(RedisConnectionFactory.class) + @ConditionalOnThreading(Threading.VIRTUAL) + LettuceConnectionFactory redisConnectionFactoryVirtualThreads( + ObjectProvider builderCustomizers, + ClientResources clientResources) { + LettuceConnectionFactory factory = createConnectionFactory(builderCustomizers, clientResources); + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("redis-"); + executor.setVirtualThreads(true); + factory.setExecutor(executor); + return factory; + } + + private LettuceConnectionFactory createConnectionFactory( + ObjectProvider builderCustomizers, + ClientResources clientResources) { LettuceClientConfiguration clientConfig = getLettuceClientConfiguration(builderCustomizers, clientResources, getProperties().getLettuce().getPool()); return createLettuceConnectionFactory(clientConfig); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientConfigurations.java index 28197797567b..de1fd52b0833 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientConfigurations.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchClientConfigurations.java @@ -22,7 +22,7 @@ import co.elastic.clients.json.jackson.JacksonJsonpMapper; import co.elastic.clients.json.jsonb.JsonbJsonpMapper; import co.elastic.clients.transport.ElasticsearchTransport; -import co.elastic.clients.transport.TransportOptions; +import co.elastic.clients.transport.rest_client.RestClientOptions; import co.elastic.clients.transport.rest_client.RestClientTransport; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.json.bind.Jsonb; @@ -90,8 +90,8 @@ static class ElasticsearchTransportConfiguration { @Bean RestClientTransport restClientTransport(RestClient restClient, JsonpMapper jsonMapper, - ObjectProvider transportOptions) { - return new RestClientTransport(restClient, jsonMapper, transportOptions.getIfAvailable()); + ObjectProvider restClientOptions) { + return new RestClientTransport(restClient, jsonMapper, restClientOptions.getIfAvailable()); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration.java index a9c4c49aa288..a7e93b42157c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration.java @@ -24,6 +24,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import javax.sql.DataSource; @@ -32,6 +34,9 @@ import org.flywaydb.core.api.callback.Callback; import org.flywaydb.core.api.configuration.FluentConfiguration; import org.flywaydb.core.api.migration.JavaMigration; +import org.flywaydb.core.extensibility.ConfigurationExtension; +import org.flywaydb.core.internal.database.postgresql.PostgreSQLConfigurationExtension; +import org.flywaydb.database.oracle.OracleConfigurationExtension; import org.flywaydb.database.sqlserver.SQLServerConfigurationExtension; import org.springframework.aot.hint.RuntimeHints; @@ -46,6 +51,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.FlywayAutoConfigurationRuntimeHints; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.FlywayDataSourceCondition; +import org.springframework.boot.autoconfigure.flyway.FlywayProperties.Oracle; +import org.springframework.boot.autoconfigure.flyway.FlywayProperties.Postgresql; +import org.springframework.boot.autoconfigure.flyway.FlywayProperties.Sqlserver; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; @@ -61,6 +69,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.ImportRuntimeHints; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.GenericConverter; import org.springframework.core.io.ResourceLoader; @@ -71,6 +81,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; +import org.springframework.util.function.SingletonSupplier; /** * {@link EnableAutoConfiguration Auto-configuration} for Flyway database migrations. @@ -116,6 +127,12 @@ public FlywaySchemaManagementProvider flywayDefaultDdlModeProvider(ObjectProvide @EnableConfigurationProperties(FlywayProperties.class) public static class FlywayConfiguration { + private final FlywayProperties properties; + + FlywayConfiguration(FlywayProperties properties) { + this.properties = properties; + } + @Bean ResourceProviderCustomizer resourceProviderCustomizer() { return new ResourceProviderCustomizer(); @@ -123,31 +140,38 @@ ResourceProviderCustomizer resourceProviderCustomizer() { @Bean @ConditionalOnMissingBean(FlywayConnectionDetails.class) - PropertiesFlywayConnectionDetails flywayConnectionDetails(FlywayProperties properties) { - return new PropertiesFlywayConnectionDetails(properties); + PropertiesFlywayConnectionDetails flywayConnectionDetails() { + return new PropertiesFlywayConnectionDetails(this.properties); } - @Deprecated(since = "3.0.0", forRemoval = true) - public Flyway flyway(FlywayProperties properties, ResourceLoader resourceLoader, - ObjectProvider dataSource, ObjectProvider flywayDataSource, - ObjectProvider fluentConfigurationCustomizers, - ObjectProvider javaMigrations, ObjectProvider callbacks) { - return flyway(properties, new PropertiesFlywayConnectionDetails(properties), resourceLoader, dataSource, - flywayDataSource, fluentConfigurationCustomizers, javaMigrations, callbacks, - new ResourceProviderCustomizer()); + @Bean + @ConditionalOnClass(name = "org.flywaydb.database.sqlserver.SQLServerConfigurationExtension") + SqlServerFlywayConfigurationCustomizer sqlServerFlywayConfigurationCustomizer() { + return new SqlServerFlywayConfigurationCustomizer(this.properties); + } + + @Bean + @ConditionalOnClass(name = "org.flywaydb.database.oracle.OracleConfigurationExtension") + OracleFlywayConfigurationCustomizer oracleFlywayConfigurationCustomizer() { + return new OracleFlywayConfigurationCustomizer(this.properties); + } + + @Bean + @ConditionalOnClass(name = "org.flywaydb.core.internal.database.postgresql.PostgreSQLConfigurationExtension") + PostgresqlFlywayConfigurationCustomizer postgresqlFlywayConfigurationCustomizer() { + return new PostgresqlFlywayConfigurationCustomizer(this.properties); } @Bean - Flyway flyway(FlywayProperties properties, FlywayConnectionDetails connectionDetails, - ResourceLoader resourceLoader, ObjectProvider dataSource, - @FlywayDataSource ObjectProvider flywayDataSource, + Flyway flyway(FlywayConnectionDetails connectionDetails, ResourceLoader resourceLoader, + ObjectProvider dataSource, @FlywayDataSource ObjectProvider flywayDataSource, ObjectProvider fluentConfigurationCustomizers, ObjectProvider javaMigrations, ObjectProvider callbacks, ResourceProviderCustomizer resourceProviderCustomizer) { FluentConfiguration configuration = new FluentConfiguration(resourceLoader.getClassLoader()); configureDataSource(configuration, flywayDataSource.getIfAvailable(), dataSource.getIfUnique(), connectionDetails); - configureProperties(configuration, properties); + configureProperties(configuration, this.properties); configureCallbacks(configuration, callbacks.orderedStream().toList()); configureJavaMigrations(configuration, javaMigrations.orderedStream().toList()); fluentConfigurationCustomizers.orderedStream().forEach((customizer) -> customizer.customize(configuration)); @@ -282,11 +306,6 @@ private void configureProperties(FluentConfiguration configuration, FlywayProper map.from(properties.getErrorOverrides()) .to((errorOverrides) -> configuration.errorOverrides(errorOverrides)); map.from(properties.getLicenseKey()).to((licenseKey) -> configuration.licenseKey(licenseKey)); - map.from(properties.getOracleSqlplus()).to((oracleSqlplus) -> configuration.oracleSqlplus(oracleSqlplus)); - map.from(properties.getOracleSqlplusWarn()) - .to((oracleSqlplusWarn) -> configuration.oracleSqlplusWarn(oracleSqlplusWarn)); - map.from(properties.getOracleKerberosCacheFile()) - .to((oracleKerberosCacheFile) -> configuration.oracleKerberosCacheFile(oracleKerberosCacheFile)); map.from(properties.getStream()).to((stream) -> configuration.stream(stream)); map.from(properties.getUndoSqlMigrationPrefix()) .to((undoSqlMigrationPrefix) -> configuration.undoSqlMigrationPrefix(undoSqlMigrationPrefix)); @@ -298,10 +317,6 @@ private void configureProperties(FluentConfiguration configuration, FlywayProper .to((configFile) -> configuration.kerberosConfigFile(configFile)); map.from(properties.getOutputQueryResults()) .to((outputQueryResults) -> configuration.outputQueryResults(outputQueryResults)); - map.from(properties.getSqlServerKerberosLoginFile()) - .whenNonNull() - .to((sqlServerKerberosLoginFile) -> configureSqlServerKerberosLoginFile(configuration, - sqlServerKerberosLoginFile)); map.from(properties.getSkipExecutingMigrations()) .to((skipExecutingMigrations) -> configuration.skipExecutingMigrations(skipExecutingMigrations)); map.from(properties.getIgnoreMigrationPatterns()) @@ -322,14 +337,6 @@ private void configureExecuteInTransaction(FluentConfiguration configuration, Fl } } - private void configureSqlServerKerberosLoginFile(FluentConfiguration configuration, - String sqlServerKerberosLoginFile) { - SQLServerConfigurationExtension sqlServerConfigurationExtension = configuration.getPluginRegister() - .getPlugin(SQLServerConfigurationExtension.class); - Assert.state(sqlServerConfigurationExtension != null, "Flyway SQL Server extension missing"); - sqlServerConfigurationExtension.setKerberosLoginFile(sqlServerKerberosLoginFile); - } - private void configureCallbacks(FluentConfiguration configuration, List callbacks) { if (!callbacks.isEmpty()) { configuration.callbacks(callbacks.toArray(new Callback[0])); @@ -491,4 +498,98 @@ public String getDriverClassName() { } + @Order(Ordered.HIGHEST_PRECEDENCE) + static final class OracleFlywayConfigurationCustomizer implements FlywayConfigurationCustomizer { + + private final FlywayProperties properties; + + OracleFlywayConfigurationCustomizer(FlywayProperties properties) { + this.properties = properties; + } + + @Override + public void customize(FluentConfiguration configuration) { + Extension extension = new Extension<>(configuration, + OracleConfigurationExtension.class, "Oracle"); + Oracle properties = this.properties.getOracle(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getSqlplus).to(extension.via((ext, sqlplus) -> ext.setSqlplus(sqlplus))); + map.from(properties::getSqlplusWarn) + .to(extension.via((ext, sqlplusWarn) -> ext.setSqlplusWarn(sqlplusWarn))); + map.from(properties::getWalletLocation) + .to(extension.via((ext, walletLocation) -> ext.setWalletLocation(walletLocation))); + map.from(properties::getKerberosCacheFile) + .to(extension.via((ext, kerberosCacheFile) -> ext.setKerberosCacheFile(kerberosCacheFile))); + } + + } + + @Order(Ordered.HIGHEST_PRECEDENCE) + static final class PostgresqlFlywayConfigurationCustomizer implements FlywayConfigurationCustomizer { + + private final FlywayProperties properties; + + PostgresqlFlywayConfigurationCustomizer(FlywayProperties properties) { + this.properties = properties; + } + + @Override + public void customize(FluentConfiguration configuration) { + Extension extension = new Extension<>(configuration, + PostgreSQLConfigurationExtension.class, "PostgreSQL"); + Postgresql properties = this.properties.getPostgresql(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getTransactionalLock) + .to(extension.via((ext, transactionalLock) -> ext.setTransactionalLock(transactionalLock))); + } + + } + + @Order(Ordered.HIGHEST_PRECEDENCE) + static final class SqlServerFlywayConfigurationCustomizer implements FlywayConfigurationCustomizer { + + private final FlywayProperties properties; + + SqlServerFlywayConfigurationCustomizer(FlywayProperties properties) { + this.properties = properties; + } + + @Override + public void customize(FluentConfiguration configuration) { + Extension extension = new Extension<>(configuration, + SQLServerConfigurationExtension.class, "SQL Server"); + Sqlserver properties = this.properties.getSqlserver(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getKerberosLoginFile).to(extension.via(this::setKerberosLoginFile)); + } + + private void setKerberosLoginFile(SQLServerConfigurationExtension configuration, String file) { + configuration.getKerberos().getLogin().setFile(file); + } + + } + + /** + * Helper class used to map properties to a {@link ConfigurationExtension}. + * + * @param the extension type + */ + static class Extension { + + private SingletonSupplier extension; + + Extension(FluentConfiguration configuration, Class type, String name) { + this.extension = SingletonSupplier.of(() -> { + E extension = configuration.getPluginRegister().getPlugin(type); + Assert.notNull(extension, () -> "Flyway %s extension missing".formatted(name)); + return extension; + }); + } + + Consumer via(BiConsumer action) { + return (value) -> action.accept(this.extension.get(), value); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayProperties.java index b629e7c425f8..4a6d7db6e0ca 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/FlywayProperties.java @@ -28,6 +28,7 @@ import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; import org.springframework.boot.convert.DurationUnit; /** @@ -295,17 +296,6 @@ public class FlywayProperties { */ private String licenseKey; - /** - * Whether to enable support for Oracle SQL*Plus commands. Requires Flyway Teams. - */ - private Boolean oracleSqlplus; - - /** - * Whether to issue a warning rather than an error when a not-yet-supported Oracle - * SQL*Plus statement is encountered. Requires Flyway Teams. - */ - private Boolean oracleSqlplusWarn; - /** * Whether to stream SQL migrations when executing them. Requires Flyway Teams. */ @@ -332,28 +322,12 @@ public class FlywayProperties { */ private String kerberosConfigFile; - /** - * Path of the Oracle Kerberos cache file. Requires Flyway Teams. - */ - private String oracleKerberosCacheFile; - - /** - * Location of the Oracle Wallet, used to sign in to the database automatically. - * Requires Flyway Teams. - */ - private String oracleWalletLocation; - /** * Whether Flyway should output a table with the results of queries when executing * migrations. Requires Flyway Teams. */ private Boolean outputQueryResults; - /** - * Path to the SQL Server Kerberos login file. Requires Flyway Teams. - */ - private String sqlServerKerberosLoginFile; - /** * Whether Flyway should skip executing the contents of the migrations and only update * the schema history table. Requires Flyway teams. @@ -372,6 +346,12 @@ public class FlywayProperties { */ private Boolean detectEncoding; + private final Oracle oracle = new Oracle(); + + private final Postgresql postgresql = new Postgresql(); + + private final Sqlserver sqlserver = new Sqlserver(); + public boolean isEnabled() { return this.enabled; } @@ -756,28 +736,37 @@ public void setLicenseKey(String licenseKey) { this.licenseKey = licenseKey; } + @DeprecatedConfigurationProperty(replacement = "spring.flyway.oracle.sqlplus", since = "3.2.0") + @Deprecated(since = "3.2.0", forRemoval = true) public Boolean getOracleSqlplus() { - return this.oracleSqlplus; + return getOracle().getSqlplus(); } + @Deprecated(since = "3.2.0", forRemoval = true) public void setOracleSqlplus(Boolean oracleSqlplus) { - this.oracleSqlplus = oracleSqlplus; + getOracle().setSqlplus(oracleSqlplus); } + @DeprecatedConfigurationProperty(replacement = "spring.flyway.oracle.sqlplus-warn", since = "3.2.0") + @Deprecated(since = "3.2.0", forRemoval = true) public Boolean getOracleSqlplusWarn() { - return this.oracleSqlplusWarn; + return getOracle().getSqlplusWarn(); } + @Deprecated(since = "3.2.0", forRemoval = true) public void setOracleSqlplusWarn(Boolean oracleSqlplusWarn) { - this.oracleSqlplusWarn = oracleSqlplusWarn; + getOracle().setSqlplusWarn(oracleSqlplusWarn); } + @DeprecatedConfigurationProperty(replacement = "spring.flyway.oracle.wallet-location", since = "3.2.0") + @Deprecated(since = "3.2.0", forRemoval = true) public String getOracleWalletLocation() { - return this.oracleWalletLocation; + return getOracle().getWalletLocation(); } + @Deprecated(since = "3.2.0", forRemoval = true) public void setOracleWalletLocation(String oracleWalletLocation) { - this.oracleWalletLocation = oracleWalletLocation; + getOracle().setWalletLocation(oracleWalletLocation); } public Boolean getStream() { @@ -820,12 +809,15 @@ public void setKerberosConfigFile(String kerberosConfigFile) { this.kerberosConfigFile = kerberosConfigFile; } + @DeprecatedConfigurationProperty(replacement = "spring.flyway.oracle.kerberos-cache-file", since = "3.2.0") + @Deprecated(since = "3.2.0", forRemoval = true) public String getOracleKerberosCacheFile() { - return this.oracleKerberosCacheFile; + return getOracle().getKerberosCacheFile(); } + @Deprecated(since = "3.2.0", forRemoval = true) public void setOracleKerberosCacheFile(String oracleKerberosCacheFile) { - this.oracleKerberosCacheFile = oracleKerberosCacheFile; + getOracle().setKerberosCacheFile(oracleKerberosCacheFile); } public Boolean getOutputQueryResults() { @@ -836,12 +828,15 @@ public void setOutputQueryResults(Boolean outputQueryResults) { this.outputQueryResults = outputQueryResults; } + @DeprecatedConfigurationProperty(replacement = "spring.flyway.sqlserver.kerberos-login-file") + @Deprecated(since = "3.2.0", forRemoval = true) public String getSqlServerKerberosLoginFile() { - return this.sqlServerKerberosLoginFile; + return getSqlserver().getKerberosLoginFile(); } + @Deprecated(since = "3.2.0", forRemoval = true) public void setSqlServerKerberosLoginFile(String sqlServerKerberosLoginFile) { - this.sqlServerKerberosLoginFile = sqlServerKerberosLoginFile; + getSqlserver().setKerberosLoginFile(sqlServerKerberosLoginFile); } public Boolean getSkipExecutingMigrations() { @@ -868,4 +863,118 @@ public void setDetectEncoding(final Boolean detectEncoding) { this.detectEncoding = detectEncoding; } + public Oracle getOracle() { + return this.oracle; + } + + public Postgresql getPostgresql() { + return this.postgresql; + } + + public Sqlserver getSqlserver() { + return this.sqlserver; + } + + /** + * {@code OracleConfigurationExtension} properties. + */ + public static class Oracle { + + /** + * Whether to enable support for Oracle SQL*Plus commands. Requires Flyway Teams. + */ + private Boolean sqlplus; + + /** + * Whether to issue a warning rather than an error when a not-yet-supported Oracle + * SQL*Plus statement is encountered. Requires Flyway Teams. + */ + private Boolean sqlplusWarn; + + /** + * Path of the Oracle Kerberos cache file. Requires Flyway Teams. + */ + private String kerberosCacheFile; + + /** + * Location of the Oracle Wallet, used to sign in to the database automatically. + * Requires Flyway Teams. + */ + private String walletLocation; + + public Boolean getSqlplus() { + return this.sqlplus; + } + + public void setSqlplus(Boolean sqlplus) { + this.sqlplus = sqlplus; + } + + public Boolean getSqlplusWarn() { + return this.sqlplusWarn; + } + + public void setSqlplusWarn(Boolean sqlplusWarn) { + this.sqlplusWarn = sqlplusWarn; + } + + public String getKerberosCacheFile() { + return this.kerberosCacheFile; + } + + public void setKerberosCacheFile(String kerberosCacheFile) { + this.kerberosCacheFile = kerberosCacheFile; + } + + public String getWalletLocation() { + return this.walletLocation; + } + + public void setWalletLocation(String walletLocation) { + this.walletLocation = walletLocation; + } + + } + + /** + * {@code PostgreSQLConfigurationExtension} properties. + */ + public static class Postgresql { + + /** + * Whether transactional advisory locks should be used. If set to false, + * session-level locks are used instead. + */ + private Boolean transactionalLock; + + public Boolean getTransactionalLock() { + return this.transactionalLock; + } + + public void setTransactionalLock(Boolean transactionalLock) { + this.transactionalLock = transactionalLock; + } + + } + + /** + * {@code SQLServerConfigurationExtension} properties. + */ + public static class Sqlserver { + + /** + * Path to the SQL Server Kerberos login file. Requires Flyway Teams. + */ + private String kerberosLoginFile; + + public String getKerberosLoginFile() { + return this.kerberosLoginFile; + } + + public void setKerberosLoginFile(String kerberosLoginFile) { + this.kerberosLoginFile = kerberosLoginFile; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/ResourceProviderCustomizerBeanRegistrationAotProcessor.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/ResourceProviderCustomizerBeanRegistrationAotProcessor.java index 919cfd3f4e68..6bdcccecd377 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/ResourceProviderCustomizerBeanRegistrationAotProcessor.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/flyway/ResourceProviderCustomizerBeanRegistrationAotProcessor.java @@ -16,8 +16,6 @@ package org.springframework.boot.autoconfigure.flyway; -import java.lang.reflect.Executable; - import javax.lang.model.element.Modifier; import org.springframework.aot.generate.GeneratedMethod; @@ -58,8 +56,7 @@ protected AotContribution(BeanRegistrationCodeFragments delegate, RegisteredBean @Override public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext, - BeanRegistrationCode beanRegistrationCode, Executable constructorOrFactoryMethod, - boolean allowDirectSupplierShortcut) { + BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) { GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> { method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName()); method.addModifiers(Modifier.PRIVATE, Modifier.STATIC); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/freemarker/FreeMarkerAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/freemarker/FreeMarkerAutoConfiguration.java index 254d8d7d4a2b..79331b21dd4f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/freemarker/FreeMarkerAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/freemarker/FreeMarkerAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,8 +62,9 @@ public void checkTemplateLocationExists() { if (logger.isWarnEnabled() && this.properties.isCheckTemplateLocation()) { List locations = getLocations(); if (locations.stream().noneMatch(this::locationExists)) { - logger.warn("Cannot find template location(s): " + locations + " (please add some templates, " - + "check your FreeMarker configuration, or set " + String suffix = (locations.size() == 1) ? "" : "s"; + logger.warn("Cannot find template location" + suffix + ": " + locations + + " (please add some templates, " + "check your FreeMarker configuration, or set " + "spring.freemarker.check-template-location=false)"); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java index 37a2b97c409b..85d5c5e9ddb0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.Executor; import graphql.GraphQL; import graphql.execution.instrumentation.Instrumentation; @@ -33,10 +34,12 @@ import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.context.annotation.Bean; @@ -63,6 +66,7 @@ import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.graphql.execution.SubscriptionExceptionResolver; +import org.springframework.graphql.execution.TypeDefinitionConfigurer; /** * {@link EnableAutoConfiguration Auto-configuration} for creating a Spring GraphQL base @@ -92,7 +96,8 @@ public GraphQlSource graphQlSource(ResourcePatternResolver resourcePatternResolv ObjectProvider exceptionResolvers, ObjectProvider subscriptionExceptionResolvers, ObjectProvider instrumentations, ObjectProvider wiringConfigurers, - ObjectProvider sourceCustomizers) { + ObjectProvider sourceCustomizers, + ObjectProvider typeDefinitionConfigurers) { String[] schemaLocations = properties.getSchema().getLocations(); Resource[] schemaResources = resolveSchemaResources(resourcePatternResolver, schemaLocations, properties.getSchema().getFileExtensions()); @@ -101,9 +106,13 @@ public GraphQlSource graphQlSource(ResourcePatternResolver resourcePatternResolv .exceptionResolvers(exceptionResolvers.orderedStream().toList()) .subscriptionExceptionResolvers(subscriptionExceptionResolvers.orderedStream().toList()) .instrumentation(instrumentations.orderedStream().toList()); + if (properties.getSchema().getInspection().isEnabled()) { + builder.inspectSchemaMappings(logger::info); + } if (!properties.getSchema().getIntrospection().isEnabled()) { builder.configureRuntimeWiring(this::enableIntrospection); } + typeDefinitionConfigurers.forEach(builder::configureTypeDefinitions); builder.configureTypeDefinitions(new ConnectionTypeDefinitionConfigurer()); wiringConfigurers.orderedStream().forEach(builder::configureRuntimeWiring); sourceCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); @@ -152,10 +161,12 @@ public ExecutionGraphQlService executionGraphQlService(GraphQlSource graphQlSour @Bean @ConditionalOnMissingBean - public AnnotatedControllerConfigurer annotatedControllerConfigurer() { + public AnnotatedControllerConfigurer annotatedControllerConfigurer( + @Qualifier(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) ObjectProvider executorProvider) { AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer(); controllerConfigurer .addFormatterRegistrar((registry) -> ApplicationConversionService.addBeans(registry, this.beanFactory)); + executorProvider.ifAvailable(controllerConfigurer::setExecutor); return controllerConfigurer; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java index 13bb71c85f71..046155b1aa4f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java @@ -79,6 +79,8 @@ public static class Schema { */ private String[] fileExtensions = new String[] { ".graphqls", ".gqls" }; + private final Inspection inspection = new Inspection(); + private final Introspection introspection = new Introspection(); private final Printer printer = new Printer(); @@ -105,6 +107,10 @@ private String[] appendSlashIfNecessary(String[] locations) { .toArray(String[]::new); } + public Inspection getInspection() { + return this.inspection; + } + public Introspection getIntrospection() { return this.introspection; } @@ -113,6 +119,24 @@ public Printer getPrinter() { return this.printer; } + public static class Inspection { + + /** + * Whether schema should be compared to the application to detect missing + * mappings. + */ + private boolean enabled = true; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + } + public static class Introspection { /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java index 52df8e11cb43..42d79c0f31c9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.graphql.data; +import java.util.Collections; import java.util.List; import graphql.GraphQL; @@ -29,9 +30,9 @@ import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.data.repository.query.QueryByExampleExecutor; -import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; import org.springframework.graphql.data.query.QueryByExampleDataFetcher; import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; /** * {@link EnableAutoConfiguration Auto-configuration} that creates a @@ -49,10 +50,10 @@ public class GraphQlQueryByExampleAutoConfiguration { @Bean - public GraphQlSourceBuilderCustomizer queryByExampleRegistrar(ObjectProvider> executors, - ObjectProvider> reactiveExecutors) { - return new GraphQlQuerydslSourceBuilderCustomizer<>(QueryByExampleDataFetcher::autoRegistrationConfigurer, - executors, reactiveExecutors); + public GraphQlSourceBuilderCustomizer queryByExampleRegistrar(ObjectProvider> executors) { + RuntimeWiringConfigurer configurer = QueryByExampleDataFetcher + .autoRegistrationConfigurer(executors.orderedStream().toList(), Collections.emptyList()); + return (builder) -> builder.configureRuntimeWiring(configurer); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java index c32b21b7811f..97e2debd0a4b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,8 +16,10 @@ package org.springframework.boot.autoconfigure.graphql.data; +import java.util.Collections; import java.util.List; +import com.querydsl.core.Query; import graphql.GraphQL; import org.springframework.beans.factory.ObjectProvider; @@ -29,9 +31,9 @@ import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.data.querydsl.QuerydslPredicateExecutor; -import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor; import org.springframework.graphql.data.query.QuerydslDataFetcher; import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; /** * {@link EnableAutoConfiguration Auto-configuration} that creates a @@ -45,15 +47,15 @@ * @see QuerydslDataFetcher#autoRegistrationConfigurer(List, List) */ @AutoConfiguration(after = GraphQlAutoConfiguration.class) -@ConditionalOnClass({ GraphQL.class, QuerydslDataFetcher.class, QuerydslPredicateExecutor.class }) +@ConditionalOnClass({ GraphQL.class, Query.class, QuerydslDataFetcher.class, QuerydslPredicateExecutor.class }) @ConditionalOnBean(GraphQlSource.class) public class GraphQlQuerydslAutoConfiguration { @Bean - public GraphQlSourceBuilderCustomizer querydslRegistrar(ObjectProvider> executors, - ObjectProvider> reactiveExecutors) { - return new GraphQlQuerydslSourceBuilderCustomizer<>(QuerydslDataFetcher::autoRegistrationConfigurer, executors, - reactiveExecutors); + public GraphQlSourceBuilderCustomizer querydslRegistrar(ObjectProvider> executors) { + RuntimeWiringConfigurer configurer = QuerydslDataFetcher + .autoRegistrationConfigurer(executors.orderedStream().toList(), Collections.emptyList()); + return (builder) -> builder.configureRuntimeWiring(configurer); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java deleted file mode 100644 index ae0db51b20ab..000000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2012-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.boot.autoconfigure.graphql.data; - -import java.util.Collections; -import java.util.List; -import java.util.function.BiFunction; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; -import org.springframework.graphql.execution.GraphQlSource; -import org.springframework.graphql.execution.RuntimeWiringConfigurer; - -/** - * {@link GraphQlSourceBuilderCustomizer} to apply auto-configured QueryDSL - * {@link RuntimeWiringConfigurer RuntimeWiringConfigurers}. - * - * @param the executor type - * @param the reactive executor type - * @author Phillip Webb - * @author Rossen Stoyanchev - * @author Brian Clozel - */ -class GraphQlQuerydslSourceBuilderCustomizer implements GraphQlSourceBuilderCustomizer { - - private final BiFunction, List, RuntimeWiringConfigurer> wiringConfigurerFactory; - - private final List executors; - - private final List reactiveExecutors; - - GraphQlQuerydslSourceBuilderCustomizer( - BiFunction, List, RuntimeWiringConfigurer> wiringConfigurerFactory, ObjectProvider executors, - ObjectProvider reactiveExecutors) { - this.wiringConfigurerFactory = wiringConfigurerFactory; - this.executors = asList(executors); - this.reactiveExecutors = asList(reactiveExecutors); - } - - private static List asList(ObjectProvider provider) { - return (provider != null) ? provider.orderedStream().toList() : Collections.emptyList(); - } - - @Override - public void customize(GraphQlSource.SchemaResourceBuilder builder) { - if (!this.executors.isEmpty() || !this.reactiveExecutors.isEmpty()) { - builder.configureRuntimeWiring(this.wiringConfigurerFactory.apply(this.executors, this.reactiveExecutors)); - } - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java index f28b17801ef1..6e784108e9da 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.graphql.data; +import java.util.Collections; import java.util.List; import graphql.GraphQL; @@ -28,10 +29,10 @@ import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; import org.springframework.context.annotation.Bean; -import org.springframework.data.repository.query.QueryByExampleExecutor; import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; import org.springframework.graphql.data.query.QueryByExampleDataFetcher; import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; /** * {@link EnableAutoConfiguration Auto-configuration} that creates a @@ -51,8 +52,9 @@ public class GraphQlReactiveQueryByExampleAutoConfiguration { @Bean public GraphQlSourceBuilderCustomizer reactiveQueryByExampleRegistrar( ObjectProvider> reactiveExecutors) { - return new GraphQlQuerydslSourceBuilderCustomizer<>(QueryByExampleDataFetcher::autoRegistrationConfigurer, - (ObjectProvider>) null, reactiveExecutors); + RuntimeWiringConfigurer configurer = QueryByExampleDataFetcher + .autoRegistrationConfigurer(Collections.emptyList(), reactiveExecutors.orderedStream().toList()); + return (builder) -> builder.configureRuntimeWiring(configurer); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java index f12be0563ac7..14b81dcc7108 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,8 +16,10 @@ package org.springframework.boot.autoconfigure.graphql.data; +import java.util.Collections; import java.util.List; +import com.querydsl.core.Query; import graphql.GraphQL; import org.springframework.beans.factory.ObjectProvider; @@ -28,10 +30,10 @@ import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; import org.springframework.context.annotation.Bean; -import org.springframework.data.querydsl.QuerydslPredicateExecutor; import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor; import org.springframework.graphql.data.query.QuerydslDataFetcher; import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; /** * {@link EnableAutoConfiguration Auto-configuration} that creates a @@ -45,15 +47,16 @@ * @see QuerydslDataFetcher#autoRegistrationConfigurer(List, List) */ @AutoConfiguration(after = GraphQlAutoConfiguration.class) -@ConditionalOnClass({ GraphQL.class, QuerydslDataFetcher.class, ReactiveQuerydslPredicateExecutor.class }) +@ConditionalOnClass({ GraphQL.class, Query.class, QuerydslDataFetcher.class, ReactiveQuerydslPredicateExecutor.class }) @ConditionalOnBean(GraphQlSource.class) public class GraphQlReactiveQuerydslAutoConfiguration { @Bean public GraphQlSourceBuilderCustomizer reactiveQuerydslRegistrar( ObjectProvider> reactiveExecutors) { - return new GraphQlQuerydslSourceBuilderCustomizer<>(QuerydslDataFetcher::autoRegistrationConfigurer, - (ObjectProvider>) null, reactiveExecutors); + RuntimeWiringConfigurer configurer = QuerydslDataFetcher.autoRegistrationConfigurer(Collections.emptyList(), + reactiveExecutors.orderedStream().toList()); + return (builder) -> builder.configureRuntimeWiring(configurer); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java index 29d0c53c248f..4c82ba3b5126 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -195,7 +195,7 @@ public HandlerMapping graphQlWebSocketMapping(GraphQlWebSocketHandler handler, G mapping.setWebSocketUpgradeMatch(true); mapping.setUrlMap(Collections.singletonMap(path, handler.initWebSocketHttpRequestHandler(new DefaultHandshakeHandler()))); - mapping.setOrder(2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean) + mapping.setOrder(-2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean) return mapping; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfiguration.java index 5641d5182184..904541755a4b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfiguration.java @@ -39,11 +39,16 @@ * @author Andy Wilkinson * @author Phillip Webb * @since 2.0.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the + * new client and its own + * Spring Boot integration. */ @AutoConfiguration @ConditionalOnClass(InfluxDB.class) @EnableConfigurationProperties(InfluxDbProperties.class) @ConditionalOnProperty("spring.influx.url") +@Deprecated(since = "3.2.0", forRemoval = true) +@SuppressWarnings("removal") public class InfluxDbAutoConfiguration { @Bean diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbCustomizer.java index 9e46dd17fa3e..62f9b0df9998 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -24,8 +24,12 @@ * * @author Eddú Meléndez * @since 2.5.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the + * new client and its own + * Spring Boot integration. */ @FunctionalInterface +@Deprecated(since = "3.2.0", forRemoval = true) public interface InfluxDbCustomizer { /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbOkHttpClientBuilderProvider.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbOkHttpClientBuilderProvider.java index 67dc383089de..14995dba425f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbOkHttpClientBuilderProvider.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbOkHttpClientBuilderProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -27,8 +27,12 @@ * * @author Stephane Nicoll * @since 2.1.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the + * new client and its own + * Spring Boot integration. */ @FunctionalInterface +@Deprecated(since = "3.2.0", forRemoval = true) public interface InfluxDbOkHttpClientBuilderProvider extends Supplier { } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbProperties.java index d8a4c07d5b68..145c490c2762 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/influx/InfluxDbProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -17,6 +17,7 @@ package org.springframework.boot.autoconfigure.influx; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; /** * Configuration properties for InfluxDB. @@ -24,7 +25,11 @@ * @author Sergey Kuptsov * @author Stephane Nicoll * @since 2.0.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of the + * new InfluxDB Java + * client and its own Spring Boot integration. */ +@Deprecated(since = "3.2.0", forRemoval = true) @ConfigurationProperties(prefix = "spring.influx") public class InfluxDbProperties { @@ -43,6 +48,8 @@ public class InfluxDbProperties { */ private String password; + @DeprecatedConfigurationProperty(reason = "the new InfluxDb Java client provides Spring Boot integration", + since = "3.2.0") public String getUrl() { return this.url; } @@ -51,6 +58,8 @@ public void setUrl(String url) { this.url = url; } + @DeprecatedConfigurationProperty(reason = "the new InfluxDb Java client provides Spring Boot integration", + since = "3.2.0") public String getUser() { return this.user; } @@ -59,6 +68,8 @@ public void setUser(String user) { this.user = user; } + @DeprecatedConfigurationProperty(reason = "the new InfluxDb Java client provides Spring Boot integration", + since = "3.2.0") public String getPassword() { return this.password; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/integration/IntegrationAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/integration/IntegrationAutoConfiguration.java index 9ca2706b611e..71f20ef9e7a2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/integration/IntegrationAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/integration/IntegrationAutoConfiguration.java @@ -24,6 +24,7 @@ import io.rsocket.transport.netty.server.TcpServerTransport; import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; @@ -43,6 +44,7 @@ import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; import org.springframework.boot.task.TaskSchedulerBuilder; +import org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -168,11 +170,18 @@ private Trigger createPeriodicTrigger(Duration period, Duration initialDelay, bo @Configuration(proxyBeanMethods = false) @ConditionalOnBean(TaskSchedulerBuilder.class) @ConditionalOnMissingBean(name = IntegrationContextUtils.TASK_SCHEDULER_BEAN_NAME) + @SuppressWarnings("removal") protected static class IntegrationTaskSchedulerConfiguration { @Bean(name = IntegrationContextUtils.TASK_SCHEDULER_BEAN_NAME) - public ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder builder) { - return builder.build(); + public ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder taskSchedulerBuilder, + ObjectProvider threadPoolTaskSchedulerBuilderProvider) { + ThreadPoolTaskSchedulerBuilder threadPoolTaskSchedulerBuilder = threadPoolTaskSchedulerBuilderProvider + .getIfUnique(); + if (threadPoolTaskSchedulerBuilder != null) { + return threadPoolTaskSchedulerBuilder.build(); + } + return taskSchedulerBuilder.build(); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java index a6aa97773ed3..5410786e4385 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfiguration.java @@ -213,6 +213,8 @@ public void customize(Jackson2ObjectMapperBuilder builder) { configureFeatures(builder, this.jacksonProperties.getMapper()); configureFeatures(builder, this.jacksonProperties.getParser()); configureFeatures(builder, this.jacksonProperties.getGenerator()); + configureFeatures(builder, this.jacksonProperties.getDatatype().getEnum()); + configureFeatures(builder, this.jacksonProperties.getDatatype().getJsonNode()); configureDateFormat(builder); configurePropertyNamingStrategy(builder); configureModules(builder); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java index 805604e98308..46162768d4f5 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jackson/JacksonProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.cfg.EnumFeature; +import com.fasterxml.jackson.databind.cfg.JsonNodeFeature; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -38,6 +40,7 @@ * @author Andy Wilkinson * @author Marcel Overdijk * @author Johannes Edmeier + * @author Eddú Meléndez * @since 1.2.0 */ @ConfigurationProperties(prefix = "spring.jackson") @@ -114,6 +117,8 @@ public class JacksonProperties { */ private Locale locale; + private final Datatype datatype = new Datatype(); + public String getDateFormat() { return this.dateFormat; } @@ -194,6 +199,10 @@ public void setLocale(Locale locale) { this.locale = locale; } + public Datatype getDatatype() { + return this.datatype; + } + public enum ConstructorDetectorStrategy { /** @@ -215,7 +224,29 @@ public enum ConstructorDetectorStrategy { * Refuse to decide implicit mode and instead throw an InvalidDefinitionException * for ambiguous cases. */ - EXPLICIT_ONLY; + EXPLICIT_ONLY + + } + + public static class Datatype { + + /** + * Jackson on/off features for enums. + */ + private final Map enumFeatures = new EnumMap<>(EnumFeature.class); + + /** + * Jackson on/off features for JsonNodes. + */ + private final Map jsonNode = new EnumMap<>(JsonNodeFeature.class); + + public Map getEnum() { + return this.enumFeatures; + } + + public Map getJsonNode() { + return this.jsonNode; + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfiguration.java index 5a41f5d0bdef..5ad8ff75ae83 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfiguration.java @@ -51,13 +51,14 @@ * @author Phillip Webb * @author Stephane Nicoll * @author Kazuki Shimizu + * @author Olga Maciaszek-Sharma * @since 1.0.0 */ @AutoConfiguration(before = SqlInitializationAutoConfiguration.class) @ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class }) @ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory") @EnableConfigurationProperties(DataSourceProperties.class) -@Import(DataSourcePoolMetadataProvidersConfiguration.class) +@Import({ DataSourcePoolMetadataProvidersConfiguration.class, DataSourceCheckpointRestoreConfiguration.class }) public class DataSourceAutoConfiguration { @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceCheckpointRestoreConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceCheckpointRestoreConfiguration.java new file mode 100644 index 000000000000..79d1a048b86d --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceCheckpointRestoreConfiguration.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-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.boot.autoconfigure.jdbc; + +import javax.sql.DataSource; + +import com.zaxxer.hikari.HikariDataSource; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnCheckpointRestore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.jdbc.HikariCheckpointRestoreLifecycle; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Checkpoint-restore specific configuration. + * + * @author Olga Maciaszek-Sharma + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnCheckpointRestore +@ConditionalOnBean(DataSource.class) +class DataSourceCheckpointRestoreConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(HikariDataSource.class) + static class Hikari { + + @Bean + @ConditionalOnMissingBean + HikariCheckpointRestoreLifecycle hikariCheckpointRestoreLifecycle(DataSource dataSource) { + return new HikariCheckpointRestoreLifecycle(dataSource); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration.java index 8dd321ee7cb4..1cebf37cd5e0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration.java @@ -172,7 +172,6 @@ PoolDataSourceImpl dataSource(DataSourceProperties properties, JdbcConnectionDet throws SQLException { PoolDataSourceImpl dataSource = createDataSource(connectionDetails, PoolDataSourceImpl.class, properties.getClassLoader()); - dataSource.setValidateConnectionOnBorrow(true); if (StringUtils.hasText(properties.getName())) { dataSource.setConnectionPoolName(properties.getName()); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfiguration.java index e144521271d1..e674dff8451b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -26,6 +26,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration; import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -46,7 +47,8 @@ * @author Kazuki Shimizu * @since 1.0.0 */ -@AutoConfiguration(before = TransactionAutoConfiguration.class) +@AutoConfiguration(before = TransactionAutoConfiguration.class, + after = TransactionManagerCustomizationAutoConfiguration.class) @ConditionalOnClass({ JdbcTemplate.class, TransactionManager.class }) @AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE) @EnableConfigurationProperties(DataSourceProperties.class) @@ -61,7 +63,8 @@ static class JdbcTransactionManagerConfiguration { DataSourceTransactionManager transactionManager(Environment environment, DataSource dataSource, ObjectProvider transactionManagerCustomizers) { DataSourceTransactionManager transactionManager = createTransactionManager(environment, dataSource); - transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager)); + transactionManagerCustomizers + .ifAvailable((customizers) -> customizers.customize((TransactionManager) transactionManager)); return transactionManager; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfiguration.java new file mode 100644 index 000000000000..9b78ee8e9d09 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.autoconfigure.jdbc; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.boot.sql.init.dependency.DatabaseInitializationDependencyConfigurer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for {@link JdbcClient}. + * + * @author Stephane Nicoll + * @since 3.2.0 + */ +@AutoConfiguration(after = JdbcTemplateAutoConfiguration.class) +@ConditionalOnSingleCandidate(NamedParameterJdbcTemplate.class) +@ConditionalOnMissingBean(JdbcClient.class) +@Import(DatabaseInitializationDependencyConfigurer.class) +public class JdbcClientAutoConfiguration { + + @Bean + JdbcClient jdbcClient(NamedParameterJdbcTemplate jdbcTemplate) { + return JdbcClient.create(jdbcTemplate); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/AcknowledgeMode.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/AcknowledgeMode.java new file mode 100644 index 000000000000..f3c3240d7e2d --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/AcknowledgeMode.java @@ -0,0 +1,105 @@ +/* + * Copyright 2012-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.boot.autoconfigure.jms; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.jms.Session; + +import org.springframework.jms.support.JmsAccessor; + +/** + * Acknowledge modes for a JMS Session. Supports the acknowledge modes defined by + * {@link jakarta.jms.Session} as well as other, non-standard modes. + * + *

+ * Note that {@link jakarta.jms.Session#SESSION_TRANSACTED} is not defined. It should be + * handled through a call to {@link JmsAccessor#setSessionTransacted(boolean)}. + * + * @author Andy Wilkinson + * @since 3.2.0 + */ +public final class AcknowledgeMode { + + private static final Map knownModes = new HashMap<>(3); + + /** + * Messages sent or received from the session are automatically acknowledged. This is + * the simplest mode and enables once-only message delivery guarantee. + */ + public static final AcknowledgeMode AUTO = new AcknowledgeMode(Session.AUTO_ACKNOWLEDGE); + + /** + * Messages are acknowledged once the message listener implementation has called + * {@link jakarta.jms.Message#acknowledge()}. This mode gives the application (rather + * than the JMS provider) complete control over message acknowledgement. + */ + public static final AcknowledgeMode CLIENT = new AcknowledgeMode(Session.CLIENT_ACKNOWLEDGE); + + /** + * Similar to auto acknowledgment except that said acknowledgment is lazy. As a + * consequence, the messages might be delivered more than once. This mode enables + * at-least-once message delivery guarantee. + */ + public static final AcknowledgeMode DUPS_OK = new AcknowledgeMode(Session.DUPS_OK_ACKNOWLEDGE); + + static { + knownModes.put("auto", AUTO); + knownModes.put("client", CLIENT); + knownModes.put("dupsok", DUPS_OK); + } + + private final int mode; + + private AcknowledgeMode(int mode) { + this.mode = mode; + } + + public int getMode() { + return this.mode; + } + + /** + * Creates an {@code AcknowledgeMode} of the given {@code mode}. The mode may be + * {@code auto}, {@code client}, {@code dupsok} or a non-standard acknowledge mode + * that can be {@link Integer#parseInt parsed as an integer}. + * @param mode the mode + * @return the acknowledge mode + */ + public static AcknowledgeMode of(String mode) { + String canonicalMode = canonicalize(mode); + AcknowledgeMode knownMode = knownModes.get(canonicalMode); + try { + return (knownMode != null) ? knownMode : new AcknowledgeMode(Integer.parseInt(canonicalMode)); + } + catch (NumberFormatException ex) { + throw new IllegalArgumentException("'" + mode + + "' is neither a known acknowledge mode (auto, client, or dups_ok) nor an integer value"); + } + } + + private static String canonicalize(String input) { + StringBuilder canonicalName = new StringBuilder(input.length()); + input.chars() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .forEach((c) -> canonicalName.append((char) c)); + return canonicalName.toString(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/DefaultJmsListenerContainerFactoryConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/DefaultJmsListenerContainerFactoryConfigurer.java index 931e8f653d98..66ab3db1ee6c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/DefaultJmsListenerContainerFactoryConfigurer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/DefaultJmsListenerContainerFactoryConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,9 +18,12 @@ import java.time.Duration; +import io.micrometer.observation.ObservationRegistry; import jakarta.jms.ConnectionFactory; import jakarta.jms.ExceptionListener; +import org.springframework.boot.autoconfigure.jms.JmsProperties.Listener.Session; +import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.jms.config.DefaultJmsListenerContainerFactory; import org.springframework.jms.support.converter.MessageConverter; import org.springframework.jms.support.destination.DestinationResolver; @@ -32,6 +35,8 @@ * * @author Stephane Nicoll * @author Eddú Meléndez + * @author Vedran Pavic + * @author Lasse Wulff * @since 1.3.3 */ public final class DefaultJmsListenerContainerFactoryConfigurer { @@ -46,6 +51,8 @@ public final class DefaultJmsListenerContainerFactoryConfigurer { private JmsProperties jmsProperties; + private ObservationRegistry observationRegistry; + /** * Set the {@link DestinationResolver} to use or {@code null} if no destination * resolver should be associated with the factory by default. @@ -90,6 +97,15 @@ void setJmsProperties(JmsProperties jmsProperties) { this.jmsProperties = jmsProperties; } + /** + * Set the {@link ObservationRegistry} to use. + * @param observationRegistry the {@link ObservationRegistry} + * @since 3.2.1 + */ + public void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + /** * Configure the specified jms listener container factory. The factory can be further * tuned and default settings can be overridden. @@ -99,36 +115,26 @@ void setJmsProperties(JmsProperties jmsProperties) { public void configure(DefaultJmsListenerContainerFactory factory, ConnectionFactory connectionFactory) { Assert.notNull(factory, "Factory must not be null"); Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); + JmsProperties.Listener listenerProperties = this.jmsProperties.getListener(); + Session sessionProperties = listenerProperties.getSession(); factory.setConnectionFactory(connectionFactory); - factory.setPubSubDomain(this.jmsProperties.isPubSubDomain()); - if (this.transactionManager != null) { - factory.setTransactionManager(this.transactionManager); - } - else { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.jmsProperties::isPubSubDomain).to(factory::setPubSubDomain); + map.from(this.jmsProperties::isSubscriptionDurable).to(factory::setSubscriptionDurable); + map.from(this.jmsProperties::getClientId).to(factory::setClientId); + map.from(this.transactionManager).to(factory::setTransactionManager); + map.from(this.destinationResolver).to(factory::setDestinationResolver); + map.from(this.messageConverter).to(factory::setMessageConverter); + map.from(this.exceptionListener).to(factory::setExceptionListener); + map.from(sessionProperties.getAcknowledgeMode()::getMode).to(factory::setSessionAcknowledgeMode); + if (this.transactionManager == null && sessionProperties.getTransacted() == null) { factory.setSessionTransacted(true); } - if (this.destinationResolver != null) { - factory.setDestinationResolver(this.destinationResolver); - } - if (this.messageConverter != null) { - factory.setMessageConverter(this.messageConverter); - } - if (this.exceptionListener != null) { - factory.setExceptionListener(this.exceptionListener); - } - JmsProperties.Listener listener = this.jmsProperties.getListener(); - factory.setAutoStartup(listener.isAutoStartup()); - if (listener.getAcknowledgeMode() != null) { - factory.setSessionAcknowledgeMode(listener.getAcknowledgeMode().getMode()); - } - String concurrency = listener.formatConcurrency(); - if (concurrency != null) { - factory.setConcurrency(concurrency); - } - Duration receiveTimeout = listener.getReceiveTimeout(); - if (receiveTimeout != null) { - factory.setReceiveTimeout(receiveTimeout.toMillis()); - } + map.from(this.observationRegistry).to(factory::setObservationRegistry); + map.from(sessionProperties::getTransacted).to(factory::setSessionTransacted); + map.from(listenerProperties::isAutoStartup).to(factory::setAutoStartup); + map.from(listenerProperties::formatConcurrency).to(factory::setConcurrency); + map.from(listenerProperties::getReceiveTimeout).as(Duration::toMillis).to(factory::setReceiveTimeout); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAnnotationDrivenConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAnnotationDrivenConfiguration.java index c503fa51842c..508863683403 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAnnotationDrivenConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAnnotationDrivenConfiguration.java @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.jms; +import io.micrometer.observation.ObservationRegistry; import jakarta.jms.ConnectionFactory; import jakarta.jms.ExceptionListener; @@ -53,15 +54,19 @@ class JmsAnnotationDrivenConfiguration { private final ObjectProvider exceptionListener; + private final ObjectProvider observationRegistry; + private final JmsProperties properties; JmsAnnotationDrivenConfiguration(ObjectProvider destinationResolver, ObjectProvider transactionManager, ObjectProvider messageConverter, - ObjectProvider exceptionListener, JmsProperties properties) { + ObjectProvider exceptionListener, + ObjectProvider observationRegistry, JmsProperties properties) { this.destinationResolver = destinationResolver; this.transactionManager = transactionManager; this.messageConverter = messageConverter; this.exceptionListener = exceptionListener; + this.observationRegistry = observationRegistry; this.properties = properties; } @@ -73,6 +78,7 @@ DefaultJmsListenerContainerFactoryConfigurer jmsListenerContainerFactoryConfigur configurer.setTransactionManager(this.transactionManager.getIfUnique()); configurer.setMessageConverter(this.messageConverter.getIfUnique()); configurer.setExceptionListener(this.exceptionListener.getIfUnique()); + configurer.setObservationRegistry(this.observationRegistry.getIfUnique()); configurer.setJmsProperties(this.properties); return configurer; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfiguration.java index e256a0a63d47..44b049a657ed 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfiguration.java @@ -17,10 +17,16 @@ package org.springframework.boot.autoconfigure.jms; import java.time.Duration; +import java.util.List; +import io.micrometer.observation.ObservationRegistry; import jakarta.jms.ConnectionFactory; import jakarta.jms.Message; +import org.springframework.aot.hint.ExecutableMode; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.TypeReference; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -28,6 +34,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.boot.autoconfigure.jms.JmsAutoConfiguration.JmsRuntimeHints; import org.springframework.boot.autoconfigure.jms.JmsProperties.DeliveryMode; import org.springframework.boot.autoconfigure.jms.JmsProperties.Template; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -35,6 +42,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportRuntimeHints; import org.springframework.jms.core.JmsMessageOperations; import org.springframework.jms.core.JmsMessagingTemplate; import org.springframework.jms.core.JmsOperations; @@ -47,6 +55,7 @@ * * @author Greg Turnquist * @author Stephane Nicoll + * @author Vedran Pavic * @since 1.0.0 */ @AutoConfiguration @@ -54,6 +63,7 @@ @ConditionalOnBean(ConnectionFactory.class) @EnableConfigurationProperties(JmsProperties.class) @Import(JmsAnnotationDrivenConfiguration.class) +@ImportRuntimeHints(JmsRuntimeHints.class) public class JmsAutoConfiguration { @Configuration(proxyBeanMethods = false) @@ -65,12 +75,16 @@ protected static class JmsTemplateConfiguration { private final ObjectProvider messageConverter; + private final ObjectProvider observationRegistry; + public JmsTemplateConfiguration(JmsProperties properties, ObjectProvider destinationResolver, - ObjectProvider messageConverter) { + ObjectProvider messageConverter, + ObjectProvider observationRegistry) { this.properties = properties; this.destinationResolver = destinationResolver; this.messageConverter = messageConverter; + this.observationRegistry = observationRegistry; } @Bean @@ -82,25 +96,22 @@ public JmsTemplate jmsTemplate(ConnectionFactory connectionFactory) { template.setPubSubDomain(this.properties.isPubSubDomain()); map.from(this.destinationResolver::getIfUnique).whenNonNull().to(template::setDestinationResolver); map.from(this.messageConverter::getIfUnique).whenNonNull().to(template::setMessageConverter); + map.from(this.observationRegistry::getIfUnique).whenNonNull().to(template::setObservationRegistry); mapTemplateProperties(this.properties.getTemplate(), template); return template; } private void mapTemplateProperties(Template properties, JmsTemplate template) { - PropertyMapper map = PropertyMapper.get(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties.getSession().getAcknowledgeMode()::getMode).to(template::setSessionAcknowledgeMode); + map.from(properties.getSession()::isTransacted).to(template::setSessionTransacted); map.from(properties::getDefaultDestination).whenNonNull().to(template::setDefaultDestinationName); map.from(properties::getDeliveryDelay).whenNonNull().as(Duration::toMillis).to(template::setDeliveryDelay); map.from(properties::determineQosEnabled).to(template::setExplicitQosEnabled); - map.from(properties::getDeliveryMode) - .whenNonNull() - .as(DeliveryMode::getValue) - .to(template::setDeliveryMode); + map.from(properties::getDeliveryMode).as(DeliveryMode::getValue).to(template::setDeliveryMode); map.from(properties::getPriority).whenNonNull().to(template::setPriority); map.from(properties::getTimeToLive).whenNonNull().as(Duration::toMillis).to(template::setTimeToLive); - map.from(properties::getReceiveTimeout) - .whenNonNull() - .as(Duration::toMillis) - .to(template::setReceiveTimeout); + map.from(properties::getReceiveTimeout).as(Duration::toMillis).to(template::setReceiveTimeout); } } @@ -126,4 +137,15 @@ private void mapTemplateProperties(Template properties, JmsMessagingTemplate mes } + static class JmsRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.reflection() + .registerType(TypeReference.of(AcknowledgeMode.class), (type) -> type.withMethod("of", + List.of(TypeReference.of(String.class)), ExecutableMode.INVOKE)); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsProperties.java index ea524db6388e..7d87ec9169cb 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/JmsProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; /** * Configuration properties for JMS. @@ -26,6 +27,8 @@ * @author Greg Turnquist * @author Phillip Webb * @author Stephane Nicoll + * @author Lasse Wulff + * @author Vedran Pavic * @since 1.0.0 */ @ConfigurationProperties(prefix = "spring.jms") @@ -42,6 +45,16 @@ public class JmsProperties { */ private String jndiName; + /** + * Whether the subscription is durable. + */ + private boolean subscriptionDurable = false; + + /** + * Client id of the connection. + */ + private String clientId; + private final Cache cache = new Cache(); private final Listener listener = new Listener(); @@ -56,6 +69,22 @@ public void setPubSubDomain(boolean pubSubDomain) { this.pubSubDomain = pubSubDomain; } + public boolean isSubscriptionDurable() { + return this.subscriptionDurable; + } + + public void setSubscriptionDurable(boolean subscriptionDurable) { + this.subscriptionDurable = subscriptionDurable; + } + + public String getClientId() { + return this.clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + public String getJndiName() { return this.jndiName; } @@ -139,17 +168,11 @@ public static class Listener { */ private boolean autoStartup = true; - /** - * Acknowledge mode of the container. By default, the listener is transacted with - * automatic acknowledgment. - */ - private AcknowledgeMode acknowledgeMode; - /** * Minimum number of concurrent consumers. When max-concurrency is not specified * the minimum will also be used as the maximum. */ - private Integer concurrency; + private Integer minConcurrency; /** * Maximum number of concurrent consumers. @@ -163,6 +186,8 @@ public static class Listener { */ private Duration receiveTimeout = Duration.ofSeconds(1); + private final Session session = new Session(); + public boolean isAutoStartup() { return this.autoStartup; } @@ -171,20 +196,34 @@ public void setAutoStartup(boolean autoStartup) { this.autoStartup = autoStartup; } + @Deprecated(since = "3.2.0", forRemoval = true) + @DeprecatedConfigurationProperty(replacement = "spring.jms.listener.session.acknowledge-mode", since = "3.2.0") public AcknowledgeMode getAcknowledgeMode() { - return this.acknowledgeMode; + return this.session.getAcknowledgeMode(); } + @Deprecated(since = "3.2.0", forRemoval = true) public void setAcknowledgeMode(AcknowledgeMode acknowledgeMode) { - this.acknowledgeMode = acknowledgeMode; + this.session.setAcknowledgeMode(acknowledgeMode); } + @DeprecatedConfigurationProperty(replacement = "spring.jms.listener.min-concurrency", since = "3.2.0") + @Deprecated(since = "3.2.0", forRemoval = true) public Integer getConcurrency() { - return this.concurrency; + return this.minConcurrency; } + @Deprecated(since = "3.2.0", forRemoval = true) public void setConcurrency(Integer concurrency) { - this.concurrency = concurrency; + this.minConcurrency = concurrency; + } + + public Integer getMinConcurrency() { + return this.minConcurrency; + } + + public void setMinConcurrency(Integer minConcurrency) { + this.minConcurrency = minConcurrency; } public Integer getMaxConcurrency() { @@ -196,10 +235,11 @@ public void setMaxConcurrency(Integer maxConcurrency) { } public String formatConcurrency() { - if (this.concurrency == null) { + if (this.minConcurrency == null) { return (this.maxConcurrency != null) ? "1-" + this.maxConcurrency : null; } - return this.concurrency + "-" + ((this.maxConcurrency != null) ? this.maxConcurrency : this.concurrency); + return this.minConcurrency + "-" + + ((this.maxConcurrency != null) ? this.maxConcurrency : this.minConcurrency); } public Duration getReceiveTimeout() { @@ -210,6 +250,41 @@ public void setReceiveTimeout(Duration receiveTimeout) { this.receiveTimeout = receiveTimeout; } + public Session getSession() { + return this.session; + } + + public static class Session { + + /** + * Acknowledge mode of the listener container. + */ + private AcknowledgeMode acknowledgeMode = AcknowledgeMode.AUTO; + + /** + * Whether the listener container should use transacted JMS sessions. Defaults + * to false in the presence of a JtaTransactionManager and true otherwise. + */ + private Boolean transacted; + + public AcknowledgeMode getAcknowledgeMode() { + return this.acknowledgeMode; + } + + public void setAcknowledgeMode(AcknowledgeMode acknowledgeMode) { + this.acknowledgeMode = acknowledgeMode; + } + + public Boolean getTransacted() { + return this.transacted; + } + + public void setTransacted(Boolean transacted) { + this.transacted = transacted; + } + + } + } public static class Template { @@ -254,6 +329,8 @@ public static class Template { */ private Duration receiveTimeout; + private final Session session = new Session(); + public String getDefaultDestination() { return this.defaultDestination; } @@ -317,45 +394,38 @@ public void setReceiveTimeout(Duration receiveTimeout) { this.receiveTimeout = receiveTimeout; } - } + public Session getSession() { + return this.session; + } - /** - * Translate the acknowledge modes defined on the {@link jakarta.jms.Session}. - * - *

- * {@link jakarta.jms.Session#SESSION_TRANSACTED} is not defined as we take care of - * this already through a call to {@code setSessionTransacted}. - */ - public enum AcknowledgeMode { + public static class Session { - /** - * Messages sent or received from the session are automatically acknowledged. This - * is the simplest mode and enables once-only message delivery guarantee. - */ - AUTO(1), + /** + * Acknowledge mode used when creating sessions. + */ + private AcknowledgeMode acknowledgeMode = AcknowledgeMode.AUTO; - /** - * Messages are acknowledged once the message listener implementation has called - * {@link jakarta.jms.Message#acknowledge()}. This mode gives the application - * (rather than the JMS provider) complete control over message acknowledgement. - */ - CLIENT(2), + /** + * Whether to use transacted sessions. + */ + private boolean transacted = false; - /** - * Similar to auto acknowledgment except that said acknowledgment is lazy. As a - * consequence, the messages might be delivered more than once. This mode enables - * at-least-once message delivery guarantee. - */ - DUPS_OK(3); + public AcknowledgeMode getAcknowledgeMode() { + return this.acknowledgeMode; + } - private final int mode; + public void setAcknowledgeMode(AcknowledgeMode acknowledgeMode) { + this.acknowledgeMode = acknowledgeMode; + } - AcknowledgeMode(int mode) { - this.mode = mode; - } + public boolean isTransacted() { + return this.transacted; + } + + public void setTransacted(boolean transacted) { + this.transacted = transacted; + } - public int getMode() { - return this.mode; } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfiguration.java index da642a7f8689..44c3cebe3082 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfiguration.java @@ -27,6 +27,7 @@ import org.springframework.boot.autoconfigure.jms.JmsProperties; import org.springframework.boot.autoconfigure.jms.JndiConnectionFactoryAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; /** @@ -35,6 +36,7 @@ * * @author Stephane Nicoll * @author Phillip Webb + * @author Eddú Meléndez * @since 3.1.0 */ @AutoConfiguration(before = JmsAutoConfiguration.class, after = JndiConnectionFactoryAutoConfiguration.class) @@ -44,4 +46,38 @@ @Import({ ActiveMQXAConnectionFactoryConfiguration.class, ActiveMQConnectionFactoryConfiguration.class }) public class ActiveMQAutoConfiguration { + @Bean + @ConditionalOnMissingBean(ActiveMQConnectionDetails.class) + ActiveMQConnectionDetails activemqConnectionDetails(ActiveMQProperties properties) { + return new PropertiesActiveMQConnectionDetails(properties); + } + + /** + * Adapts {@link ActiveMQProperties} to {@link ActiveMQConnectionDetails}. + */ + static class PropertiesActiveMQConnectionDetails implements ActiveMQConnectionDetails { + + private final ActiveMQProperties properties; + + PropertiesActiveMQConnectionDetails(ActiveMQProperties properties) { + this.properties = properties; + } + + @Override + public String getBrokerUrl() { + return this.properties.determineBrokerUrl(); + } + + @Override + public String getUser() { + return this.properties.getUser(); + } + + @Override + public String getPassword() { + return this.properties.getPassword(); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionDetails.java new file mode 100644 index 000000000000..9c095cfda901 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionDetails.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-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.boot.autoconfigure.jms.activemq; + +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; + +/** + * Details required to establish a connection to an ActiveMQ service. + * + * @author Eddú Meléndez + * @author Stephane Nicoll + * @since 3.2.0 + */ +public interface ActiveMQConnectionDetails extends ConnectionDetails { + + /** + * Broker URL to use. + * @return the url of the broker + */ + String getBrokerUrl(); + + /** + * Login user to authenticate to the broker. + * @return the login user to authenticate to the broker or {@code null} + */ + String getUser(); + + /** + * Login to authenticate against the broker. + * @return the login to authenticate against the broker or {@code null} + */ + String getPassword(); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryConfiguration.java index a55337e58a2c..a4d242600e51 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryConfiguration.java @@ -39,6 +39,7 @@ * @author Phillip Webb * @author Andy Wilkinson * @author Aurélien Leboulanger + * @author Eddú Meléndez */ @Configuration(proxyBeanMethods = false) @ConditionalOnMissingBean(ConnectionFactory.class) @@ -52,13 +53,16 @@ static class SimpleConnectionFactoryConfiguration { @Bean @ConditionalOnProperty(prefix = "spring.jms.cache", name = "enabled", havingValue = "false") ActiveMQConnectionFactory jmsConnectionFactory(ActiveMQProperties properties, - ObjectProvider factoryCustomizers) { - return createJmsConnectionFactory(properties, factoryCustomizers); + ObjectProvider factoryCustomizers, + ActiveMQConnectionDetails connectionDetails) { + return createJmsConnectionFactory(properties, factoryCustomizers, connectionDetails); } private static ActiveMQConnectionFactory createJmsConnectionFactory(ActiveMQProperties properties, - ObjectProvider factoryCustomizers) { - return new ActiveMQConnectionFactoryFactory(properties, factoryCustomizers.orderedStream().toList()) + ObjectProvider factoryCustomizers, + ActiveMQConnectionDetails connectionDetails) { + return new ActiveMQConnectionFactoryFactory(properties, factoryCustomizers.orderedStream().toList(), + connectionDetails) .createConnectionFactory(ActiveMQConnectionFactory.class); } @@ -70,10 +74,11 @@ static class CachingConnectionFactoryConfiguration { @Bean CachingConnectionFactory jmsConnectionFactory(JmsProperties jmsProperties, ActiveMQProperties properties, - ObjectProvider factoryCustomizers) { + ObjectProvider factoryCustomizers, + ActiveMQConnectionDetails connectionDetails) { JmsProperties.Cache cacheProperties = jmsProperties.getCache(); CachingConnectionFactory connectionFactory = new CachingConnectionFactory( - createJmsConnectionFactory(properties, factoryCustomizers)); + createJmsConnectionFactory(properties, factoryCustomizers, connectionDetails)); connectionFactory.setCacheConsumers(cacheProperties.isConsumers()); connectionFactory.setCacheProducers(cacheProperties.isProducers()); connectionFactory.setSessionCacheSize(cacheProperties.getSessionCacheSize()); @@ -91,9 +96,10 @@ static class PooledConnectionFactoryConfiguration { @Bean(destroyMethod = "stop") @ConditionalOnProperty(prefix = "spring.activemq.pool", name = "enabled", havingValue = "true") JmsPoolConnectionFactory jmsConnectionFactory(ActiveMQProperties properties, - ObjectProvider factoryCustomizers) { + ObjectProvider factoryCustomizers, + ActiveMQConnectionDetails connectionDetails) { ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactoryFactory(properties, - factoryCustomizers.orderedStream().toList()) + factoryCustomizers.orderedStream().toList(), connectionDetails) .createConnectionFactory(ActiveMQConnectionFactory.class); return new JmsPoolConnectionFactoryFactory(properties.getPool()) .createPooledConnectionFactory(connectionFactory); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryFactory.java index b571860491f0..67768c0363ae 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryFactory.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQConnectionFactoryFactory.java @@ -32,20 +32,22 @@ * * @author Phillip Webb * @author Venil Noronha + * @author Eddú Meléndez */ class ActiveMQConnectionFactoryFactory { - private static final String DEFAULT_NETWORK_BROKER_URL = "tcp://localhost:61616"; - private final ActiveMQProperties properties; private final List factoryCustomizers; + private final ActiveMQConnectionDetails connectionDetails; + ActiveMQConnectionFactoryFactory(ActiveMQProperties properties, - List factoryCustomizers) { + List factoryCustomizers, ActiveMQConnectionDetails connectionDetails) { Assert.notNull(properties, "Properties must not be null"); this.properties = properties; this.factoryCustomizers = (factoryCustomizers != null) ? factoryCustomizers : Collections.emptyList(); + this.connectionDetails = connectionDetails; } T createConnectionFactory(Class factoryClass) { @@ -79,9 +81,9 @@ private T doCreateConnectionFactory(Class< private T createConnectionFactoryInstance(Class factoryClass) throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { - String brokerUrl = determineBrokerUrl(); - String user = this.properties.getUser(); - String password = this.properties.getPassword(); + String brokerUrl = this.connectionDetails.getBrokerUrl(); + String user = this.connectionDetails.getUser(); + String password = this.connectionDetails.getPassword(); if (StringUtils.hasLength(user) && StringUtils.hasLength(password)) { return factoryClass.getConstructor(String.class, String.class, String.class) .newInstance(user, password, brokerUrl); @@ -95,11 +97,4 @@ private void customize(ActiveMQConnectionFactory connectionFactory) { } } - String determineBrokerUrl() { - if (this.properties.getBrokerUrl() != null) { - return this.properties.getBrokerUrl(); - } - return DEFAULT_NETWORK_BROKER_URL; - } - } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQProperties.java index 48b72e88935c..2877479a08e6 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQProperties.java @@ -31,11 +31,14 @@ * @author Stephane Nicoll * @author Aurélien Leboulanger * @author Venil Noronha + * @author Eddú Meléndez * @since 3.1.0 */ @ConfigurationProperties(prefix = "spring.activemq") public class ActiveMQProperties { + private static final String DEFAULT_NETWORK_BROKER_URL = "tcp://localhost:61616"; + /** * URL of the ActiveMQ broker. Auto-generated by default. */ @@ -128,6 +131,13 @@ public Packages getPackages() { return this.packages; } + String determineBrokerUrl() { + if (this.brokerUrl != null) { + return this.brokerUrl; + } + return DEFAULT_NETWORK_BROKER_URL; + } + public static class Packages { /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQXAConnectionFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQXAConnectionFactoryConfiguration.java index 4a7cbd214cea..6458c5824ae3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQXAConnectionFactoryConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQXAConnectionFactoryConfiguration.java @@ -36,6 +36,7 @@ * * @author Phillip Webb * @author Aurélien Leboulanger + * @author Eddú Meléndez */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(TransactionManager.class) @@ -46,10 +47,10 @@ class ActiveMQXAConnectionFactoryConfiguration { @Primary @Bean(name = { "jmsConnectionFactory", "xaJmsConnectionFactory" }) ConnectionFactory jmsConnectionFactory(ActiveMQProperties properties, - ObjectProvider factoryCustomizers, XAConnectionFactoryWrapper wrapper) - throws Exception { + ObjectProvider factoryCustomizers, XAConnectionFactoryWrapper wrapper, + ActiveMQConnectionDetails connectionDetails) throws Exception { ActiveMQXAConnectionFactory connectionFactory = new ActiveMQConnectionFactoryFactory(properties, - factoryCustomizers.orderedStream().toList()) + factoryCustomizers.orderedStream().toList(), connectionDetails) .createConnectionFactory(ActiveMQXAConnectionFactory.class); return wrapper.wrapConnectionFactory(connectionFactory); } @@ -58,8 +59,10 @@ ConnectionFactory jmsConnectionFactory(ActiveMQProperties properties, @ConditionalOnProperty(prefix = "spring.activemq.pool", name = "enabled", havingValue = "false", matchIfMissing = true) ActiveMQConnectionFactory nonXaJmsConnectionFactory(ActiveMQProperties properties, - ObjectProvider factoryCustomizers) { - return new ActiveMQConnectionFactoryFactory(properties, factoryCustomizers.orderedStream().toList()) + ObjectProvider factoryCustomizers, + ActiveMQConnectionDetails connectionDetails) { + return new ActiveMQConnectionFactoryFactory(properties, factoryCustomizers.orderedStream().toList(), + connectionDetails) .createConnectionFactory(ActiveMQConnectionFactory.class); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/DefaultExceptionTranslatorExecuteListener.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/DefaultExceptionTranslatorExecuteListener.java new file mode 100644 index 000000000000..199a62dda0e0 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/DefaultExceptionTranslatorExecuteListener.java @@ -0,0 +1,126 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.jooq; + +import java.sql.SQLException; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jooq.ExecuteContext; +import org.jooq.SQLDialect; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator; +import org.springframework.jdbc.support.SQLExceptionTranslator; +import org.springframework.jdbc.support.SQLStateSQLExceptionTranslator; +import org.springframework.util.Assert; + +/** + * Default implementation of {@link ExceptionTranslatorExecuteListener} that delegates to + * an {@link SQLExceptionTranslator}. + * + * @author Lukas Eder + * @author Andreas Ahlenstorf + * @author Phillip Webb + * @author Stephane Nicoll + */ +final class DefaultExceptionTranslatorExecuteListener implements ExceptionTranslatorExecuteListener { + + // Based on the jOOQ-spring-example from https://github.com/jOOQ/jOOQ + + private static final Log defaultLogger = LogFactory.getLog(ExceptionTranslatorExecuteListener.class); + + private final Log logger; + + private Function translatorFactory; + + DefaultExceptionTranslatorExecuteListener() { + this(defaultLogger, new DefaultTranslatorFactory()); + } + + DefaultExceptionTranslatorExecuteListener(Function translatorFactory) { + this(defaultLogger, translatorFactory); + } + + DefaultExceptionTranslatorExecuteListener(Log logger) { + this(logger, new DefaultTranslatorFactory()); + } + + private DefaultExceptionTranslatorExecuteListener(Log logger, + Function translatorFactory) { + Assert.notNull(translatorFactory, "TranslatorFactory must not be null"); + this.logger = logger; + this.translatorFactory = translatorFactory; + } + + @Override + public void exception(ExecuteContext context) { + SQLExceptionTranslator translator = this.translatorFactory.apply(context); + // The exception() callback is not only triggered for SQL exceptions but also for + // "normal" exceptions. In those cases sqlException() returns null. + SQLException exception = context.sqlException(); + while (exception != null) { + handle(context, translator, exception); + exception = exception.getNextException(); + } + } + + /** + * Handle a single exception in the chain. SQLExceptions might be nested multiple + * levels deep. The outermost exception is usually the least interesting one ("Call + * getNextException to see the cause."). Therefore the innermost exception is + * propagated and all other exceptions are logged. + * @param context the execute context + * @param translator the exception translator + * @param exception the exception + */ + private void handle(ExecuteContext context, SQLExceptionTranslator translator, SQLException exception) { + DataAccessException translated = translator.translate("jOOQ", context.sql(), exception); + if (exception.getNextException() != null) { + this.logger.error("Execution of SQL statement failed.", (translated != null) ? translated : exception); + return; + } + if (translated != null) { + context.exception(translated); + } + } + + /** + * Default {@link SQLExceptionTranslator} factory that creates the translator based on + * the Spring DB name. + */ + private static final class DefaultTranslatorFactory implements Function { + + @Override + public SQLExceptionTranslator apply(ExecuteContext context) { + return apply(context.configuration().dialect()); + } + + private SQLExceptionTranslator apply(SQLDialect dialect) { + String dbName = getSpringDbName(dialect); + return (dbName != null) ? new SQLErrorCodeSQLExceptionTranslator(dbName) + : new SQLStateSQLExceptionTranslator(); + } + + private String getSpringDbName(SQLDialect dialect) { + return (dialect != null && dialect.thirdParty() != null) ? dialect.thirdParty().springDbName() : null; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/ExceptionTranslatorExecuteListener.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/ExceptionTranslatorExecuteListener.java new file mode 100644 index 000000000000..eca548dfc334 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/ExceptionTranslatorExecuteListener.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.jooq; + +import java.sql.SQLException; +import java.util.function.Function; + +import org.jooq.ExecuteContext; +import org.jooq.ExecuteListener; +import org.jooq.impl.DefaultExecuteListenerProvider; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.support.SQLExceptionTranslator; + +/** + * An {@link ExecuteListener} used by the auto-configured + * {@link DefaultExecuteListenerProvider} to translate exceptions in the + * {@link ExecuteContext}. Most commonly used to translate {@link SQLException + * SQLExceptions} to Spring-specific {@link DataAccessException DataAccessExceptions} by + * adapting an existing {@link SQLExceptionTranslator}. + * + * @author Dennis Melzer + * @since 3.3.0 + * @see #DEFAULT + * @see #of(Function) + */ +public interface ExceptionTranslatorExecuteListener extends ExecuteListener { + + /** + * Default {@link ExceptionTranslatorExecuteListener} suitable for most applications. + */ + ExceptionTranslatorExecuteListener DEFAULT = new DefaultExceptionTranslatorExecuteListener(); + + /** + * Creates a new {@link ExceptionTranslatorExecuteListener} backed by an + * {@link SQLExceptionTranslator}. + * @param translatorFactory factory function used to create the + * {@link SQLExceptionTranslator} + * @return a new {@link ExceptionTranslatorExecuteListener} instance + */ + static ExceptionTranslatorExecuteListener of(Function translatorFactory) { + return new DefaultExceptionTranslatorExecuteListener(translatorFactory); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfiguration.java index 72944ddf3f63..4580ed021ccd 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,8 +70,15 @@ public SpringTransactionProvider transactionProvider(PlatformTransactionManager @Bean @Order(0) - public DefaultExecuteListenerProvider jooqExceptionTranslatorExecuteListenerProvider() { - return new DefaultExecuteListenerProvider(new JooqExceptionTranslator()); + public DefaultExecuteListenerProvider jooqExceptionTranslatorExecuteListenerProvider( + ExceptionTranslatorExecuteListener exceptionTranslatorExecuteListener) { + return new DefaultExecuteListenerProvider(exceptionTranslatorExecuteListener); + } + + @Bean + @ConditionalOnMissingBean(ExceptionTranslatorExecuteListener.class) + public ExceptionTranslatorExecuteListener jooqExceptionTranslator() { + return ExceptionTranslatorExecuteListener.DEFAULT; } @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqExceptionTranslator.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqExceptionTranslator.java index 383da48c975b..102bd59b0566 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqExceptionTranslator.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/JooqExceptionTranslator.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,80 +18,33 @@ import java.sql.SQLException; -import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jooq.ExecuteContext; import org.jooq.ExecuteListener; -import org.jooq.SQLDialect; import org.springframework.dao.DataAccessException; -import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator; -import org.springframework.jdbc.support.SQLExceptionTranslator; -import org.springframework.jdbc.support.SQLStateSQLExceptionTranslator; /** - * Transforms {@link java.sql.SQLException} into a Spring-specific - * {@link DataAccessException}. + * Transforms {@link SQLException} into a Spring-specific {@link DataAccessException}. * * @author Lukas Eder * @author Andreas Ahlenstorf * @author Phillip Webb * @author Stephane Nicoll * @since 1.5.10 + * @deprecated since 3.3.0 for removal in 3.5.0 in favor of + * {@link ExceptionTranslatorExecuteListener#DEFAULT} or + * {@link ExceptionTranslatorExecuteListener#of} */ +@Deprecated(since = "3.3.0", forRemoval = true) public class JooqExceptionTranslator implements ExecuteListener { - // Based on the jOOQ-spring-example from https://github.com/jOOQ/jOOQ - - private static final Log logger = LogFactory.getLog(JooqExceptionTranslator.class); + private final DefaultExceptionTranslatorExecuteListener delegate = new DefaultExceptionTranslatorExecuteListener( + LogFactory.getLog(JooqExceptionTranslator.class)); @Override public void exception(ExecuteContext context) { - SQLExceptionTranslator translator = getTranslator(context); - // The exception() callback is not only triggered for SQL exceptions but also for - // "normal" exceptions. In those cases sqlException() returns null. - SQLException exception = context.sqlException(); - while (exception != null) { - handle(context, translator, exception); - exception = exception.getNextException(); - } - } - - private SQLExceptionTranslator getTranslator(ExecuteContext context) { - SQLDialect dialect = context.configuration().dialect(); - if (dialect != null && dialect.thirdParty() != null) { - String dbName = dialect.thirdParty().springDbName(); - if (dbName != null) { - return new SQLErrorCodeSQLExceptionTranslator(dbName); - } - } - return new SQLStateSQLExceptionTranslator(); - } - - /** - * Handle a single exception in the chain. SQLExceptions might be nested multiple - * levels deep. The outermost exception is usually the least interesting one ("Call - * getNextException to see the cause."). Therefore the innermost exception is - * propagated and all other exceptions are logged. - * @param context the execute context - * @param translator the exception translator - * @param exception the exception - */ - private void handle(ExecuteContext context, SQLExceptionTranslator translator, SQLException exception) { - DataAccessException translated = translate(context, translator, exception); - if (exception.getNextException() == null) { - if (translated != null) { - context.exception(translated); - } - } - else { - logger.error("Execution of SQL statement failed.", (translated != null) ? translated : exception); - } - } - - private DataAccessException translate(ExecuteContext context, SQLExceptionTranslator translator, - SQLException exception) { - return translator.translate("jOOQ", context.sql(), exception); + this.delegate.exception(context); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/SqlDialectLookup.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/SqlDialectLookup.java index b91b72b68258..1e7504195d56 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/SqlDialectLookup.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jooq/SqlDialectLookup.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,8 @@ package org.springframework.boot.autoconfigure.jooq; -import java.sql.DatabaseMetaData; +import java.sql.Connection; +import java.sql.SQLException; import javax.sql.DataSource; @@ -25,14 +26,12 @@ import org.jooq.SQLDialect; import org.jooq.tools.jdbc.JDBCUtils; -import org.springframework.jdbc.support.JdbcUtils; -import org.springframework.jdbc.support.MetaDataAccessException; - /** * Utility to lookup well known {@link SQLDialect SQLDialects} from a {@link DataSource}. * * @author Michael Simons * @author Lukas Eder + * @author Ramil Saetov */ final class SqlDialectLookup { @@ -47,18 +46,11 @@ private SqlDialectLookup() { * @return the most suitable {@link SQLDialect} */ static SQLDialect getDialect(DataSource dataSource) { - if (dataSource == null) { - return SQLDialect.DEFAULT; - } - try { - String url = JdbcUtils.extractDatabaseMetaData(dataSource, DatabaseMetaData::getURL); - SQLDialect sqlDialect = JDBCUtils.dialect(url); - if (sqlDialect != null) { - return sqlDialect; - } + try (Connection connection = (dataSource != null) ? dataSource.getConnection() : null) { + return JDBCUtils.dialect(connection); } - catch (MetaDataAccessException ex) { - logger.warn("Unable to determine jdbc url from datasource", ex); + catch (SQLException ex) { + logger.warn("Unable to determine dialect from datasource", ex); } return SQLDialect.DEFAULT; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurer.java index 8ea525a151b5..86cfd258ee93 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurer.java @@ -17,9 +17,11 @@ package org.springframework.boot.autoconfigure.kafka; import java.time.Duration; +import java.util.function.Function; import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Listener; import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.ConsumerFactory; import org.springframework.kafka.core.KafkaTemplate; @@ -28,6 +30,7 @@ import org.springframework.kafka.listener.CommonErrorHandler; import org.springframework.kafka.listener.ConsumerAwareRebalanceListener; import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.MessageListenerContainer; import org.springframework.kafka.listener.RecordInterceptor; import org.springframework.kafka.listener.adapter.RecordFilterStrategy; import org.springframework.kafka.support.converter.BatchMessageConverter; @@ -40,6 +43,7 @@ * @author Gary Russell * @author Eddú Meléndez * @author Thomas Kåsene + * @author Moritz Halbritter * @since 1.5.0 */ public class ConcurrentKafkaListenerContainerFactoryConfigurer { @@ -66,6 +70,10 @@ public class ConcurrentKafkaListenerContainerFactoryConfigurer { private BatchInterceptor batchInterceptor; + private Function threadNameSupplier; + + private SimpleAsyncTaskExecutor listenerTaskExecutor; + /** * Set the {@link KafkaProperties} to use. * @param properties the properties @@ -156,6 +164,22 @@ void setBatchInterceptor(BatchInterceptor batchInterceptor) { this.batchInterceptor = batchInterceptor; } + /** + * Set the thread name supplier to use. + * @param threadNameSupplier the thread name supplier to use + */ + void setThreadNameSupplier(Function threadNameSupplier) { + this.threadNameSupplier = threadNameSupplier; + } + + /** + * Set the executor for threads that poll the consumer. + * @param listenerTaskExecutor task executor + */ + void setListenerTaskExecutor(SimpleAsyncTaskExecutor listenerTaskExecutor) { + this.listenerTaskExecutor = listenerTaskExecutor; + } + /** * Configure the specified Kafka listener container factory. The factory can be * further tuned and default settings can be overridden. @@ -186,6 +210,8 @@ private void configureListenerFactory(ConcurrentKafkaListenerContainerFactory batchInterceptor; + private final Function threadNameSupplier; + KafkaAnnotationDrivenConfiguration(KafkaProperties properties, ObjectProvider recordMessageConverter, ObjectProvider> recordFilterStrategy, @@ -83,7 +95,8 @@ class KafkaAnnotationDrivenConfiguration { ObjectProvider commonErrorHandler, ObjectProvider> afterRollbackProcessor, ObjectProvider> recordInterceptor, - ObjectProvider> batchInterceptor) { + ObjectProvider> batchInterceptor, + ObjectProvider> threadNameSupplier) { this.properties = properties; this.recordMessageConverter = recordMessageConverter.getIfUnique(); this.recordFilterStrategy = recordFilterStrategy.getIfUnique(); @@ -96,11 +109,28 @@ class KafkaAnnotationDrivenConfiguration { this.afterRollbackProcessor = afterRollbackProcessor.getIfUnique(); this.recordInterceptor = recordInterceptor.getIfUnique(); this.batchInterceptor = batchInterceptor.getIfUnique(); + this.threadNameSupplier = threadNameSupplier.getIfUnique(); } @Bean @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.PLATFORM) ConcurrentKafkaListenerContainerFactoryConfigurer kafkaListenerContainerFactoryConfigurer() { + return configurer(); + } + + @Bean(name = "kafkaListenerContainerFactoryConfigurer") + @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.VIRTUAL) + ConcurrentKafkaListenerContainerFactoryConfigurer kafkaListenerContainerFactoryConfigurerVirtualThreads() { + ConcurrentKafkaListenerContainerFactoryConfigurer configurer = configurer(); + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("kafka-"); + executor.setVirtualThreads(true); + configurer.setListenerTaskExecutor(executor); + return configurer; + } + + private ConcurrentKafkaListenerContainerFactoryConfigurer configurer() { ConcurrentKafkaListenerContainerFactoryConfigurer configurer = new ConcurrentKafkaListenerContainerFactoryConfigurer(); configurer.setKafkaProperties(this.properties); configurer.setBatchMessageConverter(this.batchMessageConverter); @@ -113,6 +143,7 @@ ConcurrentKafkaListenerContainerFactoryConfigurer kafkaListenerContainerFactoryC configurer.setAfterRollbackProcessor(this.afterRollbackProcessor); configurer.setRecordInterceptor(this.recordInterceptor); configurer.setBatchInterceptor(this.batchInterceptor); + configurer.setThreadNameSupplier(this.threadNameSupplier); return configurer; } @@ -121,10 +152,11 @@ ConcurrentKafkaListenerContainerFactoryConfigurer kafkaListenerContainerFactoryC ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( ConcurrentKafkaListenerContainerFactoryConfigurer configurer, ObjectProvider> kafkaConsumerFactory, - ObjectProvider>> kafkaContainerCustomizer) { + ObjectProvider>> kafkaContainerCustomizer, + ObjectProvider sslBundles) { ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); - configurer.configure(factory, kafkaConsumerFactory - .getIfAvailable(() -> new DefaultKafkaConsumerFactory<>(this.properties.buildConsumerProperties()))); + configurer.configure(factory, kafkaConsumerFactory.getIfAvailable(() -> new DefaultKafkaConsumerFactory<>( + this.properties.buildConsumerProperties(sslBundles.getIfAvailable())))); kafkaContainerCustomizer.ifAvailable(factory::setContainerCustomizer); return factory; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfiguration.java index 9d73f58cd56c..18e02adb854a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Retry.Topic; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.kafka.core.ConsumerFactory; @@ -64,6 +65,8 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick * @since 1.5.0 */ @AutoConfiguration @@ -95,6 +98,7 @@ PropertiesKafkaConnectionDetails kafkaConnectionDetails(KafkaProperties properti map.from(kafkaProducerListener).to(kafkaTemplate::setProducerListener); map.from(this.properties.getTemplate().getDefaultTopic()).to(kafkaTemplate::setDefaultTopic); map.from(this.properties.getTemplate().getTransactionIdPrefix()).to(kafkaTemplate::setTransactionIdPrefix); + map.from(this.properties.getTemplate().isObservationEnabled()).to(kafkaTemplate::setObservationEnabled); return kafkaTemplate; } @@ -107,8 +111,8 @@ public LoggingProducerListener kafkaProducerListener() { @Bean @ConditionalOnMissingBean(ConsumerFactory.class) public DefaultKafkaConsumerFactory kafkaConsumerFactory(KafkaConnectionDetails connectionDetails, - ObjectProvider customizers) { - Map properties = this.properties.buildConsumerProperties(); + ObjectProvider customizers, ObjectProvider sslBundles) { + Map properties = this.properties.buildConsumerProperties(sslBundles.getIfAvailable()); applyKafkaConnectionDetailsForConsumer(properties, connectionDetails); DefaultKafkaConsumerFactory factory = new DefaultKafkaConsumerFactory<>(properties); customizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); @@ -118,8 +122,8 @@ public LoggingProducerListener kafkaProducerListener() { @Bean @ConditionalOnMissingBean(ProducerFactory.class) public DefaultKafkaProducerFactory kafkaProducerFactory(KafkaConnectionDetails connectionDetails, - ObjectProvider customizers) { - Map properties = this.properties.buildProducerProperties(); + ObjectProvider customizers, ObjectProvider sslBundles) { + Map properties = this.properties.buildProducerProperties(sslBundles.getIfAvailable()); applyKafkaConnectionDetailsForProducer(properties, connectionDetails); DefaultKafkaProducerFactory factory = new DefaultKafkaProducerFactory<>(properties); String transactionIdPrefix = this.properties.getProducer().getTransactionIdPrefix(); @@ -155,8 +159,8 @@ public KafkaJaasLoginModuleInitializer kafkaJaasInitializer() throws IOException @Bean @ConditionalOnMissingBean - public KafkaAdmin kafkaAdmin(KafkaConnectionDetails connectionDetails) { - Map properties = this.properties.buildAdminProperties(); + public KafkaAdmin kafkaAdmin(KafkaConnectionDetails connectionDetails, ObjectProvider sslBundles) { + Map properties = this.properties.buildAdminProperties(sslBundles.getIfAvailable()); applyKafkaConnectionDetailsForAdmin(properties, connectionDetails); KafkaAdmin kafkaAdmin = new KafkaAdmin(properties); KafkaProperties.Admin admin = this.properties.getAdmin(); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.java index cb6869b98666..c74fa291f734 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaProperties.java @@ -34,10 +34,11 @@ import org.apache.kafka.common.serialization.StringSerializer; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; import org.springframework.boot.convert.DurationUnit; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; import org.springframework.core.io.Resource; import org.springframework.kafka.listener.ContainerProperties.AckMode; import org.springframework.kafka.security.jaas.KafkaJaasLoginModuleInitializer; @@ -55,6 +56,8 @@ * @author Artem Bilan * @author Nakul Mishra * @author Tomaz Fernandes + * @author Andy Wilkinson + * @author Scott Frederick * @since 1.5.0 */ @ConfigurationProperties(prefix = "spring.kafka") @@ -157,7 +160,7 @@ public Retry getRetry() { return this.retry; } - private Map buildCommonProperties() { + private Map buildCommonProperties(SslBundles sslBundles) { Map properties = new HashMap<>(); if (this.bootstrapServers != null) { properties.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, this.bootstrapServers); @@ -165,7 +168,7 @@ private Map buildCommonProperties() { if (this.clientId != null) { properties.put(CommonClientConfigs.CLIENT_ID_CONFIG, this.clientId); } - properties.putAll(this.ssl.buildProperties()); + properties.putAll(this.ssl.buildProperties(sslBundles)); properties.putAll(this.security.buildProperties()); if (!CollectionUtils.isEmpty(this.properties)) { properties.putAll(this.properties); @@ -177,13 +180,29 @@ private Map buildCommonProperties() { * Create an initial map of consumer properties from the state of this instance. *

* This allows you to add additional properties, if necessary, and override the - * default kafkaConsumerFactory bean. + * default {@code kafkaConsumerFactory} bean. * @return the consumer properties initialized with the customizations defined on this * instance + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link #buildConsumerProperties(SslBundles)}} */ + @Deprecated(since = "3.2.0", forRemoval = true) public Map buildConsumerProperties() { - Map properties = buildCommonProperties(); - properties.putAll(this.consumer.buildProperties()); + return buildConsumerProperties(null); + } + + /** + * Create an initial map of consumer properties from the state of this instance. + *

+ * This allows you to add additional properties, if necessary, and override the + * default {@code kafkaConsumerFactory} bean. + * @param sslBundles bundles providing SSL trust material + * @return the consumer properties initialized with the customizations defined on this + * instance + */ + public Map buildConsumerProperties(SslBundles sslBundles) { + Map properties = buildCommonProperties(sslBundles); + properties.putAll(this.consumer.buildProperties(sslBundles)); return properties; } @@ -191,13 +210,29 @@ public Map buildConsumerProperties() { * Create an initial map of producer properties from the state of this instance. *

* This allows you to add additional properties, if necessary, and override the - * default kafkaProducerFactory bean. + * default {@code kafkaProducerFactory} bean. * @return the producer properties initialized with the customizations defined on this * instance + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link #buildProducerProperties(SslBundles)}} */ + @Deprecated(since = "3.2.0", forRemoval = true) public Map buildProducerProperties() { - Map properties = buildCommonProperties(); - properties.putAll(this.producer.buildProperties()); + return buildProducerProperties(null); + } + + /** + * Create an initial map of producer properties from the state of this instance. + *

+ * This allows you to add additional properties, if necessary, and override the + * default {@code kafkaProducerFactory} bean. + * @param sslBundles bundles providing SSL trust material + * @return the producer properties initialized with the customizations defined on this + * instance + */ + public Map buildProducerProperties(SslBundles sslBundles) { + Map properties = buildCommonProperties(sslBundles); + properties.putAll(this.producer.buildProperties(sslBundles)); return properties; } @@ -205,13 +240,29 @@ public Map buildProducerProperties() { * Create an initial map of admin properties from the state of this instance. *

* This allows you to add additional properties, if necessary, and override the - * default kafkaAdmin bean. + * default {@code kafkaAdmin} bean. * @return the admin properties initialized with the customizations defined on this * instance + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link #buildAdminProperties(SslBundles)}} */ + @Deprecated(since = "3.2.0", forRemoval = true) public Map buildAdminProperties() { - Map properties = buildCommonProperties(); - properties.putAll(this.admin.buildProperties()); + return buildAdminProperties(null); + } + + /** + * Create an initial map of admin properties from the state of this instance. + *

+ * This allows you to add additional properties, if necessary, and override the + * default {@code kafkaAdmin} bean. + * @param sslBundles bundles providing SSL trust material + * @return the admin properties initialized with the customizations defined on this + * instance + */ + public Map buildAdminProperties(SslBundles sslBundles) { + Map properties = buildCommonProperties(sslBundles); + properties.putAll(this.admin.buildProperties(sslBundles)); return properties; } @@ -221,10 +272,25 @@ public Map buildAdminProperties() { * This allows you to add additional properties, if necessary. * @return the streams properties initialized with the customizations defined on this * instance + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link #buildStreamsProperties(SslBundles)}} */ + @Deprecated(since = "3.2.0", forRemoval = true) public Map buildStreamsProperties() { - Map properties = buildCommonProperties(); - properties.putAll(this.streams.buildProperties()); + return buildStreamsProperties(null); + } + + /** + * Create an initial map of streams properties from the state of this instance. + *

+ * This allows you to add additional properties, if necessary. + * @param sslBundles bundles providing SSL trust material + * @return the streams properties initialized with the customizations defined on this + * instance + */ + public Map buildStreamsProperties(SslBundles sslBundles) { + Map properties = buildCommonProperties(sslBundles); + properties.putAll(this.streams.buildProperties(sslBundles)); return properties; } @@ -426,7 +492,7 @@ public Map getProperties() { return this.properties; } - public Map buildProperties() { + public Map buildProperties(SslBundles sslBundles) { Properties properties = new Properties(); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(this::getAutoCommitInterval) @@ -451,7 +517,7 @@ public Map buildProperties() { map.from(this::getKeyDeserializer).to(properties.in(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG)); map.from(this::getValueDeserializer).to(properties.in(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG)); map.from(this::getMaxPollRecords).to(properties.in(ConsumerConfig.MAX_POLL_RECORDS_CONFIG)); - return properties.with(this.ssl, this.security, this.properties); + return properties.with(this.ssl, this.security, this.properties, sslBundles); } } @@ -613,7 +679,7 @@ public Map getProperties() { return this.properties; } - public Map buildProperties() { + public Map buildProperties(SslBundles sslBundles) { Properties properties = new Properties(); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(this::getAcks).to(properties.in(ProducerConfig.ACKS_CONFIG)); @@ -627,7 +693,7 @@ public Map buildProperties() { map.from(this::getKeySerializer).to(properties.in(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG)); map.from(this::getRetries).to(properties.in(ProducerConfig.RETRIES_CONFIG)); map.from(this::getValueSerializer).to(properties.in(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG)); - return properties.with(this.ssl, this.security, this.properties); + return properties.with(this.ssl, this.security, this.properties, sslBundles); } } @@ -734,11 +800,11 @@ public Map getProperties() { return this.properties; } - public Map buildProperties() { + public Map buildProperties(SslBundles sslBundles) { Properties properties = new Properties(); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(this::getClientId).to(properties.in(ProducerConfig.CLIENT_ID_CONFIG)); - return properties.with(this.ssl, this.security, this.properties); + return properties.with(this.ssl, this.security, this.properties, sslBundles); } } @@ -770,11 +836,6 @@ public static class Streams { */ private List bootstrapServers; - /** - * Maximum memory size to be used for buffering across all threads. - */ - private DataSize cacheMaxSizeBuffering; - /** * Maximum size of the in-memory state store cache across all threads. */ @@ -837,17 +898,6 @@ public void setBootstrapServers(List bootstrapServers) { this.bootstrapServers = bootstrapServers; } - @DeprecatedConfigurationProperty(replacement = "spring.kafka.streams.state-store-cache-max-size") - @Deprecated(since = "3.1.0", forRemoval = true) - public DataSize getCacheMaxSizeBuffering() { - return this.cacheMaxSizeBuffering; - } - - @Deprecated(since = "3.1.0", forRemoval = true) - public void setCacheMaxSizeBuffering(DataSize cacheMaxSizeBuffering) { - this.cacheMaxSizeBuffering = cacheMaxSizeBuffering; - } - public DataSize getStateStoreCacheMaxSize() { return this.stateStoreCacheMaxSize; } @@ -884,21 +934,18 @@ public Map getProperties() { return this.properties; } - public Map buildProperties() { + public Map buildProperties(SslBundles sslBundles) { Properties properties = new Properties(); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(this::getApplicationId).to(properties.in("application.id")); map.from(this::getBootstrapServers).to(properties.in(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG)); - map.from(this::getCacheMaxSizeBuffering) - .asInt(DataSize::toBytes) - .to(properties.in("cache.max.bytes.buffering")); map.from(this::getStateStoreCacheMaxSize) .asInt(DataSize::toBytes) .to(properties.in("statestore.cache.max.bytes")); map.from(this::getClientId).to(properties.in(CommonClientConfigs.CLIENT_ID_CONFIG)); map.from(this::getReplicationFactor).to(properties.in("replication.factor")); map.from(this::getStateDir).to(properties.in("state.dir")); - return properties.with(this.ssl, this.security, this.properties); + return properties.with(this.ssl, this.security, this.properties, sslBundles); } } @@ -916,6 +963,11 @@ public static class Template { */ private String transactionIdPrefix; + /** + * Whether to enable observation. + */ + private boolean observationEnabled; + public String getDefaultTopic() { return this.defaultTopic; } @@ -932,6 +984,14 @@ public void setTransactionIdPrefix(String transactionIdPrefix) { this.transactionIdPrefix = transactionIdPrefix; } + public boolean isObservationEnabled() { + return this.observationEnabled; + } + + public void setObservationEnabled(boolean observationEnabled) { + this.observationEnabled = observationEnabled; + } + } public static class Listener { @@ -1043,6 +1103,17 @@ public enum Type { */ private boolean autoStartup = true; + /** + * Whether to instruct the container to change the consumer thread name during + * initialization. + */ + private Boolean changeConsumerThreadName; + + /** + * Whether to enable observation. + */ + private boolean observationEnabled; + public Type getType() { return this.type; } @@ -1179,10 +1250,31 @@ public void setAutoStartup(boolean autoStartup) { this.autoStartup = autoStartup; } + public Boolean getChangeConsumerThreadName() { + return this.changeConsumerThreadName; + } + + public void setChangeConsumerThreadName(Boolean changeConsumerThreadName) { + this.changeConsumerThreadName = changeConsumerThreadName; + } + + public boolean isObservationEnabled() { + return this.observationEnabled; + } + + public void setObservationEnabled(boolean observationEnabled) { + this.observationEnabled = observationEnabled; + } + } public static class Ssl { + /** + * Name of the SSL bundle to use. + */ + private String bundle; + /** * Password of the private key in either key store key or key store file. */ @@ -1238,6 +1330,14 @@ public static class Ssl { */ private String protocol; + public String getBundle() { + return this.bundle; + } + + public void setBundle(String bundle) { + this.bundle = bundle; + } + public String getKeyPassword() { return this.keyPassword; } @@ -1326,26 +1426,39 @@ public void setProtocol(String protocol) { this.protocol = protocol; } + @Deprecated(since = "3.2.0", forRemoval = true) public Map buildProperties() { + return buildProperties(null); + } + + public Map buildProperties(SslBundles sslBundles) { validate(); Properties properties = new Properties(); - PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); - map.from(this::getKeyPassword).to(properties.in(SslConfigs.SSL_KEY_PASSWORD_CONFIG)); - map.from(this::getKeyStoreCertificateChain) - .to(properties.in(SslConfigs.SSL_KEYSTORE_CERTIFICATE_CHAIN_CONFIG)); - map.from(this::getKeyStoreKey).to(properties.in(SslConfigs.SSL_KEYSTORE_KEY_CONFIG)); - map.from(this::getKeyStoreLocation) - .as(this::resourceToPath) - .to(properties.in(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG)); - map.from(this::getKeyStorePassword).to(properties.in(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG)); - map.from(this::getKeyStoreType).to(properties.in(SslConfigs.SSL_KEYSTORE_TYPE_CONFIG)); - map.from(this::getTrustStoreCertificates).to(properties.in(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG)); - map.from(this::getTrustStoreLocation) - .as(this::resourceToPath) - .to(properties.in(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG)); - map.from(this::getTrustStorePassword).to(properties.in(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG)); - map.from(this::getTrustStoreType).to(properties.in(SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG)); - map.from(this::getProtocol).to(properties.in(SslConfigs.SSL_PROTOCOL_CONFIG)); + if (getBundle() != null) { + properties.in(SslConfigs.SSL_ENGINE_FACTORY_CLASS_CONFIG) + .accept(SslBundleSslEngineFactory.class.getName()); + properties.in(SslBundle.class.getName()).accept(sslBundles.getBundle(getBundle())); + } + else { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this::getKeyPassword).to(properties.in(SslConfigs.SSL_KEY_PASSWORD_CONFIG)); + map.from(this::getKeyStoreCertificateChain) + .to(properties.in(SslConfigs.SSL_KEYSTORE_CERTIFICATE_CHAIN_CONFIG)); + map.from(this::getKeyStoreKey).to(properties.in(SslConfigs.SSL_KEYSTORE_KEY_CONFIG)); + map.from(this::getKeyStoreLocation) + .as(this::resourceToPath) + .to(properties.in(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG)); + map.from(this::getKeyStorePassword).to(properties.in(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG)); + map.from(this::getKeyStoreType).to(properties.in(SslConfigs.SSL_KEYSTORE_TYPE_CONFIG)); + map.from(this::getTrustStoreCertificates) + .to(properties.in(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG)); + map.from(this::getTrustStoreLocation) + .as(this::resourceToPath) + .to(properties.in(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG)); + map.from(this::getTrustStorePassword).to(properties.in(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG)); + map.from(this::getTrustStoreType).to(properties.in(SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG)); + map.from(this::getProtocol).to(properties.in(SslConfigs.SSL_PROTOCOL_CONFIG)); + } return properties; } @@ -1358,6 +1471,22 @@ private void validate() { entries.put("spring.kafka.ssl.trust-store-certificates", getTrustStoreCertificates()); entries.put("spring.kafka.ssl.trust-store-location", getTrustStoreLocation()); }); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("spring.kafka.ssl.bundle", getBundle()); + entries.put("spring.kafka.ssl.key-store-key", getKeyStoreKey()); + }); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("spring.kafka.ssl.bundle", getBundle()); + entries.put("spring.kafka.ssl.key-store-location", getKeyStoreLocation()); + }); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("spring.kafka.ssl.bundle", getBundle()); + entries.put("spring.kafka.ssl.trust-store-certificates", getTrustStoreCertificates()); + }); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("spring.kafka.ssl.bundle", getBundle()); + entries.put("spring.kafka.ssl.trust-store-location", getTrustStoreLocation()); + }); } private String resourceToPath(Resource resource) { @@ -1613,8 +1742,8 @@ java.util.function.Consumer in(String key) { return (value) -> put(key, value); } - Properties with(Ssl ssl, Security security, Map properties) { - putAll(ssl.buildProperties()); + Properties with(Ssl ssl, Security security, Map properties, SslBundles sslBundles) { + putAll(ssl.buildProperties(sslBundles)); putAll(security.buildProperties()); putAll(properties); return this; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaStreamsAnnotationDrivenConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaStreamsAnnotationDrivenConfiguration.java index 28e35d3699f4..701384326303 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaStreamsAnnotationDrivenConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/KafkaStreamsAnnotationDrivenConfiguration.java @@ -30,6 +30,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; @@ -46,6 +47,7 @@ * @author Eddú Meléndez * @author Moritz Halbritter * @author Andy Wilkinson + * @author Scott Frederick */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(StreamsBuilder.class) @@ -61,8 +63,8 @@ class KafkaStreamsAnnotationDrivenConfiguration { @ConditionalOnMissingBean @Bean(KafkaStreamsDefaultConfiguration.DEFAULT_STREAMS_CONFIG_BEAN_NAME) KafkaStreamsConfiguration defaultKafkaStreamsConfig(Environment environment, - KafkaConnectionDetails connectionDetails) { - Map properties = this.properties.buildStreamsProperties(); + KafkaConnectionDetails connectionDetails, ObjectProvider sslBundles) { + Map properties = this.properties.buildStreamsProperties(sslBundles.getIfAvailable()); applyKafkaConnectionDetailsForStreams(properties, connectionDetails); if (this.properties.getStreams().getApplicationId() == null) { String applicationName = environment.getProperty("spring.application.name"); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/SslBundleSslEngineFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/SslBundleSslEngineFactory.java new file mode 100644 index 000000000000..5c5e93ebf56a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/kafka/SslBundleSslEngineFactory.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-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.boot.autoconfigure.kafka; + +import java.io.IOException; +import java.security.KeyStore; +import java.util.Map; +import java.util.Set; + +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; + +import org.apache.kafka.common.security.auth.SslEngineFactory; + +import org.springframework.boot.ssl.SslBundle; + +/** + * An {@link SslEngineFactory} that configures creates an {@link SSLEngine} from an + * {@link SslBundle}. + * + * @author Andy Wilkinson + * @author Scott Frederick + * @since 3.2.0 + */ +public class SslBundleSslEngineFactory implements SslEngineFactory { + + private static final String SSL_BUNDLE_CONFIG_NAME = SslBundle.class.getName(); + + private Map configs; + + private volatile SslBundle sslBundle; + + @Override + public void configure(Map configs) { + this.configs = configs; + this.sslBundle = (SslBundle) configs.get(SSL_BUNDLE_CONFIG_NAME); + } + + @Override + public void close() throws IOException { + + } + + @Override + public SSLEngine createClientSslEngine(String peerHost, int peerPort, String endpointIdentification) { + SSLEngine sslEngine = this.sslBundle.createSslContext().createSSLEngine(peerHost, peerPort); + sslEngine.setUseClientMode(true); + SSLParameters sslParams = sslEngine.getSSLParameters(); + sslParams.setEndpointIdentificationAlgorithm(endpointIdentification); + sslEngine.setSSLParameters(sslParams); + return sslEngine; + } + + @Override + public SSLEngine createServerSslEngine(String peerHost, int peerPort) { + SSLEngine sslEngine = this.sslBundle.createSslContext().createSSLEngine(peerHost, peerPort); + sslEngine.setUseClientMode(false); + return sslEngine; + } + + @Override + public boolean shouldBeRebuilt(Map nextConfigs) { + return !nextConfigs.equals(this.configs); + } + + @Override + public Set reconfigurableConfigs() { + return Set.of(SSL_BUNDLE_CONFIG_NAME); + } + + @Override + public KeyStore keystore() { + return this.sslBundle.getStores().getKeyStore(); + } + + @Override + public KeyStore truststore() { + return this.sslBundle.getStores().getTrustStore(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/LdapAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/LdapAutoConfiguration.java index 0144f9bf99ae..205af4c3e0ac 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/LdapAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/LdapAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,18 +46,25 @@ @EnableConfigurationProperties(LdapProperties.class) public class LdapAutoConfiguration { + @Bean + @ConditionalOnMissingBean(LdapConnectionDetails.class) + PropertiesLdapConnectionDetails propertiesLdapConnectionDetails(LdapProperties properties, + Environment environment) { + return new PropertiesLdapConnectionDetails(properties, environment); + } + @Bean @ConditionalOnMissingBean - public LdapContextSource ldapContextSource(LdapProperties properties, Environment environment, + public LdapContextSource ldapContextSource(LdapConnectionDetails connectionDetails, LdapProperties properties, ObjectProvider dirContextAuthenticationStrategy) { LdapContextSource source = new LdapContextSource(); dirContextAuthenticationStrategy.ifUnique(source::setAuthenticationStrategy); PropertyMapper propertyMapper = PropertyMapper.get().alwaysApplyingWhenNonNull(); - propertyMapper.from(properties.getUsername()).to(source::setUserDn); - propertyMapper.from(properties.getPassword()).to(source::setPassword); + propertyMapper.from(connectionDetails.getUsername()).to(source::setUserDn); + propertyMapper.from(connectionDetails.getPassword()).to(source::setPassword); propertyMapper.from(properties.getAnonymousReadOnly()).to(source::setAnonymousReadOnly); - propertyMapper.from(properties.getBase()).to(source::setBase); - propertyMapper.from(properties.determineUrls(environment)).to(source::setUrls); + propertyMapper.from(connectionDetails.getBase()).to(source::setBase); + propertyMapper.from(connectionDetails.getUrls()).to(source::setUrls); propertyMapper.from(properties.getBaseEnvironment()) .to((baseEnvironment) -> source.setBaseEnvironmentProperties(Collections.unmodifiableMap(baseEnvironment))); return source; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/LdapConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/LdapConnectionDetails.java new file mode 100644 index 000000000000..efec54659440 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/LdapConnectionDetails.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ldap; + +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; + +/** + * Details required to establish a connection to an LDAP service. + * + * @author Philipp Kessler + * @since 3.3.0 + */ +public interface LdapConnectionDetails extends ConnectionDetails { + + /** + * LDAP URLs of the server. + * @return the LDAP URLs to use + */ + String[] getUrls(); + + /** + * Base suffix from which all operations should originate. + * @return base suffix + */ + default String getBase() { + return null; + } + + /** + * Login username of the server. + * @return login username + */ + default String getUsername() { + return null; + } + + /** + * Login password of the server. + * @return login password + */ + default String getPassword() { + return null; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/PropertiesLdapConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/PropertiesLdapConnectionDetails.java new file mode 100644 index 000000000000..517adc811663 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ldap/PropertiesLdapConnectionDetails.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ldap; + +import org.springframework.core.env.Environment; + +/** + * Adapts {@link LdapProperties} to {@link LdapConnectionDetails}. + * + * @author Philipp Kessler + * @since 3.3.0 + */ +public class PropertiesLdapConnectionDetails implements LdapConnectionDetails { + + private final LdapProperties properties; + + private final Environment environment; + + PropertiesLdapConnectionDetails(LdapProperties properties, Environment environment) { + this.properties = properties; + this.environment = environment; + } + + @Override + public String[] getUrls() { + return this.properties.determineUrls(this.environment); + } + + @Override + public String getBase() { + return this.properties.getBase(); + } + + @Override + public String getUsername() { + return this.properties.getUsername(); + } + + @Override + public String getPassword() { + return this.properties.getPassword(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java index f19ab38f011b..979053511255 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java @@ -18,6 +18,8 @@ import javax.sql.DataSource; +import liquibase.UpdateSummaryEnum; +import liquibase.UpdateSummaryOutputEnum; import liquibase.change.DatabaseChange; import liquibase.integration.spring.SpringLiquibase; @@ -113,6 +115,13 @@ public SpringLiquibase liquibase(ObjectProvider dataSource, liquibase.setRollbackFile(properties.getRollbackFile()); liquibase.setTestRollbackOnUpdate(properties.isTestRollbackOnUpdate()); liquibase.setTag(properties.getTag()); + if (properties.getShowSummary() != null) { + liquibase.setShowSummary(UpdateSummaryEnum.valueOf(properties.getShowSummary().name())); + } + if (properties.getShowSummaryOutput() != null) { + liquibase + .setShowSummaryOutput(UpdateSummaryOutputEnum.valueOf(properties.getShowSummaryOutput().name())); + } return liquibase; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseProperties.java index 478897d053d9..7b32afdadfde 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -19,10 +19,11 @@ import java.io.File; import java.util.Map; +import liquibase.UpdateSummaryEnum; +import liquibase.UpdateSummaryOutputEnum; import liquibase.integration.spring.SpringLiquibase; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; import org.springframework.util.Assert; /** @@ -136,6 +137,16 @@ public class LiquibaseProperties { */ private String tag; + /** + * Whether to print a summary of the update operation. + */ + private ShowSummary showSummary; + + /** + * Where to print a summary of the update operation. + */ + private ShowSummaryOutput showSummaryOutput; + public String getChangeLog() { return this.changeLog; } @@ -257,17 +268,6 @@ public void setLabelFilter(String labelFilter) { this.labelFilter = labelFilter; } - @Deprecated(since = "3.0.0", forRemoval = true) - @DeprecatedConfigurationProperty(replacement = "spring.liquibase.label-filter") - public String getLabels() { - return getLabelFilter(); - } - - @Deprecated(since = "3.0.0", forRemoval = true) - public void setLabels(String labels) { - setLabelFilter(labels); - } - public Map getParameters() { return this.parameters; } @@ -300,4 +300,72 @@ public void setTag(String tag) { this.tag = tag; } + public ShowSummary getShowSummary() { + return this.showSummary; + } + + public void setShowSummary(ShowSummary showSummary) { + this.showSummary = showSummary; + } + + public ShowSummaryOutput getShowSummaryOutput() { + return this.showSummaryOutput; + } + + public void setShowSummaryOutput(ShowSummaryOutput showSummaryOutput) { + this.showSummaryOutput = showSummaryOutput; + } + + /** + * Enumeration of types of summary to show. Values are the same as those on + * {@link UpdateSummaryEnum}. To maximize backwards compatibility, the Liquibase enum + * is not used directly. + * + * @since 3.2.1 + */ + public enum ShowSummary { + + /** + * Do not show a summary. + */ + OFF, + + /** + * Show a summary. + */ + SUMMARY, + + /** + * Show a verbose summary. + */ + VERBOSE + + } + + /** + * Enumeration of destinations to which the summary should be output. Values are the + * same as those on {@link UpdateSummaryOutputEnum}. To maximize backwards + * compatibility, the Liquibase enum is not used directly. + * + * @since 3.2.1 + */ + public enum ShowSummaryOutput { + + /** + * Log the summary. + */ + LOG, + + /** + * Output the summary to the console. + */ + CONSOLE, + + /** + * Log the summary and output it to the console. + */ + ALL + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLogger.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLogger.java index 49a3036fd67d..1af820991ab5 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLogger.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLogger.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -80,7 +80,7 @@ else if (isCrashReport) { private void logMessage(String logLevel) { this.logger.info(String.format("%n%nError starting ApplicationContext. To display the " - + "condition evaluation report re-run your application with '" + logLevel + "' enabled.")); + + "condition evaluation report re-run your application with '%s' enabled.", logLevel)); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizer.java deleted file mode 100644 index 691064cd5e69..000000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizer.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2012-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.boot.autoconfigure.mongo; - -import java.util.ArrayList; -import java.util.List; - -import com.mongodb.ConnectionString; -import com.mongodb.MongoClientSettings; -import com.mongodb.MongoCredential; -import com.mongodb.ServerAddress; - -import org.springframework.core.Ordered; -import org.springframework.util.CollectionUtils; - -/** - * A {@link MongoClientSettingsBuilderCustomizer} that applies properties from a - * {@link MongoProperties} to a {@link MongoClientSettings}. - * - * @author Scott Frederick - * @author Safeer Ansari - * @since 2.4.0 - * @deprecated since 3.1.0 for removal in 3.3.0 in favor of - * {@link StandardMongoClientSettingsBuilderCustomizer} - */ -@Deprecated(since = "3.1.0", forRemoval = true) -public class MongoPropertiesClientSettingsBuilderCustomizer implements MongoClientSettingsBuilderCustomizer, Ordered { - - private final MongoProperties properties; - - private int order = 0; - - public MongoPropertiesClientSettingsBuilderCustomizer(MongoProperties properties) { - this.properties = properties; - } - - @Override - public void customize(MongoClientSettings.Builder settingsBuilder) { - applyUuidRepresentation(settingsBuilder); - applyHostAndPort(settingsBuilder); - applyCredentials(settingsBuilder); - applyReplicaSet(settingsBuilder); - } - - private void applyUuidRepresentation(MongoClientSettings.Builder settingsBuilder) { - settingsBuilder.uuidRepresentation(this.properties.getUuidRepresentation()); - } - - private void applyHostAndPort(MongoClientSettings.Builder settings) { - if (this.properties.getUri() != null) { - settings.applyConnectionString(new ConnectionString(this.properties.getUri())); - return; - } - if (this.properties.getHost() != null || this.properties.getPort() != null) { - String host = getOrDefault(this.properties.getHost(), "localhost"); - int port = getOrDefault(this.properties.getPort(), MongoProperties.DEFAULT_PORT); - List serverAddresses = new ArrayList<>(); - serverAddresses.add(new ServerAddress(host, port)); - if (!CollectionUtils.isEmpty(this.properties.getAdditionalHosts())) { - this.properties.getAdditionalHosts().stream().map(ServerAddress::new).forEach(serverAddresses::add); - } - settings.applyToClusterSettings((cluster) -> cluster.hosts(serverAddresses)); - return; - } - settings.applyConnectionString(new ConnectionString(MongoProperties.DEFAULT_URI)); - } - - private void applyCredentials(MongoClientSettings.Builder builder) { - if (this.properties.getUri() == null && this.properties.getUsername() != null - && this.properties.getPassword() != null) { - String database = (this.properties.getAuthenticationDatabase() != null) - ? this.properties.getAuthenticationDatabase() : this.properties.getMongoClientDatabase(); - builder.credential((MongoCredential.createCredential(this.properties.getUsername(), database, - this.properties.getPassword()))); - } - } - - private void applyReplicaSet(MongoClientSettings.Builder builder) { - if (this.properties.getReplicaSetName() != null) { - builder.applyToClusterSettings( - (cluster) -> cluster.requiredReplicaSetName(this.properties.getReplicaSetName())); - } - } - - private V getOrDefault(V value, V defaultValue) { - return (value != null) ? value : defaultValue; - } - - @Override - public int getOrder() { - return this.order; - } - - /** - * Set the order value of this object. - * @param order the new order value - * @see #getOrder() - */ - public void setOrder(int order) { - this.order = order; - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfiguration.java index 1157e002945a..5426b6f17fc9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfiguration.java @@ -18,7 +18,7 @@ import com.mongodb.MongoClientSettings; import com.mongodb.MongoClientSettings.Builder; -import com.mongodb.connection.netty.NettyStreamFactoryFactory; +import com.mongodb.connection.TransportSettings; import com.mongodb.reactivestreams.client.MongoClient; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; @@ -113,11 +113,10 @@ static final class NettyDriverMongoClientSettingsBuilderCustomizer @Override public void customize(Builder builder) { - if (!isStreamFactoryFactoryDefined(this.settings.getIfAvailable())) { + if (!isCustomTransportConfiguration(this.settings.getIfAvailable())) { NioEventLoopGroup eventLoopGroup = new NioEventLoopGroup(); this.eventLoopGroup = eventLoopGroup; - builder - .streamFactoryFactory(NettyStreamFactoryFactory.builder().eventLoopGroup(eventLoopGroup).build()); + builder.transportSettings(TransportSettings.nettyBuilder().eventLoopGroup(eventLoopGroup).build()); } } @@ -130,8 +129,10 @@ public void destroy() { } } - private boolean isStreamFactoryFactoryDefined(MongoClientSettings settings) { - return settings != null && settings.getStreamFactoryFactory() != null; + @SuppressWarnings("deprecation") + private boolean isCustomTransportConfiguration(MongoClientSettings settings) { + return settings != null + && (settings.getTransportSettings() != null || settings.getStreamFactoryFactory() != null); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfiguration.java index 2392227b2015..ed4e87c7e2fb 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfiguration.java @@ -24,6 +24,7 @@ import java.util.concurrent.TimeUnit; import org.neo4j.driver.AuthToken; +import org.neo4j.driver.AuthTokenManager; import org.neo4j.driver.AuthTokens; import org.neo4j.driver.Config; import org.neo4j.driver.Config.TrustStrategy; @@ -63,8 +64,9 @@ public class Neo4jAutoConfiguration { @Bean @ConditionalOnMissingBean(Neo4jConnectionDetails.class) - PropertiesNeo4jConnectionDetails neo4jConnectionDetails(Neo4jProperties properties) { - return new PropertiesNeo4jConnectionDetails(properties); + PropertiesNeo4jConnectionDetails neo4jConnectionDetails(Neo4jProperties properties, + ObjectProvider authTokenManager) { + return new PropertiesNeo4jConnectionDetails(properties, authTokenManager.getIfUnique()); } @Bean @@ -72,9 +74,14 @@ PropertiesNeo4jConnectionDetails neo4jConnectionDetails(Neo4jProperties properti public Driver neo4jDriver(Neo4jProperties properties, Environment environment, Neo4jConnectionDetails connectionDetails, ObjectProvider configBuilderCustomizers) { - AuthToken authToken = connectionDetails.getAuthToken(); + Config config = mapDriverConfig(properties, connectionDetails, configBuilderCustomizers.orderedStream().toList()); + AuthTokenManager authTokenManager = connectionDetails.getAuthTokenManager(); + if (authTokenManager != null) { + return GraphDatabase.driver(connectionDetails.getUri(), authTokenManager, config); + } + AuthToken authToken = connectionDetails.getAuthToken(); return GraphDatabase.driver(connectionDetails.getUri(), authToken, config); } @@ -179,8 +186,11 @@ static class PropertiesNeo4jConnectionDetails implements Neo4jConnectionDetails private final Neo4jProperties properties; - PropertiesNeo4jConnectionDetails(Neo4jProperties properties) { + private final AuthTokenManager authTokenManager; + + PropertiesNeo4jConnectionDetails(Neo4jProperties properties, AuthTokenManager authTokenManager) { this.properties = properties; + this.authTokenManager = authTokenManager; } @Override @@ -209,6 +219,11 @@ public AuthToken getAuthToken() { return AuthTokens.none(); } + @Override + public AuthTokenManager getAuthTokenManager() { + return this.authTokenManager; + } + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jConnectionDetails.java index 17a950ebd8bd..f10a122338b9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jConnectionDetails.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/neo4j/Neo4jConnectionDetails.java @@ -19,6 +19,7 @@ import java.net.URI; import org.neo4j.driver.AuthToken; +import org.neo4j.driver.AuthTokenManager; import org.neo4j.driver.AuthTokens; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; @@ -49,4 +50,14 @@ default AuthToken getAuthToken() { return AuthTokens.none(); } + /** + * Returns the {@link AuthTokenManager} to use for authentication. Defaults to + * {@code null} in which case the {@link #getAuthToken() auth token} should be used. + * @return the auth token manager + * @since 3.2.0 + */ + default AuthTokenManager getAuthTokenManager() { + return null; + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfiguration.java index b073e0c7c3ee..807a0b74b2ad 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfiguration.java @@ -25,6 +25,7 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Import; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; @@ -38,7 +39,8 @@ * @author Andy Wilkinson * @since 1.0.0 */ -@AutoConfiguration(after = DataSourceAutoConfiguration.class, +@AutoConfiguration( + after = { DataSourceAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class }, before = { TransactionAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class }) @ConditionalOnClass({ LocalContainerEntityManagerFactoryBean.class, EntityManager.class, SessionImplementor.class }) @EnableConfigurationProperties(JpaProperties.class) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/JpaBaseConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/JpaBaseConfiguration.java index 0520ff094866..90355acc6692 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/JpaBaseConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/orm/jpa/JpaBaseConfiguration.java @@ -93,7 +93,8 @@ protected JpaBaseConfiguration(DataSource dataSource, JpaProperties properties, public PlatformTransactionManager transactionManager( ObjectProvider transactionManagerCustomizers) { JpaTransactionManager transactionManager = new JpaTransactionManager(); - transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager)); + transactionManagerCustomizers + .ifAvailable((customizers) -> customizers.customize((TransactionManager) transactionManager)); return transactionManager; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java new file mode 100644 index 000000000000..fc4a4f64b6bc --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.apache.pulsar.client.api.DeadLetterPolicy.DeadLetterPolicyBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.util.Assert; + +/** + * Helper class used to map {@link PulsarProperties.Consumer.DeadLetterPolicy dead letter + * policy properties}. + * + * @author Chris Bono + * @author Phillip Webb + */ +final class DeadLetterPolicyMapper { + + private DeadLetterPolicyMapper() { + } + + static DeadLetterPolicy map(PulsarProperties.Consumer.DeadLetterPolicy policy) { + Assert.state(policy.getMaxRedeliverCount() > 0, + "Pulsar DeadLetterPolicy must have a positive 'max-redelivery-count' property value"); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + DeadLetterPolicyBuilder builder = DeadLetterPolicy.builder(); + map.from(policy::getMaxRedeliverCount).to(builder::maxRedeliverCount); + map.from(policy::getRetryLetterTopic).to(builder::retryLetterTopic); + map.from(policy::getDeadLetterTopic).to(builder::deadLetterTopic); + map.from(policy::getInitialSubscriptionName).to(builder::initialSubscriptionName); + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/SslConfigurationValidator.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetails.java similarity index 53% rename from spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/SslConfigurationValidator.java rename to spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetails.java index d044dfa29dc1..51ed0fadc322 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/SslConfigurationValidator.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetails.java @@ -14,28 +14,29 @@ * limitations under the License. */ -package org.springframework.boot.web.server; - -import java.security.KeyStore; - -import org.springframework.boot.ssl.SslBundleKey; +package org.springframework.boot.autoconfigure.pulsar; /** - * Provides utilities around SSL. + * Adapts {@link PulsarProperties} to {@link PulsarConnectionDetails}. * * @author Chris Bono - * @since 2.1.13 - * @deprecated since 3.1.0 for removal in 3.3.0 in favor of - * {@link SslBundleKey#assertContainsAlias(KeyStore)} */ -@Deprecated(since = "3.1.0", forRemoval = true) -public final class SslConfigurationValidator { +class PropertiesPulsarConnectionDetails implements PulsarConnectionDetails { + + private final PulsarProperties pulsarProperties; + + PropertiesPulsarConnectionDetails(PulsarProperties pulsarProperties) { + this.pulsarProperties = pulsarProperties; + } - private SslConfigurationValidator() { + @Override + public String getBrokerUrl() { + return this.pulsarProperties.getClient().getServiceUrl(); } - public static void validateKeyAlias(KeyStore keyStore, String keyAlias) { - SslBundleKey.of(null, keyAlias).assertContainsAlias(keyStore); + @Override + public String getAdminUrl() { + return this.pulsarProperties.getAdmin().getServiceUrl(); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java new file mode 100644 index 000000000000..c60565b5918f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java @@ -0,0 +1,205 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.ReaderBuilder; +import org.apache.pulsar.client.api.interceptor.ProducerInterceptor; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; +import org.springframework.core.task.VirtualThreadTaskExecutor; +import org.springframework.pulsar.annotation.EnablePulsar; +import org.springframework.pulsar.config.ConcurrentPulsarListenerContainerFactory; +import org.springframework.pulsar.config.DefaultPulsarReaderContainerFactory; +import org.springframework.pulsar.config.PulsarAnnotationSupportBeanNames; +import org.springframework.pulsar.core.CachingPulsarProducerFactory; +import org.springframework.pulsar.core.ConsumerBuilderCustomizer; +import org.springframework.pulsar.core.DefaultPulsarConsumerFactory; +import org.springframework.pulsar.core.DefaultPulsarProducerFactory; +import org.springframework.pulsar.core.DefaultPulsarReaderFactory; +import org.springframework.pulsar.core.ProducerBuilderCustomizer; +import org.springframework.pulsar.core.PulsarConsumerFactory; +import org.springframework.pulsar.core.PulsarProducerFactory; +import org.springframework.pulsar.core.PulsarReaderFactory; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.pulsar.core.ReaderBuilderCustomizer; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.listener.PulsarContainerProperties; +import org.springframework.pulsar.reader.PulsarReaderContainerProperties; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Apache Pulsar. + * + * @author Chris Bono + * @author Soby Chacko + * @author Alexander Preuß + * @author Phillip Webb + * @since 3.2.0 + */ +@AutoConfiguration +@ConditionalOnClass({ PulsarClient.class, PulsarTemplate.class }) +@Import(PulsarConfiguration.class) +public class PulsarAutoConfiguration { + + private PulsarProperties properties; + + private PulsarPropertiesMapper propertiesMapper; + + PulsarAutoConfiguration(PulsarProperties properties) { + this.properties = properties; + this.propertiesMapper = new PulsarPropertiesMapper(properties); + } + + @Bean + @ConditionalOnMissingBean(PulsarProducerFactory.class) + @ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "false") + DefaultPulsarProducerFactory pulsarProducerFactory(PulsarClient pulsarClient, TopicResolver topicResolver, + ObjectProvider> customizersProvider) { + List> lambdaSafeCustomizers = lambdaSafeProducerBuilderCustomizers( + customizersProvider); + return new DefaultPulsarProducerFactory<>(pulsarClient, this.properties.getProducer().getTopicName(), + lambdaSafeCustomizers, topicResolver); + } + + @Bean + @ConditionalOnMissingBean(PulsarProducerFactory.class) + @ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true) + CachingPulsarProducerFactory cachingPulsarProducerFactory(PulsarClient pulsarClient, TopicResolver topicResolver, + ObjectProvider> customizersProvider) { + PulsarProperties.Producer.Cache cacheProperties = this.properties.getProducer().getCache(); + List> lambdaSafeCustomizers = lambdaSafeProducerBuilderCustomizers( + customizersProvider); + return new CachingPulsarProducerFactory<>(pulsarClient, this.properties.getProducer().getTopicName(), + lambdaSafeCustomizers, topicResolver, cacheProperties.getExpireAfterAccess(), + cacheProperties.getMaximumSize(), cacheProperties.getInitialCapacity()); + } + + private List> lambdaSafeProducerBuilderCustomizers( + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeProducerBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + return List.of((builder) -> applyProducerBuilderCustomizers(customizers, builder)); + } + + @SuppressWarnings("unchecked") + private void applyProducerBuilderCustomizers(List> customizers, + ProducerBuilder builder) { + LambdaSafe.callbacks(ProducerBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean + PulsarTemplate pulsarTemplate(PulsarProducerFactory pulsarProducerFactory, + ObjectProvider producerInterceptors, SchemaResolver schemaResolver, + TopicResolver topicResolver) { + return new PulsarTemplate<>(pulsarProducerFactory, producerInterceptors.orderedStream().toList(), + schemaResolver, topicResolver, this.properties.getTemplate().isObservationsEnabled()); + } + + @Bean + @ConditionalOnMissingBean(PulsarConsumerFactory.class) + DefaultPulsarConsumerFactory pulsarConsumerFactory(PulsarClient pulsarClient, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeConsumerBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyConsumerBuilderCustomizers(customizers, builder)); + return new DefaultPulsarConsumerFactory<>(pulsarClient, lambdaSafeCustomizers); + } + + @SuppressWarnings("unchecked") + private void applyConsumerBuilderCustomizers(List> customizers, + ConsumerBuilder builder) { + LambdaSafe.callbacks(ConsumerBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean(name = "pulsarListenerContainerFactory") + ConcurrentPulsarListenerContainerFactory pulsarListenerContainerFactory( + PulsarConsumerFactory pulsarConsumerFactory, SchemaResolver schemaResolver, + TopicResolver topicResolver, Environment environment) { + PulsarContainerProperties containerProperties = new PulsarContainerProperties(); + containerProperties.setSchemaResolver(schemaResolver); + containerProperties.setTopicResolver(topicResolver); + if (Threading.VIRTUAL.isActive(environment)) { + containerProperties.setConsumerTaskExecutor(new VirtualThreadTaskExecutor()); + } + this.propertiesMapper.customizeContainerProperties(containerProperties); + return new ConcurrentPulsarListenerContainerFactory<>(pulsarConsumerFactory, containerProperties); + } + + @Bean + @ConditionalOnMissingBean(PulsarReaderFactory.class) + DefaultPulsarReaderFactory pulsarReaderFactory(PulsarClient pulsarClient, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeReaderBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyReaderBuilderCustomizers(customizers, builder)); + return new DefaultPulsarReaderFactory<>(pulsarClient, lambdaSafeCustomizers); + } + + @SuppressWarnings("unchecked") + private void applyReaderBuilderCustomizers(List> customizers, ReaderBuilder builder) { + LambdaSafe.callbacks(ReaderBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean(name = "pulsarReaderContainerFactory") + DefaultPulsarReaderContainerFactory pulsarReaderContainerFactory(PulsarReaderFactory pulsarReaderFactory, + SchemaResolver schemaResolver, Environment environment) { + PulsarReaderContainerProperties readerContainerProperties = new PulsarReaderContainerProperties(); + readerContainerProperties.setSchemaResolver(schemaResolver); + if (Threading.VIRTUAL.isActive(environment)) { + readerContainerProperties.setReaderTaskExecutor(new VirtualThreadTaskExecutor()); + } + this.propertiesMapper.customizeReaderContainerProperties(readerContainerProperties); + return new DefaultPulsarReaderContainerFactory<>(pulsarReaderFactory, readerContainerProperties); + } + + @Configuration(proxyBeanMethods = false) + @EnablePulsar + @ConditionalOnMissingBean(name = { PulsarAnnotationSupportBeanNames.PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME, + PulsarAnnotationSupportBeanNames.PULSAR_READER_ANNOTATION_PROCESSOR_BEAN_NAME }) + static class EnablePulsarConfiguration { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java new file mode 100644 index 000000000000..64efd410ccfa --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java @@ -0,0 +1,180 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.ClientBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.common.schema.SchemaType; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.SchemaInfo; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.TypeMapping; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.pulsar.core.DefaultPulsarClientFactory; +import org.springframework.pulsar.core.DefaultSchemaResolver; +import org.springframework.pulsar.core.DefaultTopicResolver; +import org.springframework.pulsar.core.PulsarAdminBuilderCustomizer; +import org.springframework.pulsar.core.PulsarAdministration; +import org.springframework.pulsar.core.PulsarClientBuilderCustomizer; +import org.springframework.pulsar.core.PulsarClientFactory; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.SchemaResolver.SchemaResolverCustomizer; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.function.PulsarFunction; +import org.springframework.pulsar.function.PulsarFunctionAdministration; +import org.springframework.pulsar.function.PulsarSink; +import org.springframework.pulsar.function.PulsarSource; + +/** + * Common configuration used by both {@link PulsarAutoConfiguration} and + * {@link PulsarReactiveAutoConfiguration}. A separate configuration class is used so that + * {@link PulsarAutoConfiguration} can be excluded for reactive only application. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(PulsarProperties.class) +class PulsarConfiguration { + + private final PulsarProperties properties; + + private final PulsarPropertiesMapper propertiesMapper; + + PulsarConfiguration(PulsarProperties properties) { + this.properties = properties; + this.propertiesMapper = new PulsarPropertiesMapper(properties); + } + + @Bean + @ConditionalOnMissingBean(PulsarConnectionDetails.class) + PropertiesPulsarConnectionDetails pulsarConnectionDetails() { + return new PropertiesPulsarConnectionDetails(this.properties); + } + + @Bean + @ConditionalOnMissingBean(PulsarClientFactory.class) + DefaultPulsarClientFactory pulsarClientFactory(PulsarConnectionDetails connectionDetails, + ObjectProvider customizersProvider) { + List allCustomizers = new ArrayList<>(); + allCustomizers.add((builder) -> this.propertiesMapper.customizeClientBuilder(builder, connectionDetails)); + allCustomizers.addAll(customizersProvider.orderedStream().toList()); + DefaultPulsarClientFactory clientFactory = new DefaultPulsarClientFactory( + (clientBuilder) -> applyClientBuilderCustomizers(allCustomizers, clientBuilder)); + return clientFactory; + } + + private void applyClientBuilderCustomizers(List customizers, + ClientBuilder clientBuilder) { + customizers.forEach((customizer) -> customizer.customize(clientBuilder)); + } + + @Bean + @ConditionalOnMissingBean + PulsarClient pulsarClient(PulsarClientFactory clientFactory) throws PulsarClientException { + return clientFactory.createClient(); + } + + @Bean + @ConditionalOnMissingBean + PulsarAdministration pulsarAdministration(PulsarConnectionDetails connectionDetails, + ObjectProvider pulsarAdminBuilderCustomizers) { + List allCustomizers = new ArrayList<>(); + allCustomizers.add((builder) -> this.propertiesMapper.customizeAdminBuilder(builder, connectionDetails)); + allCustomizers.addAll(pulsarAdminBuilderCustomizers.orderedStream().toList()); + return new PulsarAdministration((adminBuilder) -> applyAdminBuilderCustomizers(allCustomizers, adminBuilder)); + } + + private void applyAdminBuilderCustomizers(List customizers, + PulsarAdminBuilder adminBuilder) { + customizers.forEach((customizer) -> customizer.customize(adminBuilder)); + } + + @Bean + @ConditionalOnMissingBean(SchemaResolver.class) + DefaultSchemaResolver pulsarSchemaResolver(ObjectProvider> schemaResolverCustomizers) { + DefaultSchemaResolver schemaResolver = new DefaultSchemaResolver(); + addCustomSchemaMappings(schemaResolver, this.properties.getDefaults().getTypeMappings()); + applySchemaResolverCustomizers(schemaResolverCustomizers.orderedStream().toList(), schemaResolver); + return schemaResolver; + } + + private void addCustomSchemaMappings(DefaultSchemaResolver schemaResolver, List typeMappings) { + if (typeMappings != null) { + typeMappings.forEach((typeMapping) -> addCustomSchemaMapping(schemaResolver, typeMapping)); + } + } + + private void addCustomSchemaMapping(DefaultSchemaResolver schemaResolver, TypeMapping typeMapping) { + SchemaInfo schemaInfo = typeMapping.schemaInfo(); + if (schemaInfo != null) { + Class messageType = typeMapping.messageType(); + SchemaType schemaType = schemaInfo.schemaType(); + Class messageKeyType = schemaInfo.messageKeyType(); + Schema schema = schemaResolver.resolveSchema(schemaType, messageType, messageKeyType).orElseThrow(); + schemaResolver.addCustomSchemaMapping(typeMapping.messageType(), schema); + } + } + + @SuppressWarnings("unchecked") + private void applySchemaResolverCustomizers(List> customizers, + DefaultSchemaResolver schemaResolver) { + LambdaSafe.callbacks(SchemaResolverCustomizer.class, customizers, schemaResolver) + .invoke((customizer) -> customizer.customize(schemaResolver)); + } + + @Bean + @ConditionalOnMissingBean(TopicResolver.class) + DefaultTopicResolver pulsarTopicResolver() { + DefaultTopicResolver topicResolver = new DefaultTopicResolver(); + List typeMappings = this.properties.getDefaults().getTypeMappings(); + if (typeMappings != null) { + typeMappings.forEach((typeMapping) -> addCustomTopicMapping(topicResolver, typeMapping)); + } + return topicResolver; + } + + private void addCustomTopicMapping(DefaultTopicResolver topicResolver, TypeMapping typeMapping) { + String topicName = typeMapping.topicName(); + if (topicName != null) { + topicResolver.addCustomTopicMapping(typeMapping.messageType(), topicName); + } + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "spring.pulsar.function.enabled", havingValue = "true", matchIfMissing = true) + PulsarFunctionAdministration pulsarFunctionAdministration(PulsarAdministration pulsarAdministration, + ObjectProvider pulsarFunctions, ObjectProvider pulsarSinks, + ObjectProvider pulsarSources) { + PulsarProperties.Function properties = this.properties.getFunction(); + return new PulsarFunctionAdministration(pulsarAdministration, pulsarFunctions, pulsarSinks, pulsarSources, + properties.isFailFast(), properties.isPropagateFailures(), properties.isPropagateStopFailures()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConnectionDetails.java new file mode 100644 index 000000000000..1d21f5802e46 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConnectionDetails.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; + +/** + * Details required to establish a connection to a Pulsar service. + * + * @author Chris Bono + * @since 3.2.0 + */ +public interface PulsarConnectionDetails extends ConnectionDetails { + + /** + * URL used to connect to the broker. + * @return the service URL + */ + String getBrokerUrl(); + + /** + * URL user to connect to the admin endpoint. + * @return the admin URL + */ + String getAdminUrl(); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java new file mode 100644 index 000000000000..37fe9a3b4a1f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java @@ -0,0 +1,1000 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.apache.pulsar.client.api.AutoClusterFailoverBuilder.FailoverPolicy; +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerAccessMode; +import org.apache.pulsar.client.api.RegexSubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.SubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.schema.SchemaType; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.Assert; + +/** + * Configuration properties Apache Pulsar. + * + * @author Chris Bono + * @author Phillip Webb + * @author Swamy Mavuri + * @since 3.2.0 + */ +@ConfigurationProperties("spring.pulsar") +public class PulsarProperties { + + private final Client client = new Client(); + + private final Admin admin = new Admin(); + + private final Defaults defaults = new Defaults(); + + private final Function function = new Function(); + + private final Producer producer = new Producer(); + + private final Consumer consumer = new Consumer(); + + private final Listener listener = new Listener(); + + private final Reader reader = new Reader(); + + private final Template template = new Template(); + + public Client getClient() { + return this.client; + } + + public Admin getAdmin() { + return this.admin; + } + + public Defaults getDefaults() { + return this.defaults; + } + + public Producer getProducer() { + return this.producer; + } + + public Consumer getConsumer() { + return this.consumer; + } + + public Listener getListener() { + return this.listener; + } + + public Reader getReader() { + return this.reader; + } + + public Function getFunction() { + return this.function; + } + + public Template getTemplate() { + return this.template; + } + + public static class Client { + + /** + * Pulsar service URL in the format '(pulsar|pulsar+ssl)://host:port'. + */ + private String serviceUrl = "pulsar://localhost:6650"; + + /** + * Client operation timeout. + */ + private Duration operationTimeout = Duration.ofSeconds(30); + + /** + * Client lookup timeout. + */ + private Duration lookupTimeout; + + /** + * Duration to wait for a connection to a broker to be established. + */ + private Duration connectionTimeout = Duration.ofSeconds(10); + + /** + * Authentication settings. + */ + private final Authentication authentication = new Authentication(); + + /** + * Failover settings. + */ + private final Failover failover = new Failover(); + + public String getServiceUrl() { + return this.serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + + public Duration getOperationTimeout() { + return this.operationTimeout; + } + + public void setOperationTimeout(Duration operationTimeout) { + this.operationTimeout = operationTimeout; + } + + public Duration getLookupTimeout() { + return this.lookupTimeout; + } + + public void setLookupTimeout(Duration lookupTimeout) { + this.lookupTimeout = lookupTimeout; + } + + public Duration getConnectionTimeout() { + return this.connectionTimeout; + } + + public void setConnectionTimeout(Duration connectionTimeout) { + this.connectionTimeout = connectionTimeout; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + public Failover getFailover() { + return this.failover; + } + + } + + public static class Admin { + + /** + * Pulsar web URL for the admin endpoint in the format '(http|https)://host:port'. + */ + private String serviceUrl = "http://localhost:8080"; + + /** + * Duration to wait for a connection to server to be established. + */ + private Duration connectionTimeout = Duration.ofMinutes(1); + + /** + * Server response read time out for any request. + */ + private Duration readTimeout = Duration.ofMinutes(1); + + /** + * Server request time out for any request. + */ + private Duration requestTimeout = Duration.ofMinutes(5); + + /** + * Authentication settings. + */ + private final Authentication authentication = new Authentication(); + + public String getServiceUrl() { + return this.serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + + public Duration getConnectionTimeout() { + return this.connectionTimeout; + } + + public void setConnectionTimeout(Duration connectionTimeout) { + this.connectionTimeout = connectionTimeout; + } + + public Duration getReadTimeout() { + return this.readTimeout; + } + + public void setReadTimeout(Duration readTimeout) { + this.readTimeout = readTimeout; + } + + public Duration getRequestTimeout() { + return this.requestTimeout; + } + + public void setRequestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + } + + public static class Defaults { + + /** + * List of mappings from message type to topic name and schema info to use as a + * defaults when a topic name and/or schema is not explicitly specified when + * producing or consuming messages of the mapped type. + */ + private List typeMappings = new ArrayList<>(); + + public List getTypeMappings() { + return this.typeMappings; + } + + public void setTypeMappings(List typeMappings) { + this.typeMappings = typeMappings; + } + + /** + * A mapping from message type to topic and/or schema info to use (at least one of + * {@code topicName} or {@code schemaInfo} must be specified. + * + * @param messageType the message type + * @param topicName the topic name + * @param schemaInfo the schema info + */ + public record TypeMapping(Class messageType, String topicName, SchemaInfo schemaInfo) { + + public TypeMapping { + Assert.notNull(messageType, "messageType must not be null"); + Assert.isTrue(topicName != null || schemaInfo != null, + "At least one of topicName or schemaInfo must not be null"); + } + + } + + /** + * Represents a schema - holds enough information to construct an actual schema + * instance. + * + * @param schemaType schema type + * @param messageKeyType message key type (required for key value type) + */ + public record SchemaInfo(SchemaType schemaType, Class messageKeyType) { + + public SchemaInfo { + Assert.notNull(schemaType, "schemaType must not be null"); + Assert.isTrue(schemaType != SchemaType.NONE, "schemaType 'NONE' not supported"); + Assert.isTrue(messageKeyType == null || schemaType == SchemaType.KEY_VALUE, + "messageKeyType can only be set when schemaType is KEY_VALUE"); + } + + } + + } + + public static class Function { + + /** + * Whether to stop processing further function creates/updates when a failure + * occurs. + */ + private boolean failFast = true; + + /** + * Whether to throw an exception if any failure is encountered during server + * startup while creating/updating functions. + */ + private boolean propagateFailures = true; + + /** + * Whether to throw an exception if any failure is encountered during server + * shutdown while enforcing stop policy on functions. + */ + private boolean propagateStopFailures = false; + + public boolean isFailFast() { + return this.failFast; + } + + public void setFailFast(boolean failFast) { + this.failFast = failFast; + } + + public boolean isPropagateFailures() { + return this.propagateFailures; + } + + public void setPropagateFailures(boolean propagateFailures) { + this.propagateFailures = propagateFailures; + } + + public boolean isPropagateStopFailures() { + return this.propagateStopFailures; + } + + public void setPropagateStopFailures(boolean propagateStopFailures) { + this.propagateStopFailures = propagateStopFailures; + } + + } + + public static class Producer { + + /** + * Name for the producer. If not assigned, a unique name is generated. + */ + private String name; + + /** + * Topic the producer will publish to. + */ + private String topicName; + + /** + * Time before a message has to be acknowledged by the broker. + */ + private Duration sendTimeout = Duration.ofSeconds(30); + + /** + * Message routing mode for a partitioned producer. + */ + private MessageRoutingMode messageRoutingMode = MessageRoutingMode.RoundRobinPartition; + + /** + * Message hashing scheme to choose the partition to which the message is + * published. + */ + private HashingScheme hashingScheme = HashingScheme.JavaStringHash; + + /** + * Whether to automatically batch messages. + */ + private boolean batchingEnabled = true; + + /** + * Whether to split large-size messages into multiple chunks. + */ + private boolean chunkingEnabled; + + /** + * Message compression type. + */ + private CompressionType compressionType; + + /** + * Type of access to the topic the producer requires. + */ + private ProducerAccessMode accessMode = ProducerAccessMode.Shared; + + private final Cache cache = new Cache(); + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public String getTopicName() { + return this.topicName; + } + + public void setTopicName(String topicName) { + this.topicName = topicName; + } + + public Duration getSendTimeout() { + return this.sendTimeout; + } + + public void setSendTimeout(Duration sendTimeout) { + this.sendTimeout = sendTimeout; + } + + public MessageRoutingMode getMessageRoutingMode() { + return this.messageRoutingMode; + } + + public void setMessageRoutingMode(MessageRoutingMode messageRoutingMode) { + this.messageRoutingMode = messageRoutingMode; + } + + public HashingScheme getHashingScheme() { + return this.hashingScheme; + } + + public void setHashingScheme(HashingScheme hashingScheme) { + this.hashingScheme = hashingScheme; + } + + public boolean isBatchingEnabled() { + return this.batchingEnabled; + } + + public void setBatchingEnabled(boolean batchingEnabled) { + this.batchingEnabled = batchingEnabled; + } + + public boolean isChunkingEnabled() { + return this.chunkingEnabled; + } + + public void setChunkingEnabled(boolean chunkingEnabled) { + this.chunkingEnabled = chunkingEnabled; + } + + public CompressionType getCompressionType() { + return this.compressionType; + } + + public void setCompressionType(CompressionType compressionType) { + this.compressionType = compressionType; + } + + public ProducerAccessMode getAccessMode() { + return this.accessMode; + } + + public void setAccessMode(ProducerAccessMode accessMode) { + this.accessMode = accessMode; + } + + public Cache getCache() { + return this.cache; + } + + public static class Cache { + + /** + * Time period to expire unused entries in the cache. + */ + private Duration expireAfterAccess = Duration.ofMinutes(1); + + /** + * Maximum size of cache (entries). + */ + private long maximumSize = 1000L; + + /** + * Initial size of cache. + */ + private int initialCapacity = 50; + + public Duration getExpireAfterAccess() { + return this.expireAfterAccess; + } + + public void setExpireAfterAccess(Duration expireAfterAccess) { + this.expireAfterAccess = expireAfterAccess; + } + + public long getMaximumSize() { + return this.maximumSize; + } + + public void setMaximumSize(long maximumSize) { + this.maximumSize = maximumSize; + } + + public int getInitialCapacity() { + return this.initialCapacity; + } + + public void setInitialCapacity(int initialCapacity) { + this.initialCapacity = initialCapacity; + } + + } + + } + + public static class Consumer { + + /** + * Consumer name to identify a particular consumer from the topic stats. + */ + private String name; + + /** + * Topics the consumer subscribes to. + */ + private List topics; + + /** + * Pattern for topics the consumer subscribes to. + */ + private Pattern topicsPattern; + + /** + * Priority level for shared subscription consumers. + */ + private int priorityLevel = 0; + + /** + * Whether to read messages from the compacted topic rather than the full message + * backlog. + */ + private boolean readCompacted = false; + + /** + * Dead letter policy to use. + */ + @NestedConfigurationProperty + private DeadLetterPolicy deadLetterPolicy; + + /** + * Consumer subscription properties. + */ + private final Subscription subscription = new Subscription(); + + /** + * Whether to auto retry messages. + */ + private boolean retryEnable = false; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public Consumer.Subscription getSubscription() { + return this.subscription; + } + + public List getTopics() { + return this.topics; + } + + public void setTopics(List topics) { + this.topics = topics; + } + + public Pattern getTopicsPattern() { + return this.topicsPattern; + } + + public void setTopicsPattern(Pattern topicsPattern) { + this.topicsPattern = topicsPattern; + } + + public int getPriorityLevel() { + return this.priorityLevel; + } + + public void setPriorityLevel(int priorityLevel) { + this.priorityLevel = priorityLevel; + } + + public boolean isReadCompacted() { + return this.readCompacted; + } + + public void setReadCompacted(boolean readCompacted) { + this.readCompacted = readCompacted; + } + + public DeadLetterPolicy getDeadLetterPolicy() { + return this.deadLetterPolicy; + } + + public void setDeadLetterPolicy(DeadLetterPolicy deadLetterPolicy) { + this.deadLetterPolicy = deadLetterPolicy; + } + + public boolean isRetryEnable() { + return this.retryEnable; + } + + public void setRetryEnable(boolean retryEnable) { + this.retryEnable = retryEnable; + } + + public static class Subscription { + + /** + * Subscription name for the consumer. + */ + private String name; + + /** + * Position where to initialize a newly created subscription. + */ + private SubscriptionInitialPosition initialPosition = SubscriptionInitialPosition.Latest; + + /** + * Subscription mode to be used when subscribing to the topic. + */ + private SubscriptionMode mode = SubscriptionMode.Durable; + + /** + * Determines which type of topics (persistent, non-persistent, or all) the + * consumer should be subscribed to when using pattern subscriptions. + */ + private RegexSubscriptionMode topicsMode = RegexSubscriptionMode.PersistentOnly; + + /** + * Subscription type to be used when subscribing to a topic. + */ + private SubscriptionType type = SubscriptionType.Exclusive; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public SubscriptionInitialPosition getInitialPosition() { + return this.initialPosition; + } + + public void setInitialPosition(SubscriptionInitialPosition initialPosition) { + this.initialPosition = initialPosition; + } + + public SubscriptionMode getMode() { + return this.mode; + } + + public void setMode(SubscriptionMode mode) { + this.mode = mode; + } + + public RegexSubscriptionMode getTopicsMode() { + return this.topicsMode; + } + + public void setTopicsMode(RegexSubscriptionMode topicsMode) { + this.topicsMode = topicsMode; + } + + public SubscriptionType getType() { + return this.type; + } + + public void setType(SubscriptionType type) { + this.type = type; + } + + } + + public static class DeadLetterPolicy { + + /** + * Maximum number of times that a message will be redelivered before being + * sent to the dead letter queue. + */ + private int maxRedeliverCount; + + /** + * Name of the retry topic where the failing messages will be sent. + */ + private String retryLetterTopic; + + /** + * Name of the dead topic where the failing messages will be sent. + */ + private String deadLetterTopic; + + /** + * Name of the initial subscription of the dead letter topic. When not set, + * the initial subscription will not be created. However, when the property is + * set then the broker's 'allowAutoSubscriptionCreation' must be enabled or + * the DLQ producer will fail. + */ + private String initialSubscriptionName; + + public int getMaxRedeliverCount() { + return this.maxRedeliverCount; + } + + public void setMaxRedeliverCount(int maxRedeliverCount) { + this.maxRedeliverCount = maxRedeliverCount; + } + + public String getRetryLetterTopic() { + return this.retryLetterTopic; + } + + public void setRetryLetterTopic(String retryLetterTopic) { + this.retryLetterTopic = retryLetterTopic; + } + + public String getDeadLetterTopic() { + return this.deadLetterTopic; + } + + public void setDeadLetterTopic(String deadLetterTopic) { + this.deadLetterTopic = deadLetterTopic; + } + + public String getInitialSubscriptionName() { + return this.initialSubscriptionName; + } + + public void setInitialSubscriptionName(String initialSubscriptionName) { + this.initialSubscriptionName = initialSubscriptionName; + } + + } + + } + + public static class Listener { + + /** + * SchemaType of the consumed messages. + */ + private SchemaType schemaType; + + /** + * Whether to record observations for when the Observations API is available and + * the client supports it. + */ + private boolean observationEnabled = true; + + public SchemaType getSchemaType() { + return this.schemaType; + } + + public void setSchemaType(SchemaType schemaType) { + this.schemaType = schemaType; + } + + public boolean isObservationEnabled() { + return this.observationEnabled; + } + + public void setObservationEnabled(boolean observationEnabled) { + this.observationEnabled = observationEnabled; + } + + } + + public static class Reader { + + /** + * Reader name. + */ + private String name; + + /** + * Topics the reader subscribes to. + */ + private List topics; + + /** + * Subscription name. + */ + private String subscriptionName; + + /** + * Prefix of subscription role. + */ + private String subscriptionRolePrefix; + + /** + * Whether to read messages from a compacted topic rather than a full message + * backlog of a topic. + */ + private boolean readCompacted; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public List getTopics() { + return this.topics; + } + + public void setTopics(List topics) { + this.topics = topics; + } + + public String getSubscriptionName() { + return this.subscriptionName; + } + + public void setSubscriptionName(String subscriptionName) { + this.subscriptionName = subscriptionName; + } + + public String getSubscriptionRolePrefix() { + return this.subscriptionRolePrefix; + } + + public void setSubscriptionRolePrefix(String subscriptionRolePrefix) { + this.subscriptionRolePrefix = subscriptionRolePrefix; + } + + public boolean isReadCompacted() { + return this.readCompacted; + } + + public void setReadCompacted(boolean readCompacted) { + this.readCompacted = readCompacted; + } + + } + + public static class Template { + + /** + * Whether to record observations for when the Observations API is available. + */ + private boolean observationsEnabled = true; + + public boolean isObservationsEnabled() { + return this.observationsEnabled; + } + + public void setObservationsEnabled(boolean observationsEnabled) { + this.observationsEnabled = observationsEnabled; + } + + } + + public static class Authentication { + + /** + * Fully qualified class name of the authentication plugin. + */ + private String pluginClassName; + + /** + * Authentication parameter(s) as a map of parameter names to parameter values. + */ + private Map param = new LinkedHashMap<>(); + + public String getPluginClassName() { + return this.pluginClassName; + } + + public void setPluginClassName(String pluginClassName) { + this.pluginClassName = pluginClassName; + } + + public Map getParam() { + return this.param; + } + + public void setParam(Map param) { + this.param = param; + } + + } + + public static class Failover { + + /** + * Cluster Failover Policy. + */ + private FailoverPolicy failoverPolicy = FailoverPolicy.ORDER; + + /** + * Delay before the Pulsar client switches from the primary cluster to the backup + * cluster. + */ + private Duration failOverDelay; + + /** + * Delay before the Pulsar client switches from the backup cluster to the primary + * cluster. + */ + private Duration switchBackDelay; + + /** + * Frequency of performing a probe task. + */ + private Duration checkInterval; + + /** + * List of backupClusters The backup cluster is chosen in the sequence of the + * given list. If all backup clusters are available, the Pulsar client chooses the + * first backup cluster. + */ + private List backupClusters = new ArrayList<>(); + + public FailoverPolicy getFailoverPolicy() { + return this.failoverPolicy; + } + + public void setFailoverPolicy(FailoverPolicy failoverPolicy) { + this.failoverPolicy = failoverPolicy; + } + + public Duration getFailOverDelay() { + return this.failOverDelay; + } + + public void setFailOverDelay(Duration failOverDelay) { + this.failOverDelay = failOverDelay; + } + + public Duration getSwitchBackDelay() { + return this.switchBackDelay; + } + + public void setSwitchBackDelay(Duration switchBackDelay) { + this.switchBackDelay = switchBackDelay; + } + + public Duration getCheckInterval() { + return this.checkInterval; + } + + public void setCheckInterval(Duration checkInterval) { + this.checkInterval = checkInterval; + } + + public List getBackupClusters() { + return this.backupClusters; + } + + public void setBackupClusters(List backupClusters) { + this.backupClusters = backupClusters; + } + + public static class BackupCluster { + + /** + * Pulsar service URL in the format '(pulsar|pulsar+ssl)://host:port'. + */ + private String serviceUrl = "pulsar://localhost:6650"; + + /** + * Authentication settings. + */ + private final Authentication authentication = new Authentication(); + + public String getServiceUrl() { + return this.serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java new file mode 100644 index 000000000000..bb1401f01083 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java @@ -0,0 +1,224 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.Authentication; +import org.apache.pulsar.client.api.AuthenticationFactory; +import org.apache.pulsar.client.api.AutoClusterFailoverBuilder; +import org.apache.pulsar.client.api.ClientBuilder; +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClientException.UnsupportedAuthenticationException; +import org.apache.pulsar.client.api.ReaderBuilder; +import org.apache.pulsar.client.api.ServiceUrlProvider; +import org.apache.pulsar.client.impl.AutoClusterFailover.AutoClusterFailoverBuilderImpl; +import org.apache.pulsar.common.util.ObjectMapperFactory; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.pulsar.listener.PulsarContainerProperties; +import org.springframework.pulsar.reader.PulsarReaderContainerProperties; +import org.springframework.util.StringUtils; + +/** + * Helper class used to map {@link PulsarProperties} to various builder customizers. + * + * @author Chris Bono + * @author Phillip Webb + * @author Swamy Mavuri + */ +final class PulsarPropertiesMapper { + + private final PulsarProperties properties; + + PulsarPropertiesMapper(PulsarProperties properties) { + this.properties = properties; + } + + void customizeClientBuilder(ClientBuilder clientBuilder, PulsarConnectionDetails connectionDetails) { + PulsarProperties.Client properties = this.properties.getClient(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getConnectionTimeout).to(timeoutProperty(clientBuilder::connectionTimeout)); + map.from(properties::getOperationTimeout).to(timeoutProperty(clientBuilder::operationTimeout)); + map.from(properties::getLookupTimeout).to(timeoutProperty(clientBuilder::lookupTimeout)); + customizeAuthentication(properties.getAuthentication(), clientBuilder::authentication); + customizeServiceUrlProviderBuilder(clientBuilder::serviceUrl, clientBuilder::serviceUrlProvider, properties, + connectionDetails); + } + + private void customizeServiceUrlProviderBuilder(Consumer serviceUrlConsumer, + Consumer serviceUrlProviderConsumer, PulsarProperties.Client properties, + PulsarConnectionDetails connectionDetails) { + PulsarProperties.Failover failoverProperties = properties.getFailover(); + if (failoverProperties.getBackupClusters().isEmpty()) { + serviceUrlConsumer.accept(connectionDetails.getBrokerUrl()); + return; + } + Map secondaryAuths = getSecondaryAuths(failoverProperties); + AutoClusterFailoverBuilder autoClusterFailoverBuilder = new AutoClusterFailoverBuilderImpl(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(connectionDetails::getBrokerUrl).to(autoClusterFailoverBuilder::primary); + map.from(secondaryAuths::keySet).as(ArrayList::new).to(autoClusterFailoverBuilder::secondary); + map.from(failoverProperties::getFailoverPolicy).to(autoClusterFailoverBuilder::failoverPolicy); + map.from(failoverProperties::getFailOverDelay).to(timeoutProperty(autoClusterFailoverBuilder::failoverDelay)); + map.from(failoverProperties::getSwitchBackDelay) + .to(timeoutProperty(autoClusterFailoverBuilder::switchBackDelay)); + map.from(failoverProperties::getCheckInterval).to(timeoutProperty(autoClusterFailoverBuilder::checkInterval)); + map.from(secondaryAuths).to(autoClusterFailoverBuilder::secondaryAuthentication); + serviceUrlProviderConsumer.accept(autoClusterFailoverBuilder.build()); + } + + private Map getSecondaryAuths(PulsarProperties.Failover properties) { + Map secondaryAuths = new LinkedHashMap<>(); + properties.getBackupClusters().forEach((backupCluster) -> { + PulsarProperties.Authentication authenticationProperties = backupCluster.getAuthentication(); + if (authenticationProperties.getPluginClassName() == null) { + secondaryAuths.put(backupCluster.getServiceUrl(), null); + } + else { + customizeAuthentication(authenticationProperties, (authPluginClassName, authParams) -> { + Authentication authentication = AuthenticationFactory.create(authPluginClassName, authParams); + secondaryAuths.put(backupCluster.getServiceUrl(), authentication); + }); + } + }); + return secondaryAuths; + } + + void customizeAdminBuilder(PulsarAdminBuilder adminBuilder, PulsarConnectionDetails connectionDetails) { + PulsarProperties.Admin properties = this.properties.getAdmin(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(connectionDetails::getAdminUrl).to(adminBuilder::serviceHttpUrl); + map.from(properties::getConnectionTimeout).to(timeoutProperty(adminBuilder::connectionTimeout)); + map.from(properties::getReadTimeout).to(timeoutProperty(adminBuilder::readTimeout)); + map.from(properties::getRequestTimeout).to(timeoutProperty(adminBuilder::requestTimeout)); + customizeAuthentication(properties.getAuthentication(), adminBuilder::authentication); + } + + private void customizeAuthentication(PulsarProperties.Authentication properties, AuthenticationConsumer action) { + String pluginClassName = properties.getPluginClassName(); + if (StringUtils.hasText(pluginClassName)) { + try { + action.accept(pluginClassName, getAuthenticationParamsJson(properties.getParam())); + } + catch (UnsupportedAuthenticationException ex) { + throw new IllegalStateException("Unable to configure Pulsar authentication", ex); + } + } + } + + private String getAuthenticationParamsJson(Map params) { + Map sortedParams = new TreeMap<>(params); + try { + return ObjectMapperFactory.create().writeValueAsString(sortedParams); + } + catch (Exception ex) { + throw new IllegalStateException("Could not convert auth parameters to encoded string", ex); + } + } + + void customizeProducerBuilder(ProducerBuilder producerBuilder) { + PulsarProperties.Producer properties = this.properties.getProducer(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(producerBuilder::producerName); + map.from(properties::getTopicName).to(producerBuilder::topic); + map.from(properties::getSendTimeout).to(timeoutProperty(producerBuilder::sendTimeout)); + map.from(properties::getMessageRoutingMode).to(producerBuilder::messageRoutingMode); + map.from(properties::getHashingScheme).to(producerBuilder::hashingScheme); + map.from(properties::isBatchingEnabled).to(producerBuilder::enableBatching); + map.from(properties::isChunkingEnabled).to(producerBuilder::enableChunking); + map.from(properties::getCompressionType).to(producerBuilder::compressionType); + map.from(properties::getAccessMode).to(producerBuilder::accessMode); + } + + void customizeConsumerBuilder(ConsumerBuilder consumerBuilder) { + PulsarProperties.Consumer properties = this.properties.getConsumer(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(consumerBuilder::consumerName); + map.from(properties::getTopics).as(ArrayList::new).to(consumerBuilder::topics); + map.from(properties::getTopicsPattern).to(consumerBuilder::topicsPattern); + map.from(properties::getPriorityLevel).to(consumerBuilder::priorityLevel); + map.from(properties::isReadCompacted).to(consumerBuilder::readCompacted); + map.from(properties::getDeadLetterPolicy).as(DeadLetterPolicyMapper::map).to(consumerBuilder::deadLetterPolicy); + map.from(properties::isRetryEnable).to(consumerBuilder::enableRetry); + customizeConsumerBuilderSubscription(consumerBuilder); + } + + private void customizeConsumerBuilderSubscription(ConsumerBuilder consumerBuilder) { + PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(consumerBuilder::subscriptionName); + map.from(properties::getInitialPosition).to(consumerBuilder::subscriptionInitialPosition); + map.from(properties::getMode).to(consumerBuilder::subscriptionMode); + map.from(properties::getTopicsMode).to(consumerBuilder::subscriptionTopicsMode); + map.from(properties::getType).to(consumerBuilder::subscriptionType); + } + + void customizeContainerProperties(PulsarContainerProperties containerProperties) { + customizePulsarContainerConsumerSubscriptionProperties(containerProperties); + customizePulsarContainerListenerProperties(containerProperties); + } + + private void customizePulsarContainerConsumerSubscriptionProperties(PulsarContainerProperties containerProperties) { + PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getType).to(containerProperties::setSubscriptionType); + } + + private void customizePulsarContainerListenerProperties(PulsarContainerProperties containerProperties) { + PulsarProperties.Listener properties = this.properties.getListener(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getSchemaType).to(containerProperties::setSchemaType); + map.from(properties::isObservationEnabled).to(containerProperties::setObservationEnabled); + } + + void customizeReaderBuilder(ReaderBuilder readerBuilder) { + PulsarProperties.Reader properties = this.properties.getReader(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(readerBuilder::readerName); + map.from(properties::getTopics).to(readerBuilder::topics); + map.from(properties::getSubscriptionName).to(readerBuilder::subscriptionName); + map.from(properties::getSubscriptionRolePrefix).to(readerBuilder::subscriptionRolePrefix); + map.from(properties::isReadCompacted).to(readerBuilder::readCompacted); + } + + void customizeReaderContainerProperties(PulsarReaderContainerProperties readerContainerProperties) { + PulsarProperties.Reader properties = this.properties.getReader(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getTopics).to(readerContainerProperties::setTopics); + } + + private Consumer timeoutProperty(BiConsumer setter) { + return (duration) -> setter.accept((int) duration.toMillis(), TimeUnit.MILLISECONDS); + } + + private interface AuthenticationConsumer { + + void accept(String authPluginClassName, String authParamString) throws UnsupportedAuthenticationException; + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java new file mode 100644 index 000000000000..4c2aeb172d52 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java @@ -0,0 +1,201 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.reactive.client.adapter.AdaptedReactivePulsarClientFactory; +import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider; +import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache; +import org.apache.pulsar.reactive.client.api.ReactivePulsarClient; +import org.apache.pulsar.reactive.client.producercache.CaffeineShadedProducerCacheProvider; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.pulsar.config.PulsarAnnotationSupportBeanNames; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.reactive.config.DefaultReactivePulsarListenerContainerFactory; +import org.springframework.pulsar.reactive.config.annotation.EnableReactivePulsar; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarConsumerFactory; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarReaderFactory; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarSenderFactory; +import org.springframework.pulsar.reactive.core.ReactiveMessageConsumerBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactiveMessageSenderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactivePulsarConsumerFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarSenderFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate; +import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Spring for Apache Pulsar + * Reactive. + * + * @author Chris Bono + * @author Christophe Bornet + * @since 3.2.0 + */ +@AutoConfiguration(after = PulsarAutoConfiguration.class) +@ConditionalOnClass({ PulsarClient.class, ReactivePulsarClient.class, ReactivePulsarTemplate.class }) +@Import(PulsarConfiguration.class) +public class PulsarReactiveAutoConfiguration { + + private final PulsarProperties properties; + + private final PulsarReactivePropertiesMapper propertiesMapper; + + PulsarReactiveAutoConfiguration(PulsarProperties properties) { + this.properties = properties; + this.propertiesMapper = new PulsarReactivePropertiesMapper(properties); + } + + @Bean + @ConditionalOnMissingBean + ReactivePulsarClient reactivePulsarClient(PulsarClient pulsarClient) { + return AdaptedReactivePulsarClientFactory.create(pulsarClient); + } + + @Bean + @ConditionalOnMissingBean(ProducerCacheProvider.class) + @ConditionalOnClass(CaffeineShadedProducerCacheProvider.class) + @ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true) + CaffeineShadedProducerCacheProvider reactivePulsarProducerCacheProvider() { + PulsarProperties.Producer.Cache properties = this.properties.getProducer().getCache(); + return new CaffeineShadedProducerCacheProvider(properties.getExpireAfterAccess(), Duration.ofMinutes(10), + properties.getMaximumSize(), properties.getInitialCapacity()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true) + ReactiveMessageSenderCache reactivePulsarMessageSenderCache( + ObjectProvider producerCacheProvider) { + return reactivePulsarMessageSenderCache(producerCacheProvider.getIfAvailable()); + } + + private ReactiveMessageSenderCache reactivePulsarMessageSenderCache(ProducerCacheProvider producerCacheProvider) { + return (producerCacheProvider != null) ? AdaptedReactivePulsarClientFactory.createCache(producerCacheProvider) + : AdaptedReactivePulsarClientFactory.createCache(); + } + + @Bean + @ConditionalOnMissingBean(ReactivePulsarSenderFactory.class) + DefaultReactivePulsarSenderFactory reactivePulsarSenderFactory(ReactivePulsarClient reactivePulsarClient, + ObjectProvider reactiveMessageSenderCache, TopicResolver topicResolver, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeMessageSenderBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyMessageSenderBuilderCustomizers(customizers, builder)); + return DefaultReactivePulsarSenderFactory.builderFor(reactivePulsarClient) + .withDefaultConfigCustomizers(lambdaSafeCustomizers) + .withMessageSenderCache(reactiveMessageSenderCache.getIfAvailable()) + .withTopicResolver(topicResolver) + .build(); + } + + @SuppressWarnings("unchecked") + private void applyMessageSenderBuilderCustomizers(List> customizers, + ReactiveMessageSenderBuilder builder) { + LambdaSafe.callbacks(ReactiveMessageSenderBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean(ReactivePulsarConsumerFactory.class) + DefaultReactivePulsarConsumerFactory reactivePulsarConsumerFactory( + ReactivePulsarClient pulsarReactivePulsarClient, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeMessageConsumerBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyMessageConsumerBuilderCustomizers(customizers, builder)); + return new DefaultReactivePulsarConsumerFactory<>(pulsarReactivePulsarClient, lambdaSafeCustomizers); + } + + @SuppressWarnings("unchecked") + private void applyMessageConsumerBuilderCustomizers(List> customizers, + ReactiveMessageConsumerBuilder builder) { + LambdaSafe.callbacks(ReactiveMessageConsumerBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean(name = "reactivePulsarListenerContainerFactory") + DefaultReactivePulsarListenerContainerFactory reactivePulsarListenerContainerFactory( + ReactivePulsarConsumerFactory reactivePulsarConsumerFactory, SchemaResolver schemaResolver, + TopicResolver topicResolver) { + ReactivePulsarContainerProperties containerProperties = new ReactivePulsarContainerProperties<>(); + containerProperties.setSchemaResolver(schemaResolver); + containerProperties.setTopicResolver(topicResolver); + this.propertiesMapper.customizeContainerProperties(containerProperties); + return new DefaultReactivePulsarListenerContainerFactory<>(reactivePulsarConsumerFactory, containerProperties); + } + + @Bean + @ConditionalOnMissingBean(ReactivePulsarReaderFactory.class) + DefaultReactivePulsarReaderFactory reactivePulsarReaderFactory(ReactivePulsarClient reactivePulsarClient, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeMessageReaderBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyMessageReaderBuilderCustomizers(customizers, builder)); + return new DefaultReactivePulsarReaderFactory<>(reactivePulsarClient, lambdaSafeCustomizers); + } + + @SuppressWarnings("unchecked") + private void applyMessageReaderBuilderCustomizers(List> customizers, + ReactiveMessageReaderBuilder builder) { + LambdaSafe.callbacks(ReactiveMessageReaderBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean + ReactivePulsarTemplate pulsarReactiveTemplate(ReactivePulsarSenderFactory reactivePulsarSenderFactory, + SchemaResolver schemaResolver, TopicResolver topicResolver) { + return new ReactivePulsarTemplate<>(reactivePulsarSenderFactory, schemaResolver, topicResolver); + } + + @Configuration(proxyBeanMethods = false) + @EnableReactivePulsar + @ConditionalOnMissingBean( + name = PulsarAnnotationSupportBeanNames.REACTIVE_PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME) + static class EnableReactivePulsarConfiguration { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java new file mode 100644 index 000000000000..2f79bbae615f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java @@ -0,0 +1,108 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import java.util.ArrayList; + +import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties; + +/** + * Helper class used to map reactive {@link PulsarProperties} to various builder + * customizers. + * + * @author Chris Bono + * @author Phillip Webb + */ +final class PulsarReactivePropertiesMapper { + + private final PulsarProperties properties; + + PulsarReactivePropertiesMapper(PulsarProperties properties) { + this.properties = properties; + } + + void customizeMessageSenderBuilder(ReactiveMessageSenderBuilder builder) { + PulsarProperties.Producer properties = this.properties.getProducer(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(builder::producerName); + map.from(properties::getTopicName).to(builder::topic); + map.from(properties::getSendTimeout).to(builder::sendTimeout); + map.from(properties::getMessageRoutingMode).to(builder::messageRoutingMode); + map.from(properties::getHashingScheme).to(builder::hashingScheme); + map.from(properties::isBatchingEnabled).to(builder::batchingEnabled); + map.from(properties::isChunkingEnabled).to(builder::chunkingEnabled); + map.from(properties::getCompressionType).to(builder::compressionType); + map.from(properties::getAccessMode).to(builder::accessMode); + } + + void customizeMessageConsumerBuilder(ReactiveMessageConsumerBuilder builder) { + PulsarProperties.Consumer properties = this.properties.getConsumer(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(builder::consumerName); + map.from(properties::getTopics).as(ArrayList::new).to(builder::topics); + map.from(properties::getTopicsPattern).to(builder::topicsPattern); + map.from(properties::getPriorityLevel).to(builder::priorityLevel); + map.from(properties::isReadCompacted).to(builder::readCompacted); + map.from(properties::getDeadLetterPolicy).as(DeadLetterPolicyMapper::map).to(builder::deadLetterPolicy); + map.from(properties::isRetryEnable).to(builder::retryLetterTopicEnable); + customizerMessageConsumerBuilderSubscription(builder); + } + + private void customizerMessageConsumerBuilderSubscription(ReactiveMessageConsumerBuilder builder) { + PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(builder::subscriptionName); + map.from(properties::getInitialPosition).to(builder::subscriptionInitialPosition); + map.from(properties::getMode).to(builder::subscriptionMode); + map.from(properties::getTopicsMode).to(builder::topicsPatternSubscriptionMode); + map.from(properties::getType).to(builder::subscriptionType); + } + + void customizeContainerProperties(ReactivePulsarContainerProperties containerProperties) { + customizePulsarContainerConsumerSubscriptionProperties(containerProperties); + customizePulsarContainerListenerProperties(containerProperties); + } + + private void customizePulsarContainerConsumerSubscriptionProperties( + ReactivePulsarContainerProperties containerProperties) { + PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getType).to(containerProperties::setSubscriptionType); + } + + private void customizePulsarContainerListenerProperties(ReactivePulsarContainerProperties containerProperties) { + PulsarProperties.Listener properties = this.properties.getListener(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getSchemaType).to(containerProperties::setSchemaType); + } + + void customizeMessageReaderBuilder(ReactiveMessageReaderBuilder builder) { + PulsarProperties.Reader properties = this.properties.getReader(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(builder::readerName); + map.from(properties::getTopics).to(builder::topics); + map.from(properties::getSubscriptionName).to(builder::subscriptionName); + map.from(properties::getSubscriptionRolePrefix).to(builder::generatedSubscriptionNamePrefix); + map.from(properties::isReadCompacted).to(builder::readCompacted); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java new file mode 100644 index 000000000000..d6ce8ee1d218 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Auto-configuration for Spring for Apache Pulsar. + */ +package org.springframework.boot.autoconfigure.pulsar; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/r2dbc/ConnectionFactoryConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/r2dbc/ConnectionFactoryConfigurations.java index 987b80fee7fc..6807a349f77b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/r2dbc/ConnectionFactoryConfigurations.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/r2dbc/ConnectionFactoryConfigurations.java @@ -33,6 +33,7 @@ import org.springframework.boot.context.properties.bind.BindResult; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.r2dbc.ConnectionFactoryDecorator; import org.springframework.boot.r2dbc.EmbeddedDatabaseConnection; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Condition; @@ -54,12 +55,14 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Moritz Halbritter */ abstract class ConnectionFactoryConfigurations { protected static ConnectionFactory createConnectionFactory(R2dbcProperties properties, R2dbcConnectionDetails connectionDetails, ClassLoader classLoader, - List optionsCustomizers) { + List optionsCustomizers, + List decorators) { try { return org.springframework.boot.r2dbc.ConnectionFactoryBuilder .withOptions(new ConnectionFactoryOptionsInitializer().initialize(properties, connectionDetails, @@ -69,6 +72,7 @@ protected static ConnectionFactory createConnectionFactory(R2dbcProperties prope optionsCustomizer.customize(options); } }) + .decorators(decorators) .build(); } catch (IllegalStateException ex) { @@ -93,10 +97,11 @@ static class PooledConnectionFactoryConfiguration { @Bean(destroyMethod = "dispose") ConnectionPool connectionFactory(R2dbcProperties properties, ObjectProvider connectionDetails, ResourceLoader resourceLoader, - ObjectProvider customizers) { + ObjectProvider customizers, + ObjectProvider decorators) { ConnectionFactory connectionFactory = createConnectionFactory(properties, connectionDetails.getIfAvailable(), resourceLoader.getClassLoader(), - customizers.orderedStream().toList()); + customizers.orderedStream().toList(), decorators.orderedStream().toList()); R2dbcProperties.Pool pool = properties.getPool(); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); ConnectionPoolConfiguration.Builder builder = ConnectionPoolConfiguration.builder(connectionFactory); @@ -126,9 +131,11 @@ static class GenericConfiguration { @Bean ConnectionFactory connectionFactory(R2dbcProperties properties, ObjectProvider connectionDetails, ResourceLoader resourceLoader, - ObjectProvider customizers) { + ObjectProvider customizers, + ObjectProvider decorators) { return createConnectionFactory(properties, connectionDetails.getIfAvailable(), - resourceLoader.getClassLoader(), customizers.orderedStream().toList()); + resourceLoader.getClassLoader(), customizers.orderedStream().toList(), + decorators.orderedStream().toList()); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfiguration.java new file mode 100644 index 000000000000..9323e6eca46a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfiguration.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-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.boot.autoconfigure.reactor; + +import reactor.core.publisher.Hooks; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Reactor. + * + * @author Brian Clozel + * @since 3.2.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Hooks.class) +@EnableConfigurationProperties(ReactorProperties.class) +public class ReactorAutoConfiguration { + + ReactorAutoConfiguration(ReactorProperties properties) { + if (properties.getContextPropagation() == ReactorProperties.ContextPropagationMode.AUTO) { + Hooks.enableAutomaticContextPropagation(); + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorProperties.java new file mode 100644 index 000000000000..c82da8b52389 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/ReactorProperties.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-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.boot.autoconfigure.reactor; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for Reactor. + * + * @author Brian Clozel + * @since 3.2.0 + */ +@ConfigurationProperties(prefix = "spring.reactor") +public class ReactorProperties { + + /** + * Context Propagation support mode for Reactor operators. + */ + private ContextPropagationMode contextPropagation = ContextPropagationMode.LIMITED; + + public ContextPropagationMode getContextPropagation() { + return this.contextPropagation; + } + + public void setContextPropagation(ContextPropagationMode contextPropagation) { + this.contextPropagation = contextPropagation; + } + + public enum ContextPropagationMode { + + /** + * Context Propagation is applied to all Reactor operators. + */ + AUTO, + + /** + * Context Propagation is only applied to "tap" and "handle" Reactor operators. + */ + LIMITED + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/netty/ReactorNettyConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/netty/ReactorNettyConfigurations.java index b6e3ba354805..35867272331f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/netty/ReactorNettyConfigurations.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/netty/ReactorNettyConfigurations.java @@ -20,7 +20,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; /** * Configurations for Reactor Netty. Those should be {@code @Import} in a regular diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/package-info.java similarity index 78% rename from spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/package-info.java rename to spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/package-info.java index 22bbf429f87a..4b55cfe4d534 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/web/servlet/package-info.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/reactor/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -15,6 +15,6 @@ */ /** - * Actuator support for Spring MVC metrics. + * Auto-configuration for Reactor. */ -package org.springframework.boot.actuate.metrics.web.servlet; +package org.springframework.boot.autoconfigure.reactor; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketProperties.java index b5af9bc50305..bba90f2f7ef1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketProperties.java @@ -73,6 +73,8 @@ public static class Server { @NestedConfigurationProperty private Ssl ssl; + private final Spec spec = new Spec(); + public Integer getPort() { return this.port; } @@ -121,6 +123,66 @@ public void setSsl(Ssl ssl) { this.ssl = ssl; } + public Spec getSpec() { + return this.spec; + } + + public static class Spec { + + /** + * Sub-protocols to use in websocket handshake signature. + */ + private String protocols; + + /** + * Maximum allowable frame payload length. + */ + private DataSize maxFramePayloadLength = DataSize.ofBytes(65536); + + /** + * Whether to proxy websocket ping frames or respond to them. + */ + private boolean handlePing; + + /** + * Whether the websocket compression extension is enabled. + */ + private boolean compress; + + public String getProtocols() { + return this.protocols; + } + + public void setProtocols(String protocols) { + this.protocols = protocols; + } + + public DataSize getMaxFramePayloadLength() { + return this.maxFramePayloadLength; + } + + public void setMaxFramePayloadLength(DataSize maxFramePayloadLength) { + this.maxFramePayloadLength = maxFramePayloadLength; + } + + public boolean isHandlePing() { + return this.handlePing; + } + + public void setHandlePing(boolean handlePing) { + this.handlePing = handlePing; + } + + public boolean isCompress() { + return this.compress; + } + + public void setCompress(boolean compress) { + this.compress = compress; + } + + } + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java index 96bbbc454ba2..c9ae1d033847 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfiguration.java @@ -16,10 +16,13 @@ package org.springframework.boot.autoconfigure.rsocket; +import java.util.function.Consumer; + import io.rsocket.core.RSocketServer; import io.rsocket.frame.decoder.PayloadDecoder; import io.rsocket.transport.netty.server.TcpServerTransport; import reactor.netty.http.server.HttpServer; +import reactor.netty.http.server.WebsocketServerSpec.Builder; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -31,6 +34,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.reactor.netty.ReactorNettyConfigurations; +import org.springframework.boot.autoconfigure.rsocket.RSocketProperties.Server.Spec; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.rsocket.context.RSocketServerBootstrap; @@ -43,9 +47,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.core.io.buffer.NettyDataBufferFactory; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.messaging.rsocket.RSocketStrategies; import org.springframework.messaging.rsocket.annotation.support.RSocketMessageHandler; +import org.springframework.util.unit.DataSize; /** * {@link EnableAutoConfiguration Auto-configuration} for RSocket servers. In the case of @@ -73,7 +78,18 @@ static class WebFluxServerConfiguration { RSocketWebSocketNettyRouteProvider rSocketWebsocketRouteProvider(RSocketProperties properties, RSocketMessageHandler messageHandler, ObjectProvider customizers) { return new RSocketWebSocketNettyRouteProvider(properties.getServer().getMappingPath(), - messageHandler.responder(), customizers.orderedStream()); + messageHandler.responder(), customizeWebsocketServerSpec(properties.getServer().getSpec()), + customizers.orderedStream()); + } + + private Consumer customizeWebsocketServerSpec(Spec spec) { + return (builder) -> { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(spec.getProtocols()).to(builder::protocols); + map.from(spec.getMaxFramePayloadLength()).asInt(DataSize::toBytes).to(builder::maxFramePayloadLength); + map.from(spec.isHandlePing()).to(builder::handlePing); + map.from(spec.isCompress()).to(builder::compress); + }; } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java index 5cb8d7f374f5..f70f9d41d546 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/rsocket/RSocketWebSocketNettyRouteProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -17,6 +17,7 @@ package org.springframework.boot.autoconfigure.rsocket; import java.util.List; +import java.util.function.Consumer; import java.util.stream.Stream; import io.rsocket.SocketAcceptor; @@ -24,6 +25,8 @@ import io.rsocket.transport.ServerTransport; import io.rsocket.transport.netty.server.WebsocketRouteTransport; import reactor.netty.http.server.HttpServerRoutes; +import reactor.netty.http.server.WebsocketServerSpec; +import reactor.netty.http.server.WebsocketServerSpec.Builder; import org.springframework.boot.rsocket.server.RSocketServerCustomizer; import org.springframework.boot.web.embedded.netty.NettyRouteProvider; @@ -32,6 +35,7 @@ * {@link NettyRouteProvider} that configures an RSocket Websocket endpoint. * * @author Brian Clozel + * @author Leo Li */ class RSocketWebSocketNettyRouteProvider implements NettyRouteProvider { @@ -41,10 +45,13 @@ class RSocketWebSocketNettyRouteProvider implements NettyRouteProvider { private final List customizers; + private final Consumer serverSpecCustomizer; + RSocketWebSocketNettyRouteProvider(String mappingPath, SocketAcceptor socketAcceptor, - Stream customizers) { + Consumer serverSpecCustomizer, Stream customizers) { this.mappingPath = mappingPath; this.socketAcceptor = socketAcceptor; + this.serverSpecCustomizer = serverSpecCustomizer; this.customizers = customizers.toList(); } @@ -53,7 +60,14 @@ public HttpServerRoutes apply(HttpServerRoutes httpServerRoutes) { RSocketServer server = RSocketServer.create(this.socketAcceptor); this.customizers.forEach((customizer) -> customizer.customize(server)); ServerTransport.ConnectionAcceptor connectionAcceptor = server.asConnectionAcceptor(); - return httpServerRoutes.ws(this.mappingPath, WebsocketRouteTransport.newHandler(connectionAcceptor)); + return httpServerRoutes.ws(this.mappingPath, WebsocketRouteTransport.newHandler(connectionAcceptor), + createWebsocketServerSpec()); + } + + private WebsocketServerSpec createWebsocketServerSpec() { + WebsocketServerSpec.Builder builder = WebsocketServerSpec.builder(); + this.serverSpecCustomizer.accept(builder); + return builder.build(); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/client/OAuth2ClientPropertiesRegistrationAdapter.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/client/OAuth2ClientPropertiesRegistrationAdapter.java deleted file mode 100644 index 343f511caf87..000000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/client/OAuth2ClientPropertiesRegistrationAdapter.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2012-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.boot.autoconfigure.security.oauth2.client; - -import java.util.Map; - -import org.springframework.security.oauth2.client.registration.ClientRegistration; - -/** - * Adapter class to convert {@link OAuth2ClientProperties} to a - * {@link ClientRegistration}. - * - * @author Phillip Webb - * @author Thiago Hirata - * @author Madhura Bhave - * @author MyeongHyeon Lee - * @since 2.1.0 - * @deprecated since 3.1.0 for removal in 3.3.0 in favor of - * {@link OAuth2ClientPropertiesMapper} - */ -@Deprecated(since = "3.1.0", forRemoval = true) -public final class OAuth2ClientPropertiesRegistrationAdapter { - - private OAuth2ClientPropertiesRegistrationAdapter() { - } - - public static Map getClientRegistrations(OAuth2ClientProperties properties) { - return new OAuth2ClientPropertiesMapper(properties).asClientRegistrations(); - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/OAuth2ResourceServerProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/OAuth2ResourceServerProperties.java index 61c478793220..2584983858e5 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/OAuth2ResourceServerProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/OAuth2ResourceServerProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; import org.springframework.core.io.Resource; +import org.springframework.security.core.GrantedAuthority; import org.springframework.util.Assert; import org.springframework.util.StreamUtils; @@ -35,6 +36,7 @@ * @author Madhura Bhave * @author Artsiom Yudovin * @author Mushtaq Ahmed + * @author Yan Kardziyaka * @since 2.1.0 */ @ConfigurationProperties(prefix = "spring.security.oauth2.resourceserver") @@ -80,6 +82,28 @@ public static class Jwt { */ private List audiences = new ArrayList<>(); + /** + * Prefix to use for {@link GrantedAuthority authorities} mapped from JWT. + */ + private String authorityPrefix; + + /** + * Regex to use for splitting the value of the authorities claim into + * {@link GrantedAuthority authorities}. + */ + private String authoritiesClaimDelimiter; + + /** + * Name of token claim to use for mapping {@link GrantedAuthority authorities} + * from JWT. + */ + private String authoritiesClaimName; + + /** + * JWT principal claim name. + */ + private String principalClaimName; + public String getJwkSetUri() { return this.jwkSetUri; } @@ -120,6 +144,38 @@ public void setAudiences(List audiences) { this.audiences = audiences; } + public String getAuthorityPrefix() { + return this.authorityPrefix; + } + + public void setAuthorityPrefix(String authorityPrefix) { + this.authorityPrefix = authorityPrefix; + } + + public String getAuthoritiesClaimDelimiter() { + return this.authoritiesClaimDelimiter; + } + + public void setAuthoritiesClaimDelimiter(String authoritiesClaimDelimiter) { + this.authoritiesClaimDelimiter = authoritiesClaimDelimiter; + } + + public String getAuthoritiesClaimName() { + return this.authoritiesClaimName; + } + + public void setAuthoritiesClaimName(String authoritiesClaimName) { + this.authoritiesClaimName = authoritiesClaimName; + } + + public String getPrincipalClaimName() { + return this.principalClaimName; + } + + public void setPrincipalClaimName(String principalClaimName) { + this.principalClaimName = principalClaimName; + } + public String readPublicKey() throws IOException { String key = "spring.security.oauth2.resourceserver.public-key-location"; Assert.notNull(this.publicKeyLocation, "PublicKeyLocation must not be null"); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerConfiguration.java index d4f5388f041d..7429a8bc405c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ class ReactiveOAuth2ResourceServerConfiguration { @Configuration(proxyBeanMethods = false) @ConditionalOnClass({ BearerTokenAuthenticationToken.class, ReactiveJwtDecoder.class }) @Import({ ReactiveOAuth2ResourceServerJwkConfiguration.JwtConfiguration.class, + ReactiveOAuth2ResourceServerJwkConfiguration.JwtConverterConfiguration.class, ReactiveOAuth2ResourceServerJwkConfiguration.WebSecurityConfiguration.class }) static class JwtConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java index 3b2546865b71..5cd528044e47 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,15 +24,16 @@ import java.util.Collections; import java.util.List; import java.util.Set; -import java.util.function.Supplier; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.oauth2.resource.IssuerUriCondition; import org.springframework.boot.autoconfigure.security.oauth2.resource.KeyValueCondition; import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; +import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -49,6 +50,9 @@ import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.JwkSetUriReactiveJwtDecoderBuilder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtGrantedAuthoritiesConverterAdapter; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.util.CollectionUtils; @@ -62,6 +66,8 @@ * @author HaiTao Zhang * @author Anastasiia Losieva * @author Mushtaq Ahmed + * @author Roman Golovin + * @author Yan Kardziyaka */ @Configuration(proxyBeanMethods = false) class ReactiveOAuth2ResourceServerJwkConfiguration { @@ -72,8 +78,12 @@ static class JwtConfiguration { private final OAuth2ResourceServerProperties.Jwt properties; - JwtConfiguration(OAuth2ResourceServerProperties properties) { + private final List> additionalValidators; + + JwtConfiguration(OAuth2ResourceServerProperties properties, + ObjectProvider> additionalValidators) { this.properties = properties.getJwt(); + this.additionalValidators = additionalValidators.orderedStream().toList(); } @Bean @@ -85,8 +95,8 @@ ReactiveJwtDecoder jwtDecoder(ObjectProvider customizer.customize(builder)); NimbusReactiveJwtDecoder nimbusReactiveJwtDecoder = builder.build(); String issuerUri = this.properties.getIssuerUri(); - Supplier> defaultValidator = (issuerUri != null) - ? () -> JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators::createDefault; + OAuth2TokenValidator defaultValidator = (issuerUri != null) + ? JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators.createDefault(); nimbusReactiveJwtDecoder.setJwtValidator(getValidators(defaultValidator)); return nimbusReactiveJwtDecoder; } @@ -97,16 +107,18 @@ private void jwsAlgorithms(Set signatureAlgorithms) { } } - private OAuth2TokenValidator getValidators(Supplier> defaultValidator) { - OAuth2TokenValidator defaultValidators = defaultValidator.get(); + private OAuth2TokenValidator getValidators(OAuth2TokenValidator defaultValidator) { List audiences = this.properties.getAudiences(); - if (CollectionUtils.isEmpty(audiences)) { - return defaultValidators; + if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) { + return defaultValidator; } List> validators = new ArrayList<>(); - validators.add(defaultValidators); - validators.add(new JwtClaimValidator>(JwtClaimNames.AUD, - (aud) -> aud != null && !Collections.disjoint(aud, audiences))); + validators.add(defaultValidator); + if (!CollectionUtils.isEmpty(audiences)) { + validators.add(new JwtClaimValidator>(JwtClaimNames.AUD, + (aud) -> aud != null && !Collections.disjoint(aud, audiences))); + } + validators.addAll(this.additionalValidators); return new DelegatingOAuth2TokenValidator<>(validators); } @@ -118,7 +130,7 @@ NimbusReactiveJwtDecoder jwtDecoderByPublicKeyValue() throws Exception { NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withPublicKey(publicKey) .signatureAlgorithm(SignatureAlgorithm.from(exactlyOneAlgorithm())) .build(); - jwtDecoder.setJwtValidator(getValidators(JwtValidators::createDefault)); + jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefault())); return jwtDecoder; } @@ -148,13 +160,42 @@ SupplierReactiveJwtDecoder jwtDecoderByIssuerUri( customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); NimbusReactiveJwtDecoder jwtDecoder = builder.build(); jwtDecoder.setJwtValidator( - getValidators(() -> JwtValidators.createDefaultWithIssuer(this.properties.getIssuerUri()))); + getValidators(JwtValidators.createDefaultWithIssuer(this.properties.getIssuerUri()))); return jwtDecoder; }); } } + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(ReactiveJwtAuthenticationConverter.class) + @Conditional(JwtConverterPropertiesCondition.class) + static class JwtConverterConfiguration { + + private final OAuth2ResourceServerProperties.Jwt properties; + + JwtConverterConfiguration(OAuth2ResourceServerProperties properties) { + this.properties = properties.getJwt(); + } + + @Bean + ReactiveJwtAuthenticationConverter reactiveJwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.properties.getAuthorityPrefix()).to(grantedAuthoritiesConverter::setAuthorityPrefix); + map.from(this.properties.getAuthoritiesClaimDelimiter()) + .to(grantedAuthoritiesConverter::setAuthoritiesClaimDelimiter); + map.from(this.properties.getAuthoritiesClaimName()) + .to(grantedAuthoritiesConverter::setAuthoritiesClaimName); + ReactiveJwtAuthenticationConverter jwtAuthenticationConverter = new ReactiveJwtAuthenticationConverter(); + map.from(this.properties.getPrincipalClaimName()).to(jwtAuthenticationConverter::setPrincipalClaimName); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter( + new ReactiveJwtGrantedAuthoritiesConverterAdapter(grantedAuthoritiesConverter)); + return jwtAuthenticationConverter; + } + + } + @Configuration(proxyBeanMethods = false) @ConditionalOnMissingBean(SecurityWebFilterChain.class) static class WebSecurityConfiguration { @@ -173,4 +214,27 @@ private void customDecoder(OAuth2ResourceServerSpec server, ReactiveJwtDecoder d } + private static class JwtConverterPropertiesCondition extends AnyNestedCondition { + + JwtConverterPropertiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "authority-prefix") + static class OnAuthorityPrefix { + + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "principal-claim-name") + static class OnPrincipalClaimName { + + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "authorities-claim-name") + static class OnAuthoritiesClaimName { + + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java index 5146570a28d0..c3e20f9841a6 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.util.Collections; import java.util.List; import java.util.Set; -import java.util.function.Supplier; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -34,6 +34,7 @@ import org.springframework.boot.autoconfigure.security.oauth2.resource.IssuerUriCondition; import org.springframework.boot.autoconfigure.security.oauth2.resource.KeyValueCondition; import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; +import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -49,6 +50,8 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder; import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.util.CollectionUtils; @@ -63,6 +66,8 @@ * @author Artsiom Yudovin * @author HaiTao Zhang * @author Mushtaq Ahmed + * @author Roman Golovin + * @author Yan Kardziyaka */ @Configuration(proxyBeanMethods = false) class OAuth2ResourceServerJwtConfiguration { @@ -73,8 +78,12 @@ static class JwtDecoderConfiguration { private final OAuth2ResourceServerProperties.Jwt properties; - JwtDecoderConfiguration(OAuth2ResourceServerProperties properties) { + private final List> additionalValidators; + + JwtDecoderConfiguration(OAuth2ResourceServerProperties properties, + ObjectProvider> additionalValidators) { this.properties = properties.getJwt(); + this.additionalValidators = additionalValidators.orderedStream().toList(); } @Bean @@ -85,8 +94,8 @@ JwtDecoder jwtDecoderByJwkKeySetUri(ObjectProvider customizer.customize(builder)); NimbusJwtDecoder nimbusJwtDecoder = builder.build(); String issuerUri = this.properties.getIssuerUri(); - Supplier> defaultValidator = (issuerUri != null) - ? () -> JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators::createDefault; + OAuth2TokenValidator defaultValidator = (issuerUri != null) + ? JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators.createDefault(); nimbusJwtDecoder.setJwtValidator(getValidators(defaultValidator)); return nimbusJwtDecoder; } @@ -97,16 +106,18 @@ private void jwsAlgorithms(Set signatureAlgorithms) { } } - private OAuth2TokenValidator getValidators(Supplier> defaultValidator) { - OAuth2TokenValidator defaultValidators = defaultValidator.get(); + private OAuth2TokenValidator getValidators(OAuth2TokenValidator defaultValidator) { List audiences = this.properties.getAudiences(); - if (CollectionUtils.isEmpty(audiences)) { - return defaultValidators; + if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) { + return defaultValidator; } List> validators = new ArrayList<>(); - validators.add(defaultValidators); - validators.add(new JwtClaimValidator>(JwtClaimNames.AUD, - (aud) -> aud != null && !Collections.disjoint(aud, audiences))); + validators.add(defaultValidator); + if (!CollectionUtils.isEmpty(audiences)) { + validators.add(new JwtClaimValidator>(JwtClaimNames.AUD, + (aud) -> aud != null && !Collections.disjoint(aud, audiences))); + } + validators.addAll(this.additionalValidators); return new DelegatingOAuth2TokenValidator<>(validators); } @@ -118,7 +129,7 @@ JwtDecoder jwtDecoderByPublicKeyValue() throws Exception { NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey) .signatureAlgorithm(SignatureAlgorithm.from(exactlyOneAlgorithm())) .build(); - jwtDecoder.setJwtValidator(getValidators(JwtValidators::createDefault)); + jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefault())); return jwtDecoder; } @@ -146,7 +157,7 @@ SupplierJwtDecoder jwtDecoderByIssuerUri(ObjectProvider customizer.customize(builder)); NimbusJwtDecoder jwtDecoder = builder.build(); - jwtDecoder.setJwtValidator(getValidators(() -> JwtValidators.createDefaultWithIssuer(issuerUri))); + jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefaultWithIssuer(issuerUri))); return jwtDecoder; }); } @@ -167,4 +178,55 @@ SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) throws Exception { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(JwtAuthenticationConverter.class) + @Conditional(JwtConverterPropertiesCondition.class) + static class JwtConverterConfiguration { + + private final OAuth2ResourceServerProperties.Jwt properties; + + JwtConverterConfiguration(OAuth2ResourceServerProperties properties) { + this.properties = properties.getJwt(); + } + + @Bean + JwtAuthenticationConverter getJwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.properties.getAuthorityPrefix()).to(grantedAuthoritiesConverter::setAuthorityPrefix); + map.from(this.properties.getAuthoritiesClaimDelimiter()) + .to(grantedAuthoritiesConverter::setAuthoritiesClaimDelimiter); + map.from(this.properties.getAuthoritiesClaimName()) + .to(grantedAuthoritiesConverter::setAuthoritiesClaimName); + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + map.from(this.properties.getPrincipalClaimName()).to(jwtAuthenticationConverter::setPrincipalClaimName); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + return jwtAuthenticationConverter; + } + + } + + private static class JwtConverterPropertiesCondition extends AnyNestedCondition { + + JwtConverterPropertiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "authority-prefix") + static class OnAuthorityPrefix { + + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "principal-claim-name") + static class OnPrincipalClaimName { + + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "authorities-claim-name") + static class OnAuthoritiesClaimName { + + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/Oauth2ResourceServerConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/Oauth2ResourceServerConfiguration.java index 36c522e39a7b..18b590eb2c24 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/Oauth2ResourceServerConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/Oauth2ResourceServerConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author 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,8 @@ class Oauth2ResourceServerConfiguration { @Configuration(proxyBeanMethods = false) @ConditionalOnClass(JwtDecoder.class) @Import({ OAuth2ResourceServerJwtConfiguration.JwtDecoderConfiguration.class, - OAuth2ResourceServerJwtConfiguration.OAuth2SecurityFilterChainConfiguration.class }) + OAuth2ResourceServerJwtConfiguration.OAuth2SecurityFilterChainConfiguration.class, + OAuth2ResourceServerJwtConfiguration.JwtConverterConfiguration.class }) static class JwtConfiguration { } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfiguration.java index f995f66cdd69..9e07d8f9b98a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -17,6 +17,7 @@ package org.springframework.boot.autoconfigure.security.reactive; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -25,8 +26,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.web.reactive.config.WebFluxConfigurer; @@ -46,11 +52,23 @@ @ConditionalOnClass({ Flux.class, EnableWebFluxSecurity.class, WebFilterChainProxy.class, WebFluxConfigurer.class }) public class ReactiveSecurityAutoConfiguration { - @Configuration(proxyBeanMethods = false) - @ConditionalOnMissingBean(WebFilterChainProxy.class) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) - @EnableWebFluxSecurity - static class EnableWebFluxSecurityConfiguration { + @Configuration(proxyBeanMethods = false) + class SpringBootWebFluxSecurityConfiguration { + + @Bean + @ConditionalOnMissingBean({ ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class, + SecurityWebFilterChain.class }) + ReactiveAuthenticationManager denyAllAuthenticationManager() { + return (authentication) -> Mono.error(new UsernameNotFoundException(authentication.getName())); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(WebFilterChainProxy.class) + @EnableWebFluxSecurity + static class EnableWebFluxSecurityConfiguration { + + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfiguration.java index c2f4ce2a7323..596f0d9c0b9d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.rsocket.RSocketMessagingAutoConfiguration; import org.springframework.boot.autoconfigure.security.SecurityProperties; @@ -55,14 +57,14 @@ * @author Madhura Bhave * @since 2.0.0 */ -@AutoConfiguration(after = RSocketMessagingAutoConfiguration.class) +@AutoConfiguration(before = ReactiveSecurityAutoConfiguration.class, after = RSocketMessagingAutoConfiguration.class) @ConditionalOnClass({ ReactiveAuthenticationManager.class }) @ConditionalOnMissingBean( value = { ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class, ReactiveAuthenticationManagerResolver.class }, - type = { "org.springframework.security.oauth2.jwt.ReactiveJwtDecoder", - "org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector" }) -@Conditional(ReactiveUserDetailsServiceAutoConfiguration.ReactiveUserDetailsServiceCondition.class) + type = { "org.springframework.security.oauth2.jwt.ReactiveJwtDecoder" }) +@Conditional({ ReactiveUserDetailsServiceAutoConfiguration.RSocketEnabledOrReactiveWebApplication.class, + ReactiveUserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured.class }) @EnableConfigurationProperties(SecurityProperties.class) public class ReactiveUserDetailsServiceAutoConfiguration { @@ -96,9 +98,9 @@ private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder return NOOP_PASSWORD_PREFIX + password; } - static class ReactiveUserDetailsServiceCondition extends AnyNestedCondition { + static class RSocketEnabledOrReactiveWebApplication extends AnyNestedCondition { - ReactiveUserDetailsServiceCondition() { + RSocketEnabledOrReactiveWebApplication() { super(ConfigurationPhase.REGISTER_BEAN); } @@ -114,4 +116,29 @@ static class ReactiveWebApplicationCondition { } + static final class MissingAlternativeOrUserPropertiesConfigured extends AnyNestedCondition { + + MissingAlternativeOrUserPropertiesConfigured() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnMissingClass({ + "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository", + "org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector" }) + static final class MissingAlternative { + + } + + @ConditionalOnProperty(prefix = "spring.security.user", name = "name") + static final class NameConfigured { + + } + + @ConditionalOnProperty(prefix = "spring.security.user", name = "password") + static final class PasswordConfigured { + + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.java index 55c3dec9a6a7..9c4fef69ea0a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,11 +25,16 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.SecurityProperties; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration.MissingAlternativeOrUserPropertiesConfigured; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManagerResolver; import org.springframework.security.authentication.AuthenticationProvider; @@ -43,9 +48,7 @@ /** * {@link EnableAutoConfiguration Auto-configuration} for a Spring Security in-memory * {@link AuthenticationManager}. Adds an {@link InMemoryUserDetailsManager} with a - * default user and generated password. This can be disabled by providing a bean of type - * {@link AuthenticationManager}, {@link AuthenticationProvider} or - * {@link UserDetailsService}. + * default user and generated password. * * @author Dave Syer * @author Rob Winch @@ -54,14 +57,10 @@ */ @AutoConfiguration @ConditionalOnClass(AuthenticationManager.class) +@Conditional(MissingAlternativeOrUserPropertiesConfigured.class) @ConditionalOnBean(ObjectPostProcessor.class) -@ConditionalOnMissingBean( - value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class, - AuthenticationManagerResolver.class }, - type = { "org.springframework.security.oauth2.jwt.JwtDecoder", - "org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector", - "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository", - "org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository" }) +@ConditionalOnMissingBean(value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class, + AuthenticationManagerResolver.class }, type = "org.springframework.security.oauth2.jwt.JwtDecoder") public class UserDetailsServiceAutoConfiguration { private static final String NOOP_PASSWORD_PREFIX = "{noop}"; @@ -96,4 +95,30 @@ private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder return NOOP_PASSWORD_PREFIX + password; } + static final class MissingAlternativeOrUserPropertiesConfigured extends AnyNestedCondition { + + MissingAlternativeOrUserPropertiesConfigured() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnMissingClass({ + "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository", + "org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector", + "org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository" }) + static final class MissingAlternative { + + } + + @ConditionalOnProperty(prefix = "spring.security.user", name = "name") + static final class NameConfigured { + + } + + @ConditionalOnProperty(prefix = "spring.security.user", name = "password") + static final class PasswordConfigured { + + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/SessionAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/SessionAutoConfiguration.java index 4d6329462831..b5e36068a8a3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/SessionAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/session/SessionAutoConfiguration.java @@ -41,8 +41,8 @@ import org.springframework.boot.autoconfigure.web.reactive.WebSessionIdResolverAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.web.server.Cookie; import org.springframework.boot.web.server.Cookie.SameSite; -import org.springframework.boot.web.servlet.server.Session.Cookie; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java new file mode 100644 index 000000000000..7be8e3eceb37 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +import java.io.FileNotFoundException; +import java.net.URL; +import java.nio.file.Path; + +import org.springframework.boot.ssl.pem.PemContent; +import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; + +/** + * Helper utility to manage a single bundle content configuration property. May possibly + * contain PEM content, a location or a directory search pattern. + * + * @param name the configuration property name (excluding any prefix) + * @param value the configuration property value + * @author Phillip Webb + */ +record BundleContentProperty(String name, String value) { + + /** + * Return if the property value is PEM content. + * @return if the value is PEM content + */ + boolean isPemContent() { + return PemContent.isPresentInText(this.value); + } + + /** + * Return if there is any property value present. + * @return if the value is present + */ + boolean hasValue() { + return StringUtils.hasText(this.value); + } + + Path toWatchPath() { + return toPath(); + } + + private Path toPath() { + try { + URL url = toUrl(); + Assert.state(isFileUrl(url), () -> "Value '%s' is not a file URL".formatted(url)); + return Path.of(url.toURI()).toAbsolutePath(); + } + catch (Exception ex) { + throw new IllegalStateException("Unable to convert value of property '%s' to a path".formatted(this.name), + ex); + } + } + + private URL toUrl() throws FileNotFoundException { + Assert.state(!isPemContent(), "Value contains PEM content"); + return ResourceUtils.getURL(this.value); + } + + private boolean isFileUrl(URL url) { + return "file".equalsIgnoreCase(url.getProtocol()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java new file mode 100644 index 000000000000..3f25ecc2c0c4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java @@ -0,0 +1,117 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.Certificate; +import java.util.List; +import java.util.Objects; + +import org.springframework.util.Assert; + +/** + * Helper used to match certificates against a {@link PrivateKey}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class CertificateMatcher { + + private static final byte[] DATA = new byte[256]; + static { + for (int i = 0; i < DATA.length; i++) { + DATA[i] = (byte) i; + } + } + + private final PrivateKey privateKey; + + private final Signature signature; + + private final byte[] generatedSignature; + + CertificateMatcher(PrivateKey privateKey) { + Assert.notNull(privateKey, "Private key must not be null"); + this.privateKey = privateKey; + this.signature = createSignature(privateKey); + Assert.notNull(this.signature, "Failed to create signature"); + this.generatedSignature = sign(this.signature, privateKey); + } + + private Signature createSignature(PrivateKey privateKey) { + try { + String algorithm = getSignatureAlgorithm(privateKey); + return (algorithm != null) ? Signature.getInstance(algorithm) : null; + } + catch (NoSuchAlgorithmException ex) { + return null; + } + } + + private static String getSignatureAlgorithm(PrivateKey privateKey) { + // https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#signature-algorithms + // https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#keypairgenerator-algorithms + return switch (privateKey.getAlgorithm()) { + case "RSA" -> "SHA256withRSA"; + case "DSA" -> "SHA256withDSA"; + case "EC" -> "SHA256withECDSA"; + case "EdDSA" -> "EdDSA"; + default -> null; + }; + } + + boolean matchesAny(List certificates) { + return (this.generatedSignature != null) && certificates.stream().anyMatch(this::matches); + } + + boolean matches(Certificate certificate) { + return matches(certificate.getPublicKey()); + } + + private boolean matches(PublicKey publicKey) { + return (this.generatedSignature != null) + && Objects.equals(this.privateKey.getAlgorithm(), publicKey.getAlgorithm()) && verify(publicKey); + } + + private boolean verify(PublicKey publicKey) { + try { + this.signature.initVerify(publicKey); + this.signature.update(DATA); + return this.signature.verify(this.generatedSignature); + } + catch (InvalidKeyException | SignatureException ex) { + return false; + } + } + + private static byte[] sign(Signature signature, PrivateKey privateKey) { + try { + signature.initSign(privateKey); + signature.update(DATA); + return signature.sign(); + } + catch (InvalidKeyException | SignatureException ex) { + return null; + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java new file mode 100644 index 000000000000..eecad97b3b4e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java @@ -0,0 +1,229 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.util.Assert; + +/** + * Watches files and directories and triggers a callback on change. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class FileWatcher implements Closeable { + + private static final Log logger = LogFactory.getLog(FileWatcher.class); + + private final Duration quietPeriod; + + private final Object lock = new Object(); + + private WatcherThread thread; + + /** + * Create a new {@link FileWatcher} instance. + * @param quietPeriod the duration that no file changes should occur before triggering + * actions + */ + FileWatcher(Duration quietPeriod) { + Assert.notNull(quietPeriod, "QuietPeriod must not be null"); + this.quietPeriod = quietPeriod; + } + + /** + * Watch the given files or directories for changes. + * @param paths the files or directories to watch + * @param action the action to take when changes are detected + */ + void watch(Set paths, Runnable action) { + Assert.notNull(paths, "Paths must not be null"); + Assert.notNull(action, "Action must not be null"); + if (paths.isEmpty()) { + return; + } + synchronized (this.lock) { + try { + if (this.thread == null) { + this.thread = new WatcherThread(); + this.thread.start(); + } + this.thread.register(new Registration(paths, action)); + } + catch (IOException ex) { + throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex); + } + } + } + + @Override + public void close() throws IOException { + synchronized (this.lock) { + if (this.thread != null) { + this.thread.close(); + this.thread.interrupt(); + try { + this.thread.join(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.thread = null; + } + } + } + + /** + * The watcher thread used to check for changes. + */ + private class WatcherThread extends Thread implements Closeable { + + private final WatchService watchService = FileSystems.getDefault().newWatchService(); + + private final Map> registrations = new ConcurrentHashMap<>(); + + private volatile boolean running = true; + + WatcherThread() throws IOException { + setName("ssl-bundle-watcher"); + setDaemon(true); + setUncaughtExceptionHandler(this::onThreadException); + } + + private void onThreadException(Thread thread, Throwable throwable) { + logger.error("Uncaught exception in file watcher thread", throwable); + } + + void register(Registration registration) throws IOException { + for (Path path : registration.paths()) { + if (!Files.isRegularFile(path) && !Files.isDirectory(path)) { + throw new IOException("'%s' is neither a file nor a directory".formatted(path)); + } + Path directory = Files.isDirectory(path) ? path : path.getParent(); + WatchKey watchKey = register(directory); + this.registrations.computeIfAbsent(watchKey, (key) -> new CopyOnWriteArrayList<>()).add(registration); + } + } + + private WatchKey register(Path directory) throws IOException { + logger.debug(LogMessage.format("Registering '%s'", directory)); + return directory.register(this.watchService, StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); + } + + @Override + public void run() { + logger.debug("Watch thread started"); + Set actions = new HashSet<>(); + while (this.running) { + try { + long timeout = FileWatcher.this.quietPeriod.toMillis(); + WatchKey key = this.watchService.poll(timeout, TimeUnit.MILLISECONDS); + if (key == null) { + actions.forEach(this::runSafely); + actions.clear(); + } + else { + accumulate(key, actions); + key.reset(); + } + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + catch (ClosedWatchServiceException ex) { + logger.debug("File watcher has been closed"); + this.running = false; + } + } + logger.debug("Watch thread stopped"); + } + + private void runSafely(Runnable action) { + try { + action.run(); + } + catch (Throwable ex) { + logger.error("Unexpected SSL reload error", ex); + } + } + + private void accumulate(WatchKey key, Set actions) { + List registrations = this.registrations.get(key); + Path directory = (Path) key.watchable(); + for (WatchEvent event : key.pollEvents()) { + Path file = directory.resolve((Path) event.context()); + for (Registration registration : registrations) { + if (registration.manages(file)) { + actions.add(registration.action()); + } + } + } + } + + @Override + public void close() throws IOException { + this.running = false; + this.watchService.close(); + } + + } + + /** + * An individual watch registration. + */ + private record Registration(Set paths, Runnable action) { + + Registration { + paths = paths.stream().map(Path::toAbsolutePath).collect(Collectors.toSet()); + } + + boolean manages(Path file) { + Path absolutePath = file.toAbsolutePath(); + return this.paths.contains(absolutePath) || isInDirectories(absolutePath); + } + + private boolean isInDirectories(Path file) { + return this.paths.stream().filter(Files::isDirectory).anyMatch(file::startsWith); + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java index 16798cb2cb05..beb58d87dc91 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java @@ -23,6 +23,7 @@ * * @author Scott Frederick * @author Phillip Webb + * @author Moritz Halbritter * @since 3.1.0 * @see PemSslStoreBundle */ @@ -57,7 +58,7 @@ public static class Store { private String type; /** - * Location or content of the certificate in PEM format. + * Location or content of the certificate or certificate chain in PEM format. */ private String certificate; @@ -71,6 +72,11 @@ public static class Store { */ private String privateKeyPassword; + /** + * Whether to verify that the private key matches the public key. + */ + private boolean verifyKeys; + public String getType() { return this.type; } @@ -103,6 +109,14 @@ public void setPrivateKeyPassword(String privateKeyPassword) { this.privateKeyPassword = privateKeyPassword; } + public boolean isVerifyKeys() { + return this.verifyKeys; + } + + public void setVerifyKeys(boolean verifyKeys) { + this.verifyKeys = verifyKeys; + } + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java index 4222a21609fe..29baa16e69ee 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java @@ -24,9 +24,11 @@ import org.springframework.boot.ssl.SslStoreBundle; import org.springframework.boot.ssl.jks.JksSslStoreBundle; import org.springframework.boot.ssl.jks.JksSslStoreDetails; +import org.springframework.boot.ssl.pem.PemSslStore; import org.springframework.boot.ssl.pem.PemSslStoreBundle; import org.springframework.boot.ssl.pem.PemSslStoreDetails; import org.springframework.core.style.ToStringCreator; +import org.springframework.util.Assert; /** * {@link SslBundle} backed by {@link JksSslBundleProperties} or @@ -95,7 +97,29 @@ public SslManagerBundle getManagers() { * @return an {@link SslBundle} instance */ public static SslBundle get(PemSslBundleProperties properties) { - return new PropertiesSslBundle(asSslStoreBundle(properties), properties); + PemSslStore keyStore = getPemSslStore("keystore", properties.getKeystore()); + if (keyStore != null) { + keyStore = keyStore.withAlias(properties.getKey().getAlias()) + .withPassword(properties.getKey().getPassword()); + } + PemSslStore trustStore = getPemSslStore("truststore", properties.getTruststore()); + SslStoreBundle storeBundle = new PemSslStoreBundle(keyStore, trustStore); + return new PropertiesSslBundle(storeBundle, properties); + } + + private static PemSslStore getPemSslStore(String propertyName, PemSslBundleProperties.Store properties) { + PemSslStore pemSslStore = PemSslStore.load(asPemSslStoreDetails(properties)); + if (properties.isVerifyKeys()) { + CertificateMatcher certificateMatcher = new CertificateMatcher(pemSslStore.privateKey()); + Assert.state(certificateMatcher.matchesAny(pemSslStore.certificates()), + "Private key in %s matches none of the certificates in the chain".formatted(propertyName)); + } + return pemSslStore; + } + + private static PemSslStoreDetails asPemSslStoreDetails(PemSslBundleProperties.Store properties) { + return new PemSslStoreDetails(properties.getType(), properties.getCertificate(), properties.getPrivateKey(), + properties.getPrivateKeyPassword()); } /** @@ -104,18 +128,8 @@ public static SslBundle get(PemSslBundleProperties properties) { * @return an {@link SslBundle} instance */ public static SslBundle get(JksSslBundleProperties properties) { - return new PropertiesSslBundle(asSslStoreBundle(properties), properties); - } - - private static SslStoreBundle asSslStoreBundle(PemSslBundleProperties properties) { - PemSslStoreDetails keyStoreDetails = asStoreDetails(properties.getKeystore()); - PemSslStoreDetails trustStoreDetails = asStoreDetails(properties.getTruststore()); - return new PemSslStoreBundle(keyStoreDetails, trustStoreDetails, properties.getKey().getAlias()); - } - - private static PemSslStoreDetails asStoreDetails(PemSslBundleProperties.Store properties) { - return new PemSslStoreDetails(properties.getType(), properties.getCertificate(), properties.getPrivateKey(), - properties.getPrivateKeyPassword()); + SslStoreBundle storeBundle = asSslStoreBundle(properties); + return new PropertiesSslBundle(storeBundle, properties); } private static SslStoreBundle asSslStoreBundle(JksSslBundleProperties properties) { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java index 12b856c8a01d..1348f16b37b8 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslAutoConfiguration.java @@ -16,8 +16,7 @@ package org.springframework.boot.autoconfigure.ssl; -import java.util.List; - +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -37,19 +36,27 @@ @EnableConfigurationProperties(SslProperties.class) public class SslAutoConfiguration { - SslAutoConfiguration() { + private final SslProperties sslProperties; + + SslAutoConfiguration(SslProperties sslProperties) { + this.sslProperties = sslProperties; + } + + @Bean + FileWatcher fileWatcher() { + return new FileWatcher(this.sslProperties.getBundle().getWatch().getFile().getQuietPeriod()); } @Bean - public SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(SslProperties sslProperties) { - return new SslPropertiesBundleRegistrar(sslProperties); + SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(FileWatcher fileWatcher) { + return new SslPropertiesBundleRegistrar(this.sslProperties, fileWatcher); } @Bean @ConditionalOnMissingBean({ SslBundleRegistry.class, SslBundles.class }) - public DefaultSslBundleRegistry sslBundleRegistry(List sslBundleRegistrars) { + DefaultSslBundleRegistry sslBundleRegistry(ObjectProvider sslBundleRegistrars) { DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry(); - sslBundleRegistrars.forEach((registrar) -> registrar.registerBundles(registry)); + sslBundleRegistrars.orderedStream().forEach((registrar) -> registrar.registerBundles(registry)); return registry; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java index e8b9fd1a4cba..b01201dba07e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslBundleProperties.java @@ -36,7 +36,7 @@ public abstract class SslBundleProperties { private final Key key = new Key(); /** - * Options for the SLL connection. + * Options for the SSL connection. */ private final Options options = new Options(); @@ -45,6 +45,11 @@ public abstract class SslBundleProperties { */ private String protocol = SslBundle.DEFAULT_PROTOCOL; + /** + * Whether to reload the SSL bundle. + */ + private boolean reloadOnUpdate; + public Key getKey() { return this.key; } @@ -61,6 +66,14 @@ public void setProtocol(String protocol) { this.protocol = protocol; } + public boolean isReloadOnUpdate() { + return this.reloadOnUpdate; + } + + public void setReloadOnUpdate(boolean reloadOnUpdate) { + this.reloadOnUpdate = reloadOnUpdate; + } + public static class Options { /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java index 49aced749021..a755a871b8d7 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslProperties.java @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.ssl; +import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; @@ -25,6 +26,7 @@ * Properties for centralized SSL trust material configuration. * * @author Scott Frederick + * @author Moritz Halbritter * @since 3.1.0 */ @ConfigurationProperties(prefix = "spring.ssl") @@ -54,6 +56,11 @@ public static class Bundles { */ private final Map jks = new LinkedHashMap<>(); + /** + * Trust material watching. + */ + private final Watch watch = new Watch(); + public Map getPem() { return this.pem; } @@ -62,6 +69,40 @@ public Map getJks() { return this.jks; } + public Watch getWatch() { + return this.watch; + } + + public static class Watch { + + /** + * File watching. + */ + private final File file = new File(); + + public File getFile() { + return this.file; + } + + public static class File { + + /** + * Quiet period, after which changes are detected. + */ + private Duration quietPeriod = Duration.ofSeconds(10); + + public Duration getQuietPeriod() { + return this.quietPeriod; + } + + public void setQuietPeriod(Duration quietPeriod) { + this.quietPeriod = quietPeriod; + } + + } + + } + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java index 89a3e7c1265c..583702c82ce2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java @@ -16,8 +16,14 @@ package org.springframework.boot.autoconfigure.ssl; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundleRegistry; @@ -28,25 +34,73 @@ * * @author Scott Frederick * @author Phillip Webb + * @author Moritz Halbritter */ class SslPropertiesBundleRegistrar implements SslBundleRegistrar { private final SslProperties.Bundles properties; - SslPropertiesBundleRegistrar(SslProperties properties) { + private final FileWatcher fileWatcher; + + SslPropertiesBundleRegistrar(SslProperties properties, FileWatcher fileWatcher) { this.properties = properties.getBundle(); + this.fileWatcher = fileWatcher; } @Override public void registerBundles(SslBundleRegistry registry) { - registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get); - registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get); + registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get, this::watchedPemPaths); + registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get, this::watchedJksPaths); } private

void registerBundles(SslBundleRegistry registry, Map properties, - Function bundleFactory) { - properties.forEach((bundleName, bundleProperties) -> registry.registerBundle(bundleName, - bundleFactory.apply(bundleProperties))); + Function bundleFactory, Function> watchedPaths) { + properties.forEach((bundleName, bundleProperties) -> { + Supplier bundleSupplier = () -> bundleFactory.apply(bundleProperties); + try { + registry.registerBundle(bundleName, bundleSupplier.get()); + if (bundleProperties.isReloadOnUpdate()) { + Supplier> pathsSupplier = () -> watchedPaths.apply(bundleProperties); + watchForUpdates(registry, bundleName, pathsSupplier, bundleSupplier); + } + } + catch (IllegalStateException ex) { + throw new IllegalStateException("Unable to register SSL bundle '%s'".formatted(bundleName), ex); + } + }); + } + + private void watchForUpdates(SslBundleRegistry registry, String bundleName, Supplier> pathsSupplier, + Supplier bundleSupplier) { + try { + this.fileWatcher.watch(pathsSupplier.get(), () -> registry.updateBundle(bundleName, bundleSupplier.get())); + } + catch (RuntimeException ex) { + throw new IllegalStateException("Unable to watch for reload on update", ex); + } + } + + private Set watchedJksPaths(JksSslBundleProperties properties) { + List watched = new ArrayList<>(); + watched.add(new BundleContentProperty("keystore.location", properties.getKeystore().getLocation())); + watched.add(new BundleContentProperty("truststore.location", properties.getTruststore().getLocation())); + return watchedPaths(watched); + } + + private Set watchedPemPaths(PemSslBundleProperties properties) { + List watched = new ArrayList<>(); + watched.add(new BundleContentProperty("keystore.private-key", properties.getKeystore().getPrivateKey())); + watched.add(new BundleContentProperty("keystore.certificate", properties.getKeystore().getCertificate())); + watched.add(new BundleContentProperty("truststore.private-key", properties.getTruststore().getPrivateKey())); + watched.add(new BundleContentProperty("truststore.certificate", properties.getTruststore().getCertificate())); + return watchedPaths(watched); + } + + private Set watchedPaths(List properties) { + return properties.stream() + .filter(BundleContentProperty::hasValue) + .map(BundleContentProperty::toWatchPath) + .collect(Collectors.toSet()); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java index 1ebb19871931..2f76a06a8c76 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,22 +16,12 @@ package org.springframework.boot.autoconfigure.task; -import java.util.concurrent.Executor; - -import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.task.TaskExecutionProperties.Shutdown; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.task.TaskExecutorBuilder; -import org.springframework.boot.task.TaskExecutorCustomizer; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Lazy; -import org.springframework.core.task.TaskDecorator; +import org.springframework.context.annotation.Import; import org.springframework.core.task.TaskExecutor; -import org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; /** @@ -39,11 +29,16 @@ * * @author Stephane Nicoll * @author Camille Vienot + * @author Moritz Halbritter * @since 2.1.0 */ @ConditionalOnClass(ThreadPoolTaskExecutor.class) @AutoConfiguration @EnableConfigurationProperties(TaskExecutionProperties.class) +@Import({ TaskExecutorConfigurations.ThreadPoolTaskExecutorBuilderConfiguration.class, + TaskExecutorConfigurations.TaskExecutorBuilderConfiguration.class, + TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration.class, + TaskExecutorConfigurations.TaskExecutorConfiguration.class }) public class TaskExecutionAutoConfiguration { /** @@ -51,33 +46,4 @@ public class TaskExecutionAutoConfiguration { */ public static final String APPLICATION_TASK_EXECUTOR_BEAN_NAME = "applicationTaskExecutor"; - @Bean - @ConditionalOnMissingBean - public TaskExecutorBuilder taskExecutorBuilder(TaskExecutionProperties properties, - ObjectProvider taskExecutorCustomizers, - ObjectProvider taskDecorator) { - TaskExecutionProperties.Pool pool = properties.getPool(); - TaskExecutorBuilder builder = new TaskExecutorBuilder(); - builder = builder.queueCapacity(pool.getQueueCapacity()); - builder = builder.corePoolSize(pool.getCoreSize()); - builder = builder.maxPoolSize(pool.getMaxSize()); - builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout()); - builder = builder.keepAlive(pool.getKeepAlive()); - Shutdown shutdown = properties.getShutdown(); - builder = builder.awaitTermination(shutdown.isAwaitTermination()); - builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod()); - builder = builder.threadNamePrefix(properties.getThreadNamePrefix()); - builder = builder.customizers(taskExecutorCustomizers.orderedStream()::iterator); - builder = builder.taskDecorator(taskDecorator.getIfUnique()); - return builder; - } - - @Lazy - @Bean(name = { APPLICATION_TASK_EXECUTOR_BEAN_NAME, - AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME }) - @ConditionalOnMissingBean(Executor.class) - public ThreadPoolTaskExecutor applicationTaskExecutor(TaskExecutorBuilder builder) { - return builder.build(); - } - } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java index c8bcc17ce999..2781e99a0c6f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ * * @author Stephane Nicoll * @author Filip Hrisafov + * @author Yanming Zhou * @since 2.1.0 */ @ConfigurationProperties("spring.task.execution") @@ -32,6 +33,8 @@ public class TaskExecutionProperties { private final Pool pool = new Pool(); + private final Simple simple = new Simple(); + private final Shutdown shutdown = new Shutdown(); /** @@ -39,6 +42,10 @@ public class TaskExecutionProperties { */ private String threadNamePrefix = "task-"; + public Simple getSimple() { + return this.simple; + } + public Pool getPool() { return this.pool; } @@ -55,6 +62,24 @@ public void setThreadNamePrefix(String threadNamePrefix) { this.threadNamePrefix = threadNamePrefix; } + public static class Simple { + + /** + * Set the maximum number of parallel accesses allowed. -1 indicates no + * concurrency limit at all. + */ + private Integer concurrencyLimit; + + public Integer getConcurrencyLimit() { + return this.concurrencyLimit; + } + + public void setConcurrencyLimit(Integer concurrencyLimit) { + this.concurrencyLimit = concurrencyLimit; + } + + } + public static class Pool { /** @@ -86,6 +111,8 @@ public static class Pool { */ private Duration keepAlive = Duration.ofSeconds(60); + private final Shutdown shutdown = new Shutdown(); + public int getQueueCapacity() { return this.queueCapacity; } @@ -126,6 +153,28 @@ public void setKeepAlive(Duration keepAlive) { this.keepAlive = keepAlive; } + public Shutdown getShutdown() { + return this.shutdown; + } + + public static class Shutdown { + + /** + * Whether to accept further tasks after the application context close phase + * has begun. + */ + private boolean acceptTasksAfterContextClose; + + public boolean isAcceptTasksAfterContextClose() { + return this.acceptTasksAfterContextClose; + } + + public void setAcceptTasksAfterContextClose(boolean acceptTasksAfterContextClose) { + this.acceptTasksAfterContextClose = acceptTasksAfterContextClose; + } + + } + } public static class Shutdown { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java new file mode 100644 index 000000000000..9e46e1063915 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java @@ -0,0 +1,190 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.task; + +import java.util.concurrent.Executor; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder; +import org.springframework.boot.task.SimpleAsyncTaskExecutorCustomizer; +import org.springframework.boot.task.TaskExecutorBuilder; +import org.springframework.boot.task.TaskExecutorCustomizer; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import org.springframework.boot.task.ThreadPoolTaskExecutorCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskDecorator; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * {@link TaskExecutor} configurations to be imported by + * {@link TaskExecutionAutoConfiguration} in a specific order. + * + * @author Andy Wilkinson + * @author Moritz Halbritter + * @author Yanming Zhou + */ +class TaskExecutorConfigurations { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(Executor.class) + @SuppressWarnings("removal") + static class TaskExecutorConfiguration { + + @Bean(name = { TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, + AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME }) + @ConditionalOnThreading(Threading.VIRTUAL) + SimpleAsyncTaskExecutor applicationTaskExecutorVirtualThreads(SimpleAsyncTaskExecutorBuilder builder) { + return builder.build(); + } + + @Lazy + @Bean(name = { TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, + AsyncAnnotationBeanPostProcessor.DEFAULT_TASK_EXECUTOR_BEAN_NAME }) + @ConditionalOnThreading(Threading.PLATFORM) + ThreadPoolTaskExecutor applicationTaskExecutor(TaskExecutorBuilder taskExecutorBuilder, + ObjectProvider threadPoolTaskExecutorBuilderProvider) { + ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder = threadPoolTaskExecutorBuilderProvider + .getIfUnique(); + if (threadPoolTaskExecutorBuilder != null) { + return threadPoolTaskExecutorBuilder.build(); + } + return taskExecutorBuilder.build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("removal") + static class TaskExecutorBuilderConfiguration { + + @Bean + @ConditionalOnMissingBean + @Deprecated(since = "3.2.0", forRemoval = true) + TaskExecutorBuilder taskExecutorBuilder(TaskExecutionProperties properties, + ObjectProvider taskExecutorCustomizers, + ObjectProvider taskDecorator) { + TaskExecutionProperties.Pool pool = properties.getPool(); + TaskExecutorBuilder builder = new TaskExecutorBuilder(); + builder = builder.queueCapacity(pool.getQueueCapacity()); + builder = builder.corePoolSize(pool.getCoreSize()); + builder = builder.maxPoolSize(pool.getMaxSize()); + builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout()); + builder = builder.keepAlive(pool.getKeepAlive()); + TaskExecutionProperties.Shutdown shutdown = properties.getShutdown(); + builder = builder.awaitTermination(shutdown.isAwaitTermination()); + builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod()); + builder = builder.threadNamePrefix(properties.getThreadNamePrefix()); + builder = builder.customizers(taskExecutorCustomizers.orderedStream()::iterator); + builder = builder.taskDecorator(taskDecorator.getIfUnique()); + return builder; + } + + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("removal") + static class ThreadPoolTaskExecutorBuilderConfiguration { + + @Bean + @ConditionalOnMissingBean({ TaskExecutorBuilder.class, ThreadPoolTaskExecutorBuilder.class }) + ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder(TaskExecutionProperties properties, + ObjectProvider threadPoolTaskExecutorCustomizers, + ObjectProvider taskExecutorCustomizers, + ObjectProvider taskDecorator) { + TaskExecutionProperties.Pool pool = properties.getPool(); + ThreadPoolTaskExecutorBuilder builder = new ThreadPoolTaskExecutorBuilder(); + builder = builder.queueCapacity(pool.getQueueCapacity()); + builder = builder.corePoolSize(pool.getCoreSize()); + builder = builder.maxPoolSize(pool.getMaxSize()); + builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout()); + builder = builder.keepAlive(pool.getKeepAlive()); + builder = builder.acceptTasksAfterContextClose(pool.getShutdown().isAcceptTasksAfterContextClose()); + TaskExecutionProperties.Shutdown shutdown = properties.getShutdown(); + builder = builder.awaitTermination(shutdown.isAwaitTermination()); + builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod()); + builder = builder.threadNamePrefix(properties.getThreadNamePrefix()); + builder = builder.customizers(threadPoolTaskExecutorCustomizers.orderedStream()::iterator); + builder = builder.taskDecorator(taskDecorator.getIfUnique()); + // Apply the deprecated TaskExecutorCustomizers, too + builder = builder.additionalCustomizers(taskExecutorCustomizers.orderedStream().map(this::adapt).toList()); + return builder; + } + + private ThreadPoolTaskExecutorCustomizer adapt(TaskExecutorCustomizer customizer) { + return customizer::customize; + } + + } + + @Configuration(proxyBeanMethods = false) + static class SimpleAsyncTaskExecutorBuilderConfiguration { + + private final TaskExecutionProperties properties; + + private final ObjectProvider taskExecutorCustomizers; + + private final ObjectProvider taskDecorator; + + SimpleAsyncTaskExecutorBuilderConfiguration(TaskExecutionProperties properties, + ObjectProvider taskExecutorCustomizers, + ObjectProvider taskDecorator) { + this.properties = properties; + this.taskExecutorCustomizers = taskExecutorCustomizers; + this.taskDecorator = taskDecorator; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.PLATFORM) + SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilder() { + return builder(); + } + + @Bean(name = "simpleAsyncTaskExecutorBuilder") + @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.VIRTUAL) + SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilderVirtualThreads() { + SimpleAsyncTaskExecutorBuilder builder = builder(); + builder = builder.virtualThreads(true); + return builder; + } + + private SimpleAsyncTaskExecutorBuilder builder() { + SimpleAsyncTaskExecutorBuilder builder = new SimpleAsyncTaskExecutorBuilder(); + builder = builder.threadNamePrefix(this.properties.getThreadNamePrefix()); + builder = builder.customizers(this.taskExecutorCustomizers.orderedStream()::iterator); + builder = builder.taskDecorator(this.taskDecorator.getIfUnique()); + TaskExecutionProperties.Simple simple = this.properties.getSimple(); + builder = builder.concurrencyLimit(simple.getConcurrencyLimit()); + TaskExecutionProperties.Shutdown shutdown = this.properties.getShutdown(); + if (shutdown.isAwaitTermination()) { + builder = builder.taskTerminationTimeout(shutdown.getAwaitTerminationPeriod()); + } + return builder; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfiguration.java index a5dd93bf4f44..5909153ee8e3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,22 +16,15 @@ package org.springframework.boot.autoconfigure.task; -import java.util.concurrent.ScheduledExecutorService; - -import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.LazyInitializationExcludeFilter; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.task.TaskSchedulingProperties.Shutdown; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.task.TaskSchedulerBuilder; -import org.springframework.boot.task.TaskSchedulerCustomizer; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.scheduling.TaskScheduler; -import org.springframework.scheduling.annotation.SchedulingConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.scheduling.config.TaskManagementConfigUtils; @@ -39,38 +32,22 @@ * {@link EnableAutoConfiguration Auto-configuration} for {@link TaskScheduler}. * * @author Stephane Nicoll + * @author Moritz Halbritter * @since 2.1.0 */ @ConditionalOnClass(ThreadPoolTaskScheduler.class) @AutoConfiguration(after = TaskExecutionAutoConfiguration.class) @EnableConfigurationProperties(TaskSchedulingProperties.class) +@Import({ TaskSchedulingConfigurations.ThreadPoolTaskSchedulerBuilderConfiguration.class, + TaskSchedulingConfigurations.TaskSchedulerBuilderConfiguration.class, + TaskSchedulingConfigurations.SimpleAsyncTaskSchedulerBuilderConfiguration.class, + TaskSchedulingConfigurations.TaskSchedulerConfiguration.class }) public class TaskSchedulingAutoConfiguration { - @Bean - @ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME) - @ConditionalOnMissingBean({ SchedulingConfigurer.class, TaskScheduler.class, ScheduledExecutorService.class }) - public ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder builder) { - return builder.build(); - } - @Bean @ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME) public static LazyInitializationExcludeFilter scheduledBeanLazyInitializationExcludeFilter() { return new ScheduledBeanLazyInitializationExcludeFilter(); } - @Bean - @ConditionalOnMissingBean - public TaskSchedulerBuilder taskSchedulerBuilder(TaskSchedulingProperties properties, - ObjectProvider taskSchedulerCustomizers) { - TaskSchedulerBuilder builder = new TaskSchedulerBuilder(); - builder = builder.poolSize(properties.getPool().getSize()); - Shutdown shutdown = properties.getShutdown(); - builder = builder.awaitTermination(shutdown.isAwaitTermination()); - builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod()); - builder = builder.threadNamePrefix(properties.getThreadNamePrefix()); - builder = builder.customizers(taskSchedulerCustomizers); - return builder; - } - } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingConfigurations.java new file mode 100644 index 000000000000..59bee07b10c1 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingConfigurations.java @@ -0,0 +1,164 @@ +/* + * Copyright 2012-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.boot.autoconfigure.task; + +import java.util.concurrent.ScheduledExecutorService; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder; +import org.springframework.boot.task.SimpleAsyncTaskSchedulerCustomizer; +import org.springframework.boot.task.TaskSchedulerBuilder; +import org.springframework.boot.task.TaskSchedulerCustomizer; +import org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder; +import org.springframework.boot.task.ThreadPoolTaskSchedulerCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.TaskManagementConfigUtils; + +/** + * {@link TaskScheduler} configurations to be imported by + * {@link TaskSchedulingAutoConfiguration} in a specific order. + * + * @author Moritz Halbritter + */ +class TaskSchedulingConfigurations { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME) + @ConditionalOnMissingBean({ TaskScheduler.class, ScheduledExecutorService.class }) + @SuppressWarnings("removal") + static class TaskSchedulerConfiguration { + + @Bean(name = "taskScheduler") + @ConditionalOnThreading(Threading.VIRTUAL) + SimpleAsyncTaskScheduler taskSchedulerVirtualThreads(SimpleAsyncTaskSchedulerBuilder builder) { + return builder.build(); + } + + @Bean + @ConditionalOnThreading(Threading.PLATFORM) + ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder taskSchedulerBuilder, + ObjectProvider threadPoolTaskSchedulerBuilderProvider) { + ThreadPoolTaskSchedulerBuilder threadPoolTaskSchedulerBuilder = threadPoolTaskSchedulerBuilderProvider + .getIfUnique(); + if (threadPoolTaskSchedulerBuilder != null) { + return threadPoolTaskSchedulerBuilder.build(); + } + return taskSchedulerBuilder.build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("removal") + static class TaskSchedulerBuilderConfiguration { + + @Bean + @ConditionalOnMissingBean + TaskSchedulerBuilder taskSchedulerBuilder(TaskSchedulingProperties properties, + ObjectProvider taskSchedulerCustomizers) { + TaskSchedulerBuilder builder = new TaskSchedulerBuilder(); + builder = builder.poolSize(properties.getPool().getSize()); + TaskSchedulingProperties.Shutdown shutdown = properties.getShutdown(); + builder = builder.awaitTermination(shutdown.isAwaitTermination()); + builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod()); + builder = builder.threadNamePrefix(properties.getThreadNamePrefix()); + builder = builder.customizers(taskSchedulerCustomizers); + return builder; + } + + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("removal") + static class ThreadPoolTaskSchedulerBuilderConfiguration { + + @Bean + @ConditionalOnMissingBean({ TaskSchedulerBuilder.class, ThreadPoolTaskSchedulerBuilder.class }) + ThreadPoolTaskSchedulerBuilder threadPoolTaskSchedulerBuilder(TaskSchedulingProperties properties, + ObjectProvider threadPoolTaskSchedulerCustomizers, + ObjectProvider taskSchedulerCustomizers) { + TaskSchedulingProperties.Shutdown shutdown = properties.getShutdown(); + ThreadPoolTaskSchedulerBuilder builder = new ThreadPoolTaskSchedulerBuilder(); + builder = builder.poolSize(properties.getPool().getSize()); + builder = builder.awaitTermination(shutdown.isAwaitTermination()); + builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod()); + builder = builder.threadNamePrefix(properties.getThreadNamePrefix()); + builder = builder.customizers(threadPoolTaskSchedulerCustomizers); + // Apply the deprecated TaskSchedulerCustomizers, too + builder = builder.additionalCustomizers(taskSchedulerCustomizers.orderedStream().map(this::adapt).toList()); + return builder; + } + + private ThreadPoolTaskSchedulerCustomizer adapt(TaskSchedulerCustomizer customizer) { + return customizer::customize; + } + + } + + @Configuration(proxyBeanMethods = false) + static class SimpleAsyncTaskSchedulerBuilderConfiguration { + + private final TaskSchedulingProperties properties; + + private final ObjectProvider taskSchedulerCustomizers; + + SimpleAsyncTaskSchedulerBuilderConfiguration(TaskSchedulingProperties properties, + ObjectProvider taskSchedulerCustomizers) { + this.properties = properties; + this.taskSchedulerCustomizers = taskSchedulerCustomizers; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.PLATFORM) + SimpleAsyncTaskSchedulerBuilder simpleAsyncTaskSchedulerBuilder() { + return builder(); + } + + @Bean(name = "simpleAsyncTaskSchedulerBuilder") + @ConditionalOnMissingBean + @ConditionalOnThreading(Threading.VIRTUAL) + SimpleAsyncTaskSchedulerBuilder simpleAsyncTaskSchedulerBuilderVirtualThreads() { + SimpleAsyncTaskSchedulerBuilder builder = builder(); + builder = builder.virtualThreads(true); + return builder; + } + + private SimpleAsyncTaskSchedulerBuilder builder() { + SimpleAsyncTaskSchedulerBuilder builder = new SimpleAsyncTaskSchedulerBuilder(); + builder = builder.threadNamePrefix(this.properties.getThreadNamePrefix()); + builder = builder.customizers(this.taskSchedulerCustomizers.orderedStream()::iterator); + TaskSchedulingProperties.Simple simple = this.properties.getSimple(); + builder = builder.concurrencyLimit(simple.getConcurrencyLimit()); + TaskSchedulingProperties.Shutdown shutdown = this.properties.getShutdown(); + if (shutdown.isAwaitTermination()) { + builder = builder.taskTerminationTimeout(shutdown.getAwaitTerminationPeriod()); + } + return builder; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java index f9bc7beac2c1..ea26f3261039 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskSchedulingProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -31,6 +31,8 @@ public class TaskSchedulingProperties { private final Pool pool = new Pool(); + private final Simple simple = new Simple(); + private final Shutdown shutdown = new Shutdown(); /** @@ -42,6 +44,10 @@ public Pool getPool() { return this.pool; } + public Simple getSimple() { + return this.simple; + } + public Shutdown getShutdown() { return this.shutdown; } @@ -71,6 +77,24 @@ public void setSize(int size) { } + public static class Simple { + + /** + * Set the maximum number of parallel accesses allowed. -1 indicates no + * concurrency limit at all. + */ + private Integer concurrencyLimit; + + public Integer getConcurrencyLimit() { + return this.concurrencyLimit; + } + + public void setConcurrencyLimit(Integer concurrencyLimit) { + this.concurrencyLimit = concurrencyLimit; + } + + } + public static class Shutdown { /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/Threading.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/Threading.java new file mode 100644 index 000000000000..b82e29953fce --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/Threading.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-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.boot.autoconfigure.thread; + +import org.springframework.boot.system.JavaVersion; +import org.springframework.core.env.Environment; + +/** + * Threading of the application. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +public enum Threading { + + /** + * Platform threads. Active if virtual threads are not active. + */ + PLATFORM { + + @Override + public boolean isActive(Environment environment) { + return !VIRTUAL.isActive(environment); + } + + }, + /** + * Virtual threads. Active if {@code spring.threads.virtual.enabled} is {@code true} + * and running on Java 21 or later. + */ + VIRTUAL { + + @Override + public boolean isActive(Environment environment) { + return environment.getProperty("spring.threads.virtual.enabled", boolean.class, false) + && JavaVersion.getJavaVersion().isEqualOrNewerThan(JavaVersion.TWENTY_ONE); + } + + }; + + /** + * Determines whether the threading is active. + * @param environment the environment + * @return whether the threading is active + */ + public abstract boolean isActive(Environment environment); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/package-info.java new file mode 100644 index 000000000000..61c141a651aa --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/thread/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Classes related to threads. + */ +package org.springframework.boot.autoconfigure.thread; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizer.java new file mode 100644 index 000000000000..18a072b0f837 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizer.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-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.boot.autoconfigure.transaction; + +import java.util.List; + +import org.springframework.transaction.ConfigurableTransactionManager; +import org.springframework.transaction.TransactionExecutionListener; + +/** + * {@link TransactionManagerCustomizer} that adds {@link TransactionExecutionListener + * execution listeners} to any transaction manager that is + * {@link ConfigurableTransactionManager configurable}. + * + * @author Andy Wilkinson + */ +class ExecutionListenersTransactionManagerCustomizer + implements TransactionManagerCustomizer { + + private final List listeners; + + ExecutionListenersTransactionManagerCustomizer(List listeners) { + this.listeners = listeners; + } + + @Override + public void customize(ConfigurableTransactionManager transactionManager) { + this.listeners.forEach(transactionManager::addListener); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/PlatformTransactionManagerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/PlatformTransactionManagerCustomizer.java index 64c7fd927bdd..1b5cd099471e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/PlatformTransactionManagerCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/PlatformTransactionManagerCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -26,14 +26,12 @@ * @param the transaction manager type * @author Phillip Webb * @since 1.5.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link TransactionManagerCustomizer}. */ +@Deprecated(since = "3.2.0", forRemoval = true) @FunctionalInterface -public interface PlatformTransactionManagerCustomizer { - - /** - * Customize the given transaction manager. - * @param transactionManager the transaction manager to customize - */ - void customize(T transactionManager); +public interface PlatformTransactionManagerCustomizer + extends TransactionManagerCustomizer { } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfiguration.java index 7f3efffddde4..b1268020c454 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfiguration.java @@ -16,7 +16,6 @@ package org.springframework.boot.autoconfigure.transaction; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.LazyInitializationExcludeFilter; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -24,7 +23,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.transaction.PlatformTransactionManager; @@ -46,16 +44,8 @@ */ @AutoConfiguration @ConditionalOnClass(PlatformTransactionManager.class) -@EnableConfigurationProperties(TransactionProperties.class) public class TransactionAutoConfiguration { - @Bean - @ConditionalOnMissingBean - public TransactionManagerCustomizers platformTransactionManagerCustomizers( - ObjectProvider> customizers) { - return new TransactionManagerCustomizers(customizers.orderedStream().toList()); - } - @Bean @ConditionalOnMissingBean @ConditionalOnSingleCandidate(ReactiveTransactionManager.class) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfiguration.java new file mode 100644 index 000000000000..aba33e3226c3 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfiguration.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-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.boot.autoconfigure.transaction; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionExecutionListener; +import org.springframework.transaction.TransactionManager; + +/** + * Auto-configuration for the customization of a {@link TransactionManager}. + * + * @author Andy Wilkinson + * @since 3.2.0 + */ +@ConditionalOnClass(PlatformTransactionManager.class) +@AutoConfiguration(before = TransactionAutoConfiguration.class) +@EnableConfigurationProperties(TransactionProperties.class) +public class TransactionManagerCustomizationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + TransactionManagerCustomizers platformTransactionManagerCustomizers( + ObjectProvider> customizers) { + return TransactionManagerCustomizers.of(customizers.orderedStream().toList()); + } + + @Bean + ExecutionListenersTransactionManagerCustomizer transactionExecutionListeners( + ObjectProvider listeners) { + return new ExecutionListenersTransactionManagerCustomizer(listeners.orderedStream().toList()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizer.java new file mode 100644 index 000000000000..e268fe87a49f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-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.boot.autoconfigure.transaction; + +import org.springframework.transaction.TransactionManager; + +/** + * Callback interface that can be implemented by beans wishing to customize + * {@link TransactionManager TransactionManagers} while retaining default + * auto-configuration. + * + * @param the transaction manager type + * @author Andy Wilkinson + * @since 3.2.0 + */ +public interface TransactionManagerCustomizer { + + /** + * Customize the given transaction manager. + * @param transactionManager the transaction manager to customize + */ + void customize(T transactionManager); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizers.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizers.java index 2bb603c4bc60..88f513a3c9d0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizers.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizers.java @@ -23,26 +23,69 @@ import org.springframework.boot.util.LambdaSafe; import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionManager; /** - * A collection of {@link PlatformTransactionManagerCustomizer}. + * A collection of {@link TransactionManagerCustomizer TransactionManagerCustomizers}. * * @author Phillip Webb + * @author Andy Wilkinson * @since 1.5.0 */ public class TransactionManagerCustomizers { - private final List> customizers; + private final List> customizers; + /** + * Creates a new {@code TransactionManagerCustomizers} instance containing the given + * {@code customizers}. + * @param customizers the customizers + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of {@link #of(Collection)} + */ + @SuppressWarnings("removal") + @Deprecated(since = "3.2.0", forRemoval = true) public TransactionManagerCustomizers(Collection> customizers) { - this.customizers = (customizers != null) ? new ArrayList<>(customizers) : Collections.emptyList(); + this((customizers != null) ? new ArrayList<>(customizers) + : Collections.>emptyList()); } + private TransactionManagerCustomizers(List> customizers) { + this.customizers = customizers; + } + + /** + * Customize the given {@code platformTransactionManager}. + * @param platformTransactionManager the platform transaction manager to customize + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link #customize(TransactionManager)} + */ + @Deprecated(since = "3.2.0", forRemoval = true) + public void customize(PlatformTransactionManager platformTransactionManager) { + customize((TransactionManager) platformTransactionManager); + } + + /** + * Customize the given {@code transactionManager}. + * @param transactionManager the transaction manager to customize + * @since 3.2.0 + */ @SuppressWarnings("unchecked") - public void customize(PlatformTransactionManager transactionManager) { - LambdaSafe.callbacks(PlatformTransactionManagerCustomizer.class, this.customizers, transactionManager) + public void customize(TransactionManager transactionManager) { + LambdaSafe.callbacks(TransactionManagerCustomizer.class, this.customizers, transactionManager) .withLogger(TransactionManagerCustomizers.class) .invoke((customizer) -> customizer.customize(transactionManager)); } + /** + * Returns a new {@code TransactionManagerCustomizers} instance containing the given + * {@code customizers}. + * @param customizers the customizers + * @return the new instance + * @since 3.2.0 + */ + public static TransactionManagerCustomizers of(Collection> customizers) { + return new TransactionManagerCustomizers((customizers != null) ? new ArrayList<>(customizers) + : Collections.>emptyList()); + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionProperties.java index d48200a06f39..a9170ee0cd78 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/TransactionProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -32,7 +32,7 @@ * @since 1.5.0 */ @ConfigurationProperties(prefix = "spring.transaction") -public class TransactionProperties implements PlatformTransactionManagerCustomizer { +public class TransactionProperties implements TransactionManagerCustomizer { /** * Default transaction timeout. If a duration suffix is not specified, seconds will be diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JndiJtaConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JndiJtaConfiguration.java index 91b59dd02af0..3db22f4cc775 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JndiJtaConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JndiJtaConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.TransactionManager; import org.springframework.transaction.jta.JtaTransactionManager; /** @@ -43,7 +44,8 @@ class JndiJtaConfiguration { JtaTransactionManager transactionManager( ObjectProvider transactionManagerCustomizers) { JtaTransactionManager jtaTransactionManager = new JtaTransactionManager(); - transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(jtaTransactionManager)); + transactionManagerCustomizers + .ifAvailable((customizers) -> customizers.customize((TransactionManager) jtaTransactionManager)); return jtaTransactionManager; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfiguration.java index a9040ac4ba1f..480e32b81dfa 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfiguration.java @@ -25,6 +25,7 @@ import org.springframework.boot.autoconfigure.jms.artemis.ArtemisAutoConfiguration; import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration; import org.springframework.context.annotation.Import; /** @@ -36,7 +37,8 @@ * @since 1.2.0 */ @AutoConfiguration(before = { XADataSourceAutoConfiguration.class, ActiveMQAutoConfiguration.class, - ArtemisAutoConfiguration.class, HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class }) + ArtemisAutoConfiguration.class, HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class, + TransactionManagerCustomizationAutoConfiguration.class }) @ConditionalOnClass(jakarta.transaction.Transaction.class) @ConditionalOnProperty(prefix = "spring.jta", value = "enabled", matchIfMissing = true) @Import(JndiJtaConfiguration.class) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapter.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapter.java index 700a68a91610..e6c792838d86 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapter.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapter.java @@ -39,6 +39,7 @@ * * @author Stephane Nicoll * @author Phillip Webb + * @author Zisis Pavloudis * @since 2.0.0 */ public class ValidatorAdapter implements SmartValidator, ApplicationContextAware, InitializingBean, DisposableBean { @@ -153,4 +154,13 @@ private static Validator wrap(Validator validator, boolean existingBean) { return validator; } + @Override + @SuppressWarnings("unchecked") + public T unwrap(Class type) { + if (type.isInstance(this.target)) { + return (T) this.target; + } + return this.target.unwrap(type); + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorProperties.java index 9900a86157b9..e8bbce97d980 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 the original author 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,6 +55,11 @@ public class ErrorProperties { */ private IncludeAttribute includeBindingErrors = IncludeAttribute.NEVER; + /** + * When to include "path" attribute. + */ + private IncludeAttribute includePath = IncludeAttribute.ALWAYS; + private final Whitelabel whitelabel = new Whitelabel(); public String getPath() { @@ -97,6 +102,14 @@ public void setIncludeBindingErrors(IncludeAttribute includeBindingErrors) { this.includeBindingErrors = includeBindingErrors; } + public IncludeAttribute getIncludePath() { + return this.includePath; + } + + public void setIncludePath(IncludeAttribute includePath) { + this.includePath = includePath; + } + public Whitelabel getWhitelabel() { return this.whitelabel; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java index a4e5bcf2d1a8..d0be8c72f65d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,17 +154,6 @@ public void setServerHeader(String serverHeader) { this.serverHeader = serverHeader; } - @Deprecated(since = "3.0.0", forRemoval = true) - @DeprecatedConfigurationProperty - public DataSize getMaxHttpHeaderSize() { - return getMaxHttpRequestHeaderSize(); - } - - @Deprecated(since = "3.0.0", forRemoval = true) - public void setMaxHttpHeaderSize(DataSize maxHttpHeaderSize) { - setMaxHttpRequestHeaderSize(maxHttpHeaderSize); - } - public DataSize getMaxHttpRequestHeaderSize() { return this.maxHttpRequestHeaderSize; } @@ -340,6 +329,11 @@ public static class Session { @DurationUnit(ChronoUnit.SECONDS) private Duration timeout = Duration.ofMinutes(30); + /** + * The maximum number of sessions that can be stored. + */ + private int maxSessions = 10000; + @NestedConfigurationProperty private final Cookie cookie = new Cookie(); @@ -351,6 +345,14 @@ public void setTimeout(Duration timeout) { this.timeout = timeout; } + public int getMaxSessions() { + return this.maxSessions; + } + + public void setMaxSessions(int maxSessions) { + this.maxSessions = maxSessions; + } + public Cookie getCookie() { return this.cookie; } @@ -475,7 +477,7 @@ public static class Tomcat { /** * Whether to reject requests with illegal header names or values. */ - @Deprecated(since = "2.7.12", forRemoval = true) + @Deprecated(since = "2.7.12", forRemoval = true) // Remove in 3.3 private boolean rejectIllegalHeader = true; /** @@ -634,11 +636,13 @@ public void setConnectionTimeout(Duration connectionTimeout) { this.connectionTimeout = connectionTimeout; } - @DeprecatedConfigurationProperty(reason = "The setting has been deprecated in Tomcat") + @Deprecated(since = "3.2.0", forRemoval = true) + @DeprecatedConfigurationProperty(reason = "The setting has been deprecated in Tomcat", since = "3.2.0") public boolean isRejectIllegalHeader() { return this.rejectIllegalHeader; } + @Deprecated(since = "3.2.0", forRemoval = true) public void setRejectIllegalHeader(boolean rejectIllegalHeader) { this.rejectIllegalHeader = rejectIllegalHeader; } @@ -914,6 +918,11 @@ public static class Threads { */ private int minSpare = 10; + /** + * Maximum capacity of the thread pool's backing queue. + */ + private int maxQueueCapacity = 2147483647; + public int getMax() { return this.max; } @@ -930,6 +939,14 @@ public void setMinSpare(int minSpare) { this.minSpare = minSpare; } + public int getMaxQueueCapacity() { + return this.maxQueueCapacity; + } + + public void setMaxQueueCapacity(int maxQueueCapacity) { + this.maxQueueCapacity = maxQueueCapacity; + } + } /** @@ -1123,6 +1140,12 @@ public static class Jetty { */ private DataSize maxHttpResponseHeaderSize = DataSize.ofKilobytes(8); + /** + * Maximum number of connections that the server accepts and processes at any + * given time. + */ + private int maxConnections = -1; + public Accesslog getAccesslog() { return this.accesslog; } @@ -1155,6 +1178,14 @@ public void setMaxHttpResponseHeaderSize(DataSize maxHttpResponseHeaderSize) { this.maxHttpResponseHeaderSize = maxHttpResponseHeaderSize; } + public int getMaxConnections() { + return this.maxConnections; + } + + public void setMaxConnections(int maxConnections) { + this.maxConnections = maxConnections; + } + /** * Jetty access log properties. */ @@ -1395,11 +1426,6 @@ public static class Netty { */ private DataSize initialBufferSize = DataSize.ofBytes(128); - /** - * Maximum chunk size that can be decoded for an HTTP request. - */ - private DataSize maxChunkSize = DataSize.ofKilobytes(8); - /** * Maximum length that can be decoded for an HTTP request's initial line. */ @@ -1446,17 +1472,6 @@ public void setInitialBufferSize(DataSize initialBufferSize) { this.initialBufferSize = initialBufferSize; } - @Deprecated(since = "3.0.0", forRemoval = true) - @DeprecatedConfigurationProperty(reason = "Deprecated for removal in Reactor Netty") - public DataSize getMaxChunkSize() { - return this.maxChunkSize; - } - - @Deprecated(since = "3.0.0", forRemoval = true) - public void setMaxChunkSize(DataSize maxChunkSize) { - this.maxChunkSize = maxChunkSize; - } - public DataSize getMaxInitialLineLength() { return this.maxInitialLineLength; } @@ -1647,7 +1662,7 @@ public void setMaxCookies(Integer maxCookies) { this.maxCookies = maxCookies; } - @DeprecatedConfigurationProperty(replacement = "server.undertow.decode-slash") + @DeprecatedConfigurationProperty(replacement = "server.undertow.decode-slash", since = "3.0.3") @Deprecated(forRemoval = true, since = "3.0.3") public boolean isAllowEncodedSlash() { return this.allowEncodedSlash; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/AutoConfiguredRestClientSsl.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/AutoConfiguredRestClientSsl.java new file mode 100644 index 000000000000..8fc9dd9663b2 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/AutoConfiguredRestClientSsl.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.client; + +import java.util.function.Consumer; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * An auto-configured {@link RestClientSsl} implementation. + * + * @author Phillip Webb + */ +class AutoConfiguredRestClientSsl implements RestClientSsl { + + private final SslBundles sslBundles; + + AutoConfiguredRestClientSsl(SslBundles sslBundles) { + this.sslBundles = sslBundles; + } + + @Override + public Consumer fromBundle(String bundleName) { + return fromBundle(this.sslBundles.getBundle(bundleName)); + } + + @Override + public Consumer fromBundle(SslBundle bundle) { + return (builder) -> { + ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS.withSslBundle(bundle); + ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings); + builder.requestFactory(requestFactory); + }; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizer.java new file mode 100644 index 000000000000..c5372d5d7f84 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizer.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.client; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.util.Assert; +import org.springframework.web.client.RestClient; + +/** + * {@link RestClientCustomizer} to apply {@link HttpMessageConverter + * HttpMessageConverters}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public class HttpMessageConvertersRestClientCustomizer implements RestClientCustomizer { + + private final Iterable> messageConverters; + + public HttpMessageConvertersRestClientCustomizer(HttpMessageConverter... messageConverters) { + Assert.notNull(messageConverters, "MessageConverters must not be null"); + this.messageConverters = Arrays.asList(messageConverters); + } + + HttpMessageConvertersRestClientCustomizer(HttpMessageConverters messageConverters) { + this.messageConverters = messageConverters; + } + + @Override + public void customize(RestClient.Builder restClientBuilder) { + restClientBuilder.messageConverters(this::configureMessageConverters); + } + + private void configureMessageConverters(List> messageConverters) { + if (this.messageConverters != null) { + messageConverters.clear(); + this.messageConverters.forEach(messageConverters::add); + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/NotReactiveWebApplicationCondition.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/NotReactiveWebApplicationCondition.java new file mode 100644 index 000000000000..b45fbcc58f1f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/NotReactiveWebApplicationCondition.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.web.client; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; + +/** + * {@link SpringBootCondition} that applies only when running in a non-reactive web + * application. + * + * @author Phillip Webb + */ +class NotReactiveWebApplicationCondition extends NoneNestedConditions { + + NotReactiveWebApplicationCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + private static final class ReactiveWebApplication { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfiguration.java new file mode 100644 index 000000000000..6198b70bfaee --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfiguration.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.client; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Scope; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.client.RestClient; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for {@link RestClient}. + *

+ * This will produce a {@link RestClient.Builder RestClient.Builder} bean with the + * {@code prototype} scope, meaning each injection point will receive a newly cloned + * instance of the builder. + * + * @author Arjen Poutsma + * @author Moritz Halbritter + * @since 3.2.0 + */ +@AutoConfiguration(after = { HttpMessageConvertersAutoConfiguration.class, SslAutoConfiguration.class }) +@ConditionalOnClass(RestClient.class) +@Conditional(NotReactiveWebApplicationCondition.class) +public class RestClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @Order(Ordered.LOWEST_PRECEDENCE) + HttpMessageConvertersRestClientCustomizer httpMessageConvertersRestClientCustomizer( + ObjectProvider messageConverters) { + return new HttpMessageConvertersRestClientCustomizer(messageConverters.getIfUnique()); + } + + @Bean + @ConditionalOnMissingBean(RestClientSsl.class) + @ConditionalOnBean(SslBundles.class) + AutoConfiguredRestClientSsl restClientSsl(SslBundles sslBundles) { + return new AutoConfiguredRestClientSsl(sslBundles); + } + + @Bean + @ConditionalOnMissingBean + RestClientBuilderConfigurer restClientBuilderConfigurer(ObjectProvider customizerProvider) { + RestClientBuilderConfigurer configurer = new RestClientBuilderConfigurer(); + configurer.setRestClientCustomizers(customizerProvider.orderedStream().toList()); + return configurer; + } + + @Bean + @Scope("prototype") + @ConditionalOnMissingBean + RestClient.Builder restClientBuilder(RestClientBuilderConfigurer restClientBuilderConfigurer) { + RestClient.Builder builder = RestClient.builder() + .requestFactory(ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS)); + return restClientBuilderConfigurer.configure(builder); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurer.java new file mode 100644 index 000000000000..8d6f57bd461f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurer.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.client; + +import java.util.List; + +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.Builder; + +/** + * Configure {@link RestClient.Builder} with sensible defaults. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +public class RestClientBuilderConfigurer { + + private List customizers; + + void setRestClientCustomizers(List customizers) { + this.customizers = customizers; + } + + /** + * Configure the specified {@link RestClient.Builder}. The builder can be further + * tuned and default settings can be overridden. + * @param builder the {@link RestClient.Builder} instance to configure + * @return the configured builder + */ + public RestClient.Builder configure(RestClient.Builder builder) { + applyCustomizers(builder); + return builder; + } + + private void applyCustomizers(Builder builder) { + if (this.customizers != null) { + for (RestClientCustomizer customizer : this.customizers) { + customizer.customize(builder); + } + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientSsl.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientSsl.java new file mode 100644 index 000000000000..fd892efb4326 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestClientSsl.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.client; + +import java.util.function.Consumer; + +import org.springframework.boot.ssl.NoSuchSslBundleException; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * Interface that can be used to {@link RestClient.Builder#apply apply} SSL configuration + * to a {@link org.springframework.web.client.RestClient.Builder RestClient.Builder}. + *

+ * Typically used as follows:

+ * @Bean
+ * public MyBean myBean(RestClient.Builder restClientBuilder, RestClientSsl ssl) {
+ *     RestClient restClientrestClient= restClientBuilder.apply(ssl.fromBundle("mybundle")).build();
+ *     return new MyBean(webClient);
+ * }
+ * 
NOTE: Apply SSL configuration will replace any previously + * {@link RestClient.Builder#requestFactory configured} {@link ClientHttpRequestFactory}. + * If you need to configure {@link ClientHttpRequestFactory} with more than just SSL + * consider using a {@link ClientHttpRequestFactorySettings} with + * {@link ClientHttpRequestFactories}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public interface RestClientSsl { + + /** + * Return a {@link Consumer} that will apply SSL configuration for the named + * {@link SslBundle} to a {@link org.springframework.web.client.RestClient.Builder + * RestClient.Builder}. + * @param bundleName the name of the SSL bundle to apply + * @return a {@link Consumer} to apply the configuration + * @throws NoSuchSslBundleException if a bundle with the provided name does not exist + */ + Consumer fromBundle(String bundleName) throws NoSuchSslBundleException; + + /** + * Return a {@link Consumer} that will apply SSL configuration for the + * {@link SslBundle} to a {@link org.springframework.web.client.RestClient.Builder + * RestClient.Builder}. + * @param bundle the SSL bundle to apply + * @return a {@link Consumer} to apply the configuration + */ + Consumer fromBundle(SslBundle bundle); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfiguration.java index 0986c60bd9b8..f6fc73629ae5 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfiguration.java @@ -21,12 +21,8 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; -import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; import org.springframework.boot.autoconfigure.http.HttpMessageConverters; import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; -import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration.NotReactiveWebApplicationCondition; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateCustomizer; import org.springframework.boot.web.client.RestTemplateRequestCustomizer; @@ -49,7 +45,6 @@ public class RestTemplateAutoConfiguration { @Bean @Lazy - @ConditionalOnMissingBean public RestTemplateBuilderConfigurer restTemplateBuilderConfigurer( ObjectProvider messageConverters, ObjectProvider restTemplateCustomizers, @@ -69,17 +64,4 @@ public RestTemplateBuilder restTemplateBuilder(RestTemplateBuilderConfigurer res return restTemplateBuilderConfigurer.configure(builder); } - static class NotReactiveWebApplicationCondition extends NoneNestedConditions { - - NotReactiveWebApplicationCondition() { - super(ConfigurationPhase.PARSE_CONFIGURATION); - } - - @ConditionalOnWebApplication(type = Type.REACTIVE) - static class ReactiveWebApplication { - - } - - } - } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/EmbeddedWebServerFactoryCustomizerAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/EmbeddedWebServerFactoryCustomizerAutoConfiguration.java index eca465353db6..aef7f8036163 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/EmbeddedWebServerFactoryCustomizerAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/EmbeddedWebServerFactoryCustomizerAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,9 @@ import io.undertow.Undertow; import org.apache.catalina.startup.Tomcat; import org.apache.coyote.UpgradeProtocol; +import org.eclipse.jetty.ee10.webapp.WebAppContext; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.util.Loader; -import org.eclipse.jetty.webapp.WebAppContext; import org.xnio.SslClientAuthMode; import reactor.netty.http.server.HttpServer; @@ -29,18 +29,23 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWarDeployment; +import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.thread.Threading; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.embedded.undertow.UndertowDeploymentInfoCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; +import org.springframework.core.task.VirtualThreadTaskExecutor; /** * {@link EnableAutoConfiguration Auto-configuration} for embedded servlet and reactive * web servers customizations. * * @author Phillip Webb + * @author Moritz Halbritter * @since 2.0.0 */ @AutoConfiguration @@ -62,6 +67,12 @@ public TomcatWebServerFactoryCustomizer tomcatWebServerFactoryCustomizer(Environ return new TomcatWebServerFactoryCustomizer(environment, serverProperties); } + @Bean + @ConditionalOnThreading(Threading.VIRTUAL) + TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() { + return new TomcatVirtualThreadsWebServerFactoryCustomizer(); + } + } /** @@ -77,6 +88,13 @@ public JettyWebServerFactoryCustomizer jettyWebServerFactoryCustomizer(Environme return new JettyWebServerFactoryCustomizer(environment, serverProperties); } + @Bean + @ConditionalOnThreading(Threading.VIRTUAL) + JettyVirtualThreadsWebServerFactoryCustomizer jettyVirtualThreadsWebServerFactoryCustomizer( + ServerProperties serverProperties) { + return new JettyVirtualThreadsWebServerFactoryCustomizer(serverProperties); + } + } /** @@ -92,6 +110,12 @@ public UndertowWebServerFactoryCustomizer undertowWebServerFactoryCustomizer(Env return new UndertowWebServerFactoryCustomizer(environment, serverProperties); } + @Bean + @ConditionalOnThreading(Threading.VIRTUAL) + UndertowDeploymentInfoCustomizer virtualThreadsUndertowDeploymentInfoCustomizer() { + return (deploymentInfo) -> deploymentInfo.setExecutor(new VirtualThreadTaskExecutor("undertow-")); + } + } /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyThreadPool.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyThreadPool.java new file mode 100644 index 000000000000..7c8dadadb908 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyThreadPool.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.embedded; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.SynchronousQueue; + +import org.eclipse.jetty.util.BlockingArrayQueue; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.eclipse.jetty.util.thread.ThreadPool; + +import org.springframework.boot.autoconfigure.web.ServerProperties; + +/** + * Creates a {@link ThreadPool} for Jetty, applying + * {@link org.springframework.boot.autoconfigure.web.ServerProperties.Jetty.Threads + * ServerProperties.Jetty.Threads Jetty thread properties}. + * + * @author Moritz Halbritter + */ +final class JettyThreadPool { + + private JettyThreadPool() { + } + + static QueuedThreadPool create(ServerProperties.Jetty.Threads properties) { + BlockingQueue queue = determineBlockingQueue(properties.getMaxQueueCapacity()); + int maxThreadCount = (properties.getMax() > 0) ? properties.getMax() : 200; + int minThreadCount = (properties.getMin() > 0) ? properties.getMin() : 8; + int threadIdleTimeout = (properties.getIdleTimeout() != null) ? (int) properties.getIdleTimeout().toMillis() + : 60000; + return new QueuedThreadPool(maxThreadCount, minThreadCount, threadIdleTimeout, queue); + } + + private static BlockingQueue determineBlockingQueue(Integer maxQueueCapacity) { + if (maxQueueCapacity == null) { + return null; + } + if (maxQueueCapacity == 0) { + return new SynchronousQueue<>(); + } + return new BlockingArrayQueue<>(maxQueueCapacity); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizer.java new file mode 100644 index 000000000000..05720b3c82dc --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizer.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.embedded; + +import org.eclipse.jetty.util.VirtualThreads; +import org.eclipse.jetty.util.thread.QueuedThreadPool; + +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.web.embedded.jetty.ConfigurableJettyWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.core.Ordered; +import org.springframework.util.Assert; + +/** + * Activates virtual threads on the {@link ConfigurableJettyWebServerFactory}. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +public class JettyVirtualThreadsWebServerFactoryCustomizer + implements WebServerFactoryCustomizer, Ordered { + + private final ServerProperties serverProperties; + + public JettyVirtualThreadsWebServerFactoryCustomizer(ServerProperties serverProperties) { + this.serverProperties = serverProperties; + } + + @Override + public void customize(ConfigurableJettyWebServerFactory factory) { + Assert.state(VirtualThreads.areSupported(), "Virtual threads are not supported"); + QueuedThreadPool threadPool = JettyThreadPool.create(this.serverProperties.getJetty().getThreads()); + threadPool.setVirtualThreadsExecutor(VirtualThreads.getDefaultVirtualThreadsExecutor()); + factory.setThreadPool(threadPool); + } + + @Override + public int getOrder() { + return JettyWebServerFactoryCustomizer.ORDER + 1; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizer.java index 2248f1a72039..c12333dc9cfc 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizer.java @@ -18,9 +18,9 @@ import java.time.Duration; import java.util.Arrays; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.SynchronousQueue; +import java.util.List; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.server.AbstractConnector; import org.eclipse.jetty.server.ConnectionFactory; import org.eclipse.jetty.server.CustomRequestLog; @@ -28,12 +28,6 @@ import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.RequestLogWriter; import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.handler.ContextHandler; -import org.eclipse.jetty.server.handler.HandlerCollection; -import org.eclipse.jetty.server.handler.HandlerWrapper; -import org.eclipse.jetty.util.BlockingArrayQueue; -import org.eclipse.jetty.util.thread.QueuedThreadPool; -import org.eclipse.jetty.util.thread.ThreadPool; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.cloud.CloudPlatform; @@ -60,6 +54,8 @@ public class JettyWebServerFactoryCustomizer implements WebServerFactoryCustomizer, Ordered { + static final int ORDER = 0; + private final Environment environment; private final ServerProperties serverProperties; @@ -71,7 +67,7 @@ public JettyWebServerFactoryCustomizer(Environment environment, ServerProperties @Override public int getOrder() { - return 0; + return ORDER; } @Override @@ -79,8 +75,9 @@ public void customize(ConfigurableJettyWebServerFactory factory) { ServerProperties.Jetty properties = this.serverProperties.getJetty(); factory.setUseForwardHeaders(getOrDeduceUseForwardHeaders()); ServerProperties.Jetty.Threads threadProperties = properties.getThreads(); - factory.setThreadPool(determineThreadPool(properties.getThreads())); + factory.setThreadPool(JettyThreadPool.create(properties.getThreads())); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getMaxConnections).to(factory::setMaxConnections); map.from(threadProperties::getAcceptors).to(factory::setAcceptors); map.from(threadProperties::getSelectors).to(factory::setSelectors); map.from(this.serverProperties::getMaxHttpRequestHeaderSize) @@ -133,42 +130,25 @@ public void customize(Server server) { setHandlerMaxHttpFormPostSize(server.getHandlers()); } - private void setHandlerMaxHttpFormPostSize(Handler... handlers) { + private void setHandlerMaxHttpFormPostSize(List handlers) { for (Handler handler : handlers) { - if (handler instanceof ContextHandler contextHandler) { - contextHandler.setMaxFormContentSize(maxHttpFormPostSize); - } - else if (handler instanceof HandlerWrapper wrapper) { - setHandlerMaxHttpFormPostSize(wrapper.getHandler()); - } - else if (handler instanceof HandlerCollection collection) { - setHandlerMaxHttpFormPostSize(collection.getHandlers()); - } + setHandlerMaxHttpFormPostSize(handler); } } - }); - } - - private ThreadPool determineThreadPool(ServerProperties.Jetty.Threads properties) { - BlockingQueue queue = determineBlockingQueue(properties.getMaxQueueCapacity()); - int maxThreadCount = (properties.getMax() > 0) ? properties.getMax() : 200; - int minThreadCount = (properties.getMin() > 0) ? properties.getMin() : 8; - int threadIdleTimeout = (properties.getIdleTimeout() != null) ? (int) properties.getIdleTimeout().toMillis() - : 60000; - return new QueuedThreadPool(maxThreadCount, minThreadCount, threadIdleTimeout, queue); - } + private void setHandlerMaxHttpFormPostSize(Handler handler) { + if (handler instanceof ServletContextHandler contextHandler) { + contextHandler.setMaxFormContentSize(maxHttpFormPostSize); + } + else if (handler instanceof Handler.Wrapper wrapper) { + setHandlerMaxHttpFormPostSize(wrapper.getHandler()); + } + else if (handler instanceof Handler.Collection collection) { + setHandlerMaxHttpFormPostSize(collection.getHandlers()); + } + } - private BlockingQueue determineBlockingQueue(Integer maxQueueCapacity) { - if (maxQueueCapacity == null) { - return null; - } - if (maxQueueCapacity == 0) { - return new SynchronousQueue<>(); - } - else { - return new BlockingArrayQueue<>(maxQueueCapacity); - } + }); } private void customizeAccessLog(ConfigurableJettyWebServerFactory factory, diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizer.java index 0748e79898d1..6dc657b3d7cb 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizer.java @@ -19,7 +19,6 @@ import java.time.Duration; import io.netty.channel.ChannelOption; -import reactor.netty.http.server.HttpRequestDecoderSpec; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.cloud.CloudPlatform; @@ -66,7 +65,6 @@ public void customize(NettyReactiveWebServerFactory factory) { .to((maxKeepAliveRequests) -> customizeMaxKeepAliveRequests(factory, maxKeepAliveRequests)); if (this.serverProperties.getHttp2() != null && this.serverProperties.getHttp2().isEnabled()) { map.from(this.serverProperties.getMaxHttpRequestHeaderSize()) - .whenNonNull() .to((size) -> customizeHttp2MaxHeaderSize(factory, size.toBytes())); } customizeRequestDecoder(factory, map); @@ -88,37 +86,22 @@ private void customizeConnectionTimeout(NettyReactiveWebServerFactory factory, D private void customizeRequestDecoder(NettyReactiveWebServerFactory factory, PropertyMapper propertyMapper) { factory.addServerCustomizers((httpServer) -> httpServer.httpRequestDecoder((httpRequestDecoderSpec) -> { propertyMapper.from(this.serverProperties.getMaxHttpRequestHeaderSize()) - .whenNonNull() .to((maxHttpRequestHeader) -> httpRequestDecoderSpec .maxHeaderSize((int) maxHttpRequestHeader.toBytes())); ServerProperties.Netty nettyProperties = this.serverProperties.getNetty(); - maxChunkSize(propertyMapper, httpRequestDecoderSpec, nettyProperties); propertyMapper.from(nettyProperties.getMaxInitialLineLength()) - .whenNonNull() .to((maxInitialLineLength) -> httpRequestDecoderSpec .maxInitialLineLength((int) maxInitialLineLength.toBytes())); propertyMapper.from(nettyProperties.getH2cMaxContentLength()) - .whenNonNull() .to((h2cMaxContentLength) -> httpRequestDecoderSpec .h2cMaxContentLength((int) h2cMaxContentLength.toBytes())); propertyMapper.from(nettyProperties.getInitialBufferSize()) - .whenNonNull() .to((initialBufferSize) -> httpRequestDecoderSpec.initialBufferSize((int) initialBufferSize.toBytes())); - propertyMapper.from(nettyProperties.isValidateHeaders()) - .whenNonNull() - .to(httpRequestDecoderSpec::validateHeaders); + propertyMapper.from(nettyProperties.isValidateHeaders()).to(httpRequestDecoderSpec::validateHeaders); return httpRequestDecoderSpec; })); } - @SuppressWarnings({ "deprecation", "removal" }) - private void maxChunkSize(PropertyMapper propertyMapper, HttpRequestDecoderSpec httpRequestDecoderSpec, - ServerProperties.Netty nettyProperties) { - propertyMapper.from(nettyProperties.getMaxChunkSize()) - .whenNonNull() - .to((maxChunkSize) -> httpRequestDecoderSpec.maxChunkSize((int) maxChunkSize.toBytes())); - } - private void customizeIdleTimeout(NettyReactiveWebServerFactory factory, Duration idleTimeout) { factory.addServerCustomizers((httpServer) -> httpServer.idleTimeout(idleTimeout)); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizer.java new file mode 100644 index 000000000000..54ba36c7e67f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizer.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.embedded; + +import org.apache.coyote.ProtocolHandler; +import org.apache.tomcat.util.threads.VirtualThreadExecutor; + +import org.springframework.boot.web.embedded.tomcat.ConfigurableTomcatWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.core.Ordered; + +/** + * Activates {@link VirtualThreadExecutor} on {@link ProtocolHandler Tomcat's protocol + * handler}. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +public class TomcatVirtualThreadsWebServerFactoryCustomizer + implements WebServerFactoryCustomizer, Ordered { + + @Override + public void customize(ConfigurableTomcatWebServerFactory factory) { + factory.addProtocolHandlerCustomizers( + (protocolHandler) -> protocolHandler.setExecutor(new VirtualThreadExecutor("tomcat-handler-"))); + } + + @Override + public int getOrder() { + return TomcatWebServerFactoryCustomizer.ORDER + 1; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java index e47b7cccfe59..2976b2d5edcc 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,10 @@ import java.util.function.ObjIntConsumer; import java.util.stream.Collectors; +import javax.management.ObjectName; + import org.apache.catalina.Lifecycle; +import org.apache.catalina.core.StandardThreadExecutor; import org.apache.catalina.valves.AccessLogValve; import org.apache.catalina.valves.ErrorReportValve; import org.apache.catalina.valves.RemoteIpValve; @@ -36,6 +39,7 @@ import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.autoconfigure.web.ServerProperties.Tomcat.Accesslog; import org.springframework.boot.autoconfigure.web.ServerProperties.Tomcat.Remoteip; +import org.springframework.boot.autoconfigure.web.ServerProperties.Tomcat.Threads; import org.springframework.boot.cloud.CloudPlatform; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.web.embedded.tomcat.ConfigurableTomcatWebServerFactory; @@ -67,6 +71,8 @@ public class TomcatWebServerFactoryCustomizer implements WebServerFactoryCustomizer, Ordered { + static final int ORDER = 0; + private final Environment environment; private final ServerProperties serverProperties; @@ -78,10 +84,11 @@ public TomcatWebServerFactoryCustomizer(Environment environment, ServerPropertie @Override public int getOrder() { - return 0; + return ORDER; } @Override + @SuppressWarnings("removal") public void customize(ConfigurableTomcatWebServerFactory factory) { ServerProperties.Tomcat properties = this.serverProperties.getTomcat(); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); @@ -91,13 +98,7 @@ public void customize(ConfigurableTomcatWebServerFactory factory) { .as(Long::intValue) .to(factory::setBackgroundProcessorDelay); customizeRemoteIpValve(factory); - ServerProperties.Tomcat.Threads threadProperties = properties.getThreads(); - map.from(threadProperties::getMax) - .when(this::isPositive) - .to((maxThreads) -> customizeMaxThreads(factory, threadProperties.getMax())); - map.from(threadProperties::getMinSpare) - .when(this::isPositive) - .to((minSpareThreads) -> customizeMinThreads(factory, minSpareThreads)); + configureExecutor(factory, properties.getThreads()); map.from(this.serverProperties.getMaxHttpRequestHeaderSize()) .asInt(DataSize::toBytes) .when(this::isPositive) @@ -145,6 +146,19 @@ public void customize(ConfigurableTomcatWebServerFactory factory) { customizeErrorReportValve(this.serverProperties.getError(), factory); } + private void configureExecutor(ConfigurableTomcatWebServerFactory factory, Threads threadProperties) { + factory.addProtocolHandlerCustomizers((handler) -> { + StandardThreadExecutor executor = new StandardThreadExecutor(); + executor.setMinSpareThreads(threadProperties.getMinSpare()); + executor.setMaxThreads(threadProperties.getMax()); + executor.setMaxQueueSize(threadProperties.getMaxQueueCapacity()); + if (handler instanceof AbstractProtocol protocol) { + executor.setNamePrefix(ObjectName.unquote(protocol.getName()) + "-exec-"); + } + handler.setExecutor(executor); + }); + } + private boolean isPositive(int value) { return value > 0; } @@ -249,16 +263,6 @@ private boolean getOrDeduceUseForwardHeaders() { return this.serverProperties.getForwardHeadersStrategy() == ServerProperties.ForwardHeadersStrategy.NATIVE; } - @SuppressWarnings("rawtypes") - private void customizeMaxThreads(ConfigurableTomcatWebServerFactory factory, int maxThreads) { - customizeHandler(factory, maxThreads, AbstractProtocol.class, AbstractProtocol::setMaxThreads); - } - - @SuppressWarnings("rawtypes") - private void customizeMinThreads(ConfigurableTomcatWebServerFactory factory, int minSpareThreads) { - customizeHandler(factory, minSpareThreads, AbstractProtocol.class, AbstractProtocol::setMinSpareThreads); - } - @SuppressWarnings("rawtypes") private void customizeMaxHttpRequestHeaderSize(ConfigurableTomcatWebServerFactory factory, int maxHttpRequestHeaderSize) { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizer.java index fc9640ba10b4..c71f67929f51 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizer.java @@ -77,8 +77,7 @@ public int getOrder() { public void customize(ConfigurableUndertowWebServerFactory factory) { PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); ServerOptions options = new ServerOptions(factory); - ServerProperties properties = this.serverProperties; - map.from(properties::getMaxHttpRequestHeaderSize) + map.from(this.serverProperties::getMaxHttpRequestHeaderSize) .asInt(DataSize::toBytes) .when(this::isPositive) .to(options.option(UndertowOptions.MAX_HEADER_SIZE)); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java index 722c5706f31a..45ba63007162 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java @@ -78,6 +78,10 @@ else if (codec instanceof PartEventHttpMessageReader partEventHttpMessageReader) map.from(multipartProperties::getMaxHeadersSize) .asInt(DataSize::toBytes) .to(partEventHttpMessageReader::setMaxHeadersSize); + map.from(multipartProperties::getMaxDiskUsagePerPart) + .as(DataSize::toBytes) + .to(partEventHttpMessageReader::setMaxPartSize); + map.from(multipartProperties::getMaxParts).to(partEventHttpMessageReader::setMaxParts); map.from(multipartProperties::getHeadersCharset).to(partEventHttpMessageReader::setHeadersCharset); } }); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java index b1bd6d7854d9..01b4e005a2eb 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -37,7 +37,7 @@ public class ReactiveMultipartProperties { /** * Maximum amount of memory allowed per part before it's written to disk. Set to -1 to - * store all contents in memory. Ignored when streaming is enabled. + * store all contents in memory. */ private DataSize maxInMemorySize = DataSize.ofKilobytes(256); @@ -49,7 +49,7 @@ public class ReactiveMultipartProperties { /** * Maximum amount of disk space allowed per part. Default is -1 which enforces no - * limits. Ignored when streaming is enabled. + * limits. */ private DataSize maxDiskUsagePerPart = DataSize.ofBytes(-1); @@ -62,7 +62,7 @@ public class ReactiveMultipartProperties { /** * Directory used to store file parts larger than 'maxInMemorySize'. Default is a * directory named 'spring-multipart' created under the system temporary directory. - * Ignored when streaming is enabled. + * Ignored when using the PartEvent streaming support. */ private String fileStorageDirectory; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryConfiguration.java index 2d92ced3d6e8..74880e24d3c3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryConfiguration.java @@ -17,7 +17,7 @@ package org.springframework.boot.autoconfigure.web.reactive; import io.undertow.Undertow; -import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.ee10.servlet.ServletHolder; import reactor.netty.http.server.HttpServer; import org.springframework.beans.factory.ObjectProvider; @@ -39,8 +39,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.http.client.reactive.JettyResourceFactory; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; /** * Configuration classes for reactive web servers @@ -97,17 +96,10 @@ TomcatReactiveWebServerFactory tomcatReactiveWebServerFactory( static class EmbeddedJetty { @Bean - @ConditionalOnMissingBean - JettyResourceFactory jettyServerResourceFactory() { - return new JettyResourceFactory(); - } - - @Bean - JettyReactiveWebServerFactory jettyReactiveWebServerFactory(JettyResourceFactory resourceFactory, + JettyReactiveWebServerFactory jettyReactiveWebServerFactory( ObjectProvider serverCustomizers) { JettyReactiveWebServerFactory serverFactory = new JettyReactiveWebServerFactory(); serverFactory.getServerCustomizers().addAll(serverCustomizers.orderedStream().toList()); - serverFactory.setResourceFactory(resourceFactory); return serverFactory; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java index 54b1effcb9cb..0d7ae4eb49df 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProviders; import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; import org.springframework.boot.autoconfigure.validation.ValidatorAdapter; @@ -54,12 +55,14 @@ import org.springframework.context.annotation.ImportRuntimeHints; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.core.task.AsyncTaskExecutor; import org.springframework.format.FormatterRegistry; import org.springframework.format.support.FormattingConversionService; import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.util.ClassUtils; import org.springframework.validation.Validator; import org.springframework.web.filter.reactive.HiddenHttpMethodFilter; +import org.springframework.web.reactive.config.BlockingExecutionConfigurer; import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration; import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.reactive.config.ResourceHandlerRegistration; @@ -184,6 +187,17 @@ public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) { this.codecCustomizers.orderedStream().forEach((customizer) -> customizer.customize(configurer)); } + @Override + public void configureBlockingExecution(BlockingExecutionConfigurer configurer) { + if (this.beanFactory.containsBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)) { + Object taskExecutor = this.beanFactory + .getBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME); + if (taskExecutor instanceof AsyncTaskExecutor asyncTaskExecutor) { + configurer.setExecutor(asyncTaskExecutor); + } + } + } + @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { if (!this.resourceProperties.isAddMappings()) { @@ -318,7 +332,10 @@ public LocaleContextResolver localeContextResolver() { public WebSessionManager webSessionManager(ObjectProvider webSessionIdResolver) { DefaultWebSessionManager webSessionManager = new DefaultWebSessionManager(); Duration timeout = this.serverProperties.getReactive().getSession().getTimeout(); - webSessionManager.setSessionStore(new MaxIdleTimeInMemoryWebSessionStore(timeout)); + int maxSessions = this.serverProperties.getReactive().getSession().getMaxSessions(); + MaxIdleTimeInMemoryWebSessionStore sessionStore = new MaxIdleTimeInMemoryWebSessionStore(timeout); + sessionStore.setMaxSessions(maxSessions); + webSessionManager.setSessionStore(sessionStore); webSessionIdResolver.ifAvailable(webSessionManager::setSessionIdResolver); return webSessionManager; } @@ -343,6 +360,7 @@ static class ProblemDetailsErrorHandlingConfiguration { @Bean @ConditionalOnMissingBean(ResponseEntityExceptionHandler.class) + @Order(0) ProblemDetailsExceptionHandler problemDetailsExceptionHandler() { return new ProblemDetailsExceptionHandler(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java index 1e8ddc1fd1f0..c601654d0e42 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java @@ -18,10 +18,8 @@ import java.util.Collections; import java.util.Date; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import org.apache.commons.logging.Log; import reactor.core.publisher.Mono; @@ -33,7 +31,6 @@ import org.springframework.boot.web.reactive.error.ErrorAttributes; import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; import org.springframework.context.ApplicationContext; -import org.springframework.core.NestedExceptionUtils; import org.springframework.core.io.Resource; import org.springframework.core.log.LogMessage; import org.springframework.http.HttpLogging; @@ -49,6 +46,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.result.view.ViewResolver; import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.DisconnectedClientHelper; import org.springframework.web.util.HtmlUtils; /** @@ -56,25 +54,12 @@ * * @author Brian Clozel * @author Scott Frederick + * @author Moritz Halbritter * @since 2.0.0 * @see ErrorAttributes */ public abstract class AbstractErrorWebExceptionHandler implements ErrorWebExceptionHandler, InitializingBean { - /** - * Currently duplicated from Spring WebFlux HttpWebHandlerAdapter. - */ - private static final Set DISCONNECTED_CLIENT_EXCEPTIONS; - - static { - Set exceptions = new HashSet<>(); - exceptions.add("AbortedException"); - exceptions.add("ClientAbortException"); - exceptions.add("EOFException"); - exceptions.add("EofException"); - DISCONNECTED_CLIENT_EXCEPTIONS = Collections.unmodifiableSet(exceptions); - } - private static final Log logger = HttpLogging.forLogName(AbstractErrorWebExceptionHandler.class); private final ApplicationContext applicationContext; @@ -184,6 +169,17 @@ protected boolean isBindingErrorsEnabled(ServerRequest request) { return getBooleanParameter(request, "errors"); } + /** + * Check whether the path attribute has been set on the given request. + * @param request the source request + * @return {@code true} if the path attribute has been requested, {@code false} + * otherwise + * @since 3.3.0 + */ + protected boolean isPathEnabled(ServerRequest request) { + return getBooleanParameter(request, "path"); + } + private boolean getBooleanParameter(ServerRequest request, String parameterName) { String parameter = request.queryParam(parameterName).orElse("false"); return !"false".equalsIgnoreCase(parameter); @@ -306,13 +302,7 @@ public Mono handle(ServerWebExchange exchange, Throwable throwable) { } private boolean isDisconnectedClientError(Throwable ex) { - return DISCONNECTED_CLIENT_EXCEPTIONS.contains(ex.getClass().getSimpleName()) - || isDisconnectedClientErrorMessage(NestedExceptionUtils.getMostSpecificCause(ex).getMessage()); - } - - private boolean isDisconnectedClientErrorMessage(String message) { - message = (message != null) ? message.toLowerCase() : ""; - return (message.contains("broken pipe") || message.contains("connection reset by peer")); + return DisconnectedClientHelper.isClientDisconnectedException(ex); } /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandler.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandler.java index 5bc52de7c76f..a45e23bca311 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandler.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -75,6 +75,7 @@ * * @author Brian Clozel * @author Scott Frederick + * @author Moritz Halbritter * @since 2.0.0 */ public class DefaultErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler { @@ -164,6 +165,7 @@ protected ErrorAttributeOptions getErrorAttributeOptions(ServerRequest request, if (isIncludeBindingErrors(request, mediaType)) { options = options.including(Include.BINDING_ERRORS); } + options = isIncludePath(request, mediaType) ? options.including(Include.PATH) : options.excluding(Include.PATH); return options; } @@ -177,7 +179,7 @@ protected boolean isIncludeStackTrace(ServerRequest request, MediaType produces) return switch (this.errorProperties.getIncludeStacktrace()) { case ALWAYS -> true; case ON_PARAM -> isTraceEnabled(request); - default -> false; + case NEVER -> false; }; } @@ -191,7 +193,7 @@ protected boolean isIncludeMessage(ServerRequest request, MediaType produces) { return switch (this.errorProperties.getIncludeMessage()) { case ALWAYS -> true; case ON_PARAM -> isMessageEnabled(request); - default -> false; + case NEVER -> false; }; } @@ -205,7 +207,22 @@ protected boolean isIncludeBindingErrors(ServerRequest request, MediaType produc return switch (this.errorProperties.getIncludeBindingErrors()) { case ALWAYS -> true; case ON_PARAM -> isBindingErrorsEnabled(request); - default -> false; + case NEVER -> false; + }; + } + + /** + * Determine if the path attribute should be included. + * @param request the source request + * @param produces the media type produced (or {@code MediaType.ALL}) + * @return if the path attribute should be included + * @since 3.3.0 + */ + protected boolean isIncludePath(ServerRequest request, MediaType produces) { + return switch (this.errorProperties.getIncludePath()) { + case ALWAYS -> true; + case ON_PARAM -> isPathEnabled(request); + case NEVER -> false; }; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java index 34bf67e605a8..308ac80f5031 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java @@ -46,7 +46,6 @@ @ConditionalOnClass(WebClient.class) @AutoConfigureAfter(SslAutoConfiguration.class) @Import({ ClientHttpConnectorFactoryConfiguration.ReactorNetty.class, - ClientHttpConnectorFactoryConfiguration.JettyClient.class, ClientHttpConnectorFactoryConfiguration.HttpClient5.class, ClientHttpConnectorFactoryConfiguration.JdkClient.class }) public class ClientHttpConnectorAutoConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java index cf0769bc0cda..d3f9aa1b0d8b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java @@ -27,8 +27,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.http.client.reactive.JettyResourceFactory; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; /** * Configuration classes for WebClient client connectors. @@ -55,24 +54,6 @@ ReactorClientHttpConnectorFactory reactorClientHttpConnectorFactory( } - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(org.eclipse.jetty.reactive.client.ReactiveRequest.class) - @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) - static class JettyClient { - - @Bean - @ConditionalOnMissingBean - JettyResourceFactory jettyClientResourceFactory() { - return new JettyResourceFactory(); - } - - @Bean - JettyClientHttpConnectorFactory jettyClientHttpConnectorFactory(JettyResourceFactory jettyResourceFactory) { - return new JettyClientHttpConnectorFactory(jettyResourceFactory); - } - - } - @Configuration(proxyBeanMethods = false) @ConditionalOnClass({ HttpAsyncClients.class, AsyncRequestProducer.class, ReactiveResponseConsumer.class }) @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java deleted file mode 100644 index 5824abf1ca92..000000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2012-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.boot.autoconfigure.web.reactive.function.client; - -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.http.HttpClientTransportOverHTTP; -import org.eclipse.jetty.io.ClientConnector; -import org.eclipse.jetty.util.ssl.SslContextFactory; - -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslOptions; -import org.springframework.http.client.reactive.JettyClientHttpConnector; -import org.springframework.http.client.reactive.JettyResourceFactory; - -/** - * {@link ClientHttpConnectorFactory} for {@link JettyClientHttpConnector}. - * - * @author Phillip Webb - */ -class JettyClientHttpConnectorFactory implements ClientHttpConnectorFactory { - - private final JettyResourceFactory jettyResourceFactory; - - JettyClientHttpConnectorFactory(JettyResourceFactory jettyResourceFactory) { - this.jettyResourceFactory = jettyResourceFactory; - } - - @Override - public JettyClientHttpConnector createClientHttpConnector(SslBundle sslBundle) { - SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); - if (sslBundle != null) { - SslOptions options = sslBundle.getOptions(); - if (options.getCiphers() != null) { - sslContextFactory.setIncludeCipherSuites(options.getCiphers()); - sslContextFactory.setExcludeCipherSuites(); - } - if (options.getEnabledProtocols() != null) { - sslContextFactory.setIncludeProtocols(options.getEnabledProtocols()); - sslContextFactory.setExcludeProtocols(); - } - sslContextFactory.setSslContext(sslBundle.createSslContext()); - } - ClientConnector connector = new ClientConnector(); - connector.setSslContextFactory(sslContextFactory); - HttpClientTransportOverHTTP transport = new HttpClientTransportOverHTTP(connector); - HttpClient httpClient = new HttpClient(transport); - return new JettyClientHttpConnector(httpClient, this.jettyResourceFactory); - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java index b5dcd6b136a1..3f596126ac51 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java @@ -31,8 +31,8 @@ import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslManagerBundle; import org.springframework.boot.ssl.SslOptions; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.http.client.reactive.ReactorResourceFactory; import org.springframework.util.function.ThrowingConsumer; /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration.java index 4e29ba2e0f10..f8fc51bc0133 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration.java @@ -89,12 +89,18 @@ public DispatcherServlet dispatcherServlet(WebMvcProperties webMvcProperties) { DispatcherServlet dispatcherServlet = new DispatcherServlet(); dispatcherServlet.setDispatchOptionsRequest(webMvcProperties.isDispatchOptionsRequest()); dispatcherServlet.setDispatchTraceRequest(webMvcProperties.isDispatchTraceRequest()); - dispatcherServlet.setThrowExceptionIfNoHandlerFound(webMvcProperties.isThrowExceptionIfNoHandlerFound()); + configureThrowExceptionIfNoHandlerFound(webMvcProperties, dispatcherServlet); dispatcherServlet.setPublishEvents(webMvcProperties.isPublishRequestHandledEvents()); dispatcherServlet.setEnableLoggingRequestDetails(webMvcProperties.isLogRequestDetails()); return dispatcherServlet; } + @SuppressWarnings({ "deprecation", "removal" }) + private void configureThrowExceptionIfNoHandlerFound(WebMvcProperties webMvcProperties, + DispatcherServlet dispatcherServlet) { + dispatcherServlet.setThrowExceptionIfNoHandlerFound(webMvcProperties.isThrowExceptionIfNoHandlerFound()); + } + @Bean @ConditionalOnBean(MultipartResolver.class) @ConditionalOnMissingBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfiguration.java index f91fa1ec8ac2..9bac3910ac20 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -46,6 +46,7 @@ * @author Greg Turnquist * @author Josh Long * @author Toshiaki Maki + * @author Yanming Zhou * @since 2.0.0 */ @AutoConfiguration @@ -72,6 +73,7 @@ public MultipartConfigElement multipartConfigElement() { public StandardServletMultipartResolver multipartResolver() { StandardServletMultipartResolver multipartResolver = new StandardServletMultipartResolver(); multipartResolver.setResolveLazily(this.multipartProperties.isResolveLazily()); + multipartResolver.setStrictServletCompliance(this.multipartProperties.isStrictServletCompliance()); return multipartResolver; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.java index 1388ae73bbb1..6e38a93927dd 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -43,6 +43,7 @@ * @author Josh Long * @author Toshiaki Maki * @author Stephane Nicoll + * @author Yanming Zhou * @since 2.0.0 */ @ConfigurationProperties(prefix = "spring.servlet.multipart", ignoreUnknownFields = false) @@ -79,6 +80,12 @@ public class MultipartProperties { */ private boolean resolveLazily = false; + /** + * Whether to resolve the multipart request strictly complying with the Servlet + * specification, only to be used for "multipart/form-data" requests. + */ + private boolean strictServletCompliance = false; + public boolean getEnabled() { return this.enabled; } @@ -127,6 +134,14 @@ public void setResolveLazily(boolean resolveLazily) { this.resolveLazily = resolveLazily; } + public boolean isStrictServletCompliance() { + return this.strictServletCompliance; + } + + public void setStrictServletCompliance(boolean strictServletCompliance) { + this.strictServletCompliance = strictServletCompliance; + } + /** * Create a new {@link MultipartConfigElement} using the properties. * @return a new {@link MultipartConfigElement} configured using there properties diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryConfiguration.java index b33e8eaf4ced..266b298eeb25 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryConfiguration.java @@ -20,9 +20,9 @@ import jakarta.servlet.Servlet; import org.apache.catalina.startup.Tomcat; import org.apache.coyote.UpgradeProtocol; +import org.eclipse.jetty.ee10.webapp.WebAppContext; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.util.Loader; -import org.eclipse.jetty.webapp.WebAppContext; import org.xnio.SslClientAuthMode; import org.springframework.beans.factory.ObjectProvider; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java index ada52f53cd8c..f5fff60f90a6 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java @@ -31,7 +31,6 @@ import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureOrder; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -403,24 +402,6 @@ public EnableWebMvcConfiguration(WebMvcProperties mvcProperties, WebProperties w this.beanFactory = beanFactory; } - @Bean - @Override - public RequestMappingHandlerAdapter requestMappingHandlerAdapter( - @Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager, - @Qualifier("mvcConversionService") FormattingConversionService conversionService, - @Qualifier("mvcValidator") Validator validator) { - RequestMappingHandlerAdapter adapter = super.requestMappingHandlerAdapter(contentNegotiationManager, - conversionService, validator); - setIgnoreDefaultModelOnRedirect(adapter); - return adapter; - } - - @SuppressWarnings({ "deprecation", "removal" }) - private void setIgnoreDefaultModelOnRedirect(RequestMappingHandlerAdapter adapter) { - adapter.setIgnoreDefaultModelOnRedirect( - this.mvcProperties == null || this.mvcProperties.isIgnoreDefaultModelOnRedirect()); - } - @Override protected RequestMappingHandlerAdapter createRequestMappingHandlerAdapter() { if (this.mvcRegistrations != null) { @@ -682,6 +663,7 @@ static class ProblemDetailsErrorHandlingConfiguration { @Bean @ConditionalOnMissingBean(ResponseEntityExceptionHandler.class) + @Order(0) ProblemDetailsExceptionHandler problemDetailsExceptionHandler() { return new ProblemDetailsExceptionHandler(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java index 0f84b1b4b2fd..9945241d3e45 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java @@ -57,12 +57,6 @@ public class WebMvcProperties { */ private boolean dispatchOptionsRequest = true; - /** - * Whether the content of the "default" model should be ignored during redirect - * scenarios. - */ - private boolean ignoreDefaultModelOnRedirect = true; - /** * Whether to publish a ServletRequestHandledEvent at the end of each request. */ @@ -71,8 +65,10 @@ public class WebMvcProperties { /** * Whether a "NoHandlerFoundException" should be thrown if no Handler was found to * process a request. + * @deprecated since 3.2.0 for removal in 3.4.0 */ - private boolean throwExceptionIfNoHandlerFound = false; + @Deprecated(since = "3.2.0", forRemoval = true) + private boolean throwExceptionIfNoHandlerFound = true; /** * Whether logging of (potentially sensitive) request details at DEBUG and TRACE level @@ -120,17 +116,6 @@ public Format getFormat() { return this.format; } - @Deprecated(since = "3.0.0", forRemoval = true) - @DeprecatedConfigurationProperty(reason = "Deprecated for removal in Spring MVC") - public boolean isIgnoreDefaultModelOnRedirect() { - return this.ignoreDefaultModelOnRedirect; - } - - @Deprecated(since = "3.0.0", forRemoval = true) - public void setIgnoreDefaultModelOnRedirect(boolean ignoreDefaultModelOnRedirect) { - this.ignoreDefaultModelOnRedirect = ignoreDefaultModelOnRedirect; - } - public boolean isPublishRequestHandledEvents() { return this.publishRequestHandledEvents; } @@ -139,10 +124,15 @@ public void setPublishRequestHandledEvents(boolean publishRequestHandledEvents) this.publishRequestHandledEvents = publishRequestHandledEvents; } + @Deprecated(since = "3.2.0", forRemoval = true) + @DeprecatedConfigurationProperty( + reason = "DispatcherServlet property is deprecated for removal and should no longer need to be configured", + since = "3.2.0") public boolean isThrowExceptionIfNoHandlerFound() { return this.throwExceptionIfNoHandlerFound; } + @Deprecated(since = "3.2.0", forRemoval = true) public void setThrowExceptionIfNoHandlerFound(boolean throwExceptionIfNoHandlerFound) { this.throwExceptionIfNoHandlerFound = throwExceptionIfNoHandlerFound; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/AbstractErrorController.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/AbstractErrorController.java index 4777a492d666..3fbfbd3b38c1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/AbstractErrorController.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/AbstractErrorController.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 the original author 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,6 +41,7 @@ * @author Dave Syer * @author Phillip Webb * @author Scott Frederick + * @author Moritz Halbritter * @since 1.3.0 * @see ErrorAttributes */ @@ -74,18 +75,43 @@ protected Map getErrorAttributes(HttpServletRequest request, Err return this.errorAttributes.getErrorAttributes(webRequest, options); } + /** + * Returns whether the trace parameter is set. + * @param request the request + * @return whether the trace parameter is set + */ protected boolean getTraceParameter(HttpServletRequest request) { return getBooleanParameter(request, "trace"); } + /** + * Returns whether the message parameter is set. + * @param request the request + * @return whether the message parameter is set + */ protected boolean getMessageParameter(HttpServletRequest request) { return getBooleanParameter(request, "message"); } + /** + * Returns whether the errors parameter is set. + * @param request the request + * @return whether the errors parameter is set + */ protected boolean getErrorsParameter(HttpServletRequest request) { return getBooleanParameter(request, "errors"); } + /** + * Returns whether the path parameter is set. + * @param request the request + * @return whether the path parameter is set + * @since 3.3.0 + */ + protected boolean getPathParameter(HttpServletRequest request) { + return getBooleanParameter(request, "path"); + } + protected boolean getBooleanParameter(HttpServletRequest request, String parameterName) { String parameter = request.getParameter(parameterName); if (parameter == null) { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorController.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorController.java index 610f24517f3f..add6851f2ec0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorController.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorController.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,6 +49,7 @@ * @author Michael Stummvoll * @author Stephane Nicoll * @author Scott Frederick + * @author Moritz Halbritter * @since 1.0.0 * @see ErrorAttributes * @see ErrorProperties @@ -121,6 +122,7 @@ protected ErrorAttributeOptions getErrorAttributeOptions(HttpServletRequest requ if (isIncludeBindingErrors(request, mediaType)) { options = options.including(Include.BINDING_ERRORS); } + options = isIncludePath(request, mediaType) ? options.including(Include.PATH) : options.excluding(Include.PATH); return options; } @@ -134,7 +136,7 @@ protected boolean isIncludeStackTrace(HttpServletRequest request, MediaType prod return switch (getErrorProperties().getIncludeStacktrace()) { case ALWAYS -> true; case ON_PARAM -> getTraceParameter(request); - default -> false; + case NEVER -> false; }; } @@ -148,7 +150,7 @@ protected boolean isIncludeMessage(HttpServletRequest request, MediaType produce return switch (getErrorProperties().getIncludeMessage()) { case ALWAYS -> true; case ON_PARAM -> getMessageParameter(request); - default -> false; + case NEVER -> false; }; } @@ -162,7 +164,22 @@ protected boolean isIncludeBindingErrors(HttpServletRequest request, MediaType p return switch (getErrorProperties().getIncludeBindingErrors()) { case ALWAYS -> true; case ON_PARAM -> getErrorsParameter(request); - default -> false; + case NEVER -> false; + }; + } + + /** + * Determine if the path attribute should be included. + * @param request the source request + * @param produces the media type produced (or {@code MediaType.ALL}) + * @return if the path attribute should be included + * @since 3.3.0 + */ + protected boolean isIncludePath(HttpServletRequest request, MediaType produces) { + return switch (getErrorProperties().getIncludePath()) { + case ALWAYS -> true; + case ON_PARAM -> getPathParameter(request); + case NEVER -> false; }; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/JettyWebSocketReactiveWebServerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/JettyWebSocketReactiveWebServerCustomizer.java index 4c050c4237ed..00ca570b9d6c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/JettyWebSocketReactiveWebServerCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/JettyWebSocketReactiveWebServerCustomizer.java @@ -17,15 +17,13 @@ package org.springframework.boot.autoconfigure.websocket.reactive; import jakarta.servlet.ServletContext; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.websocket.jakarta.server.JakartaWebSocketServerContainer; +import org.eclipse.jetty.ee10.websocket.server.JettyWebSocketServerContainer; +import org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter; import org.eclipse.jetty.server.Handler; -import org.eclipse.jetty.server.handler.HandlerCollection; -import org.eclipse.jetty.server.handler.HandlerWrapper; -import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.websocket.core.server.WebSocketMappings; import org.eclipse.jetty.websocket.core.server.WebSocketServerComponents; -import org.eclipse.jetty.websocket.jakarta.server.internal.JakartaWebSocketServerContainer; -import org.eclipse.jetty.websocket.server.JettyWebSocketServerContainer; -import org.eclipse.jetty.websocket.servlet.WebSocketUpgradeFilter; import org.springframework.boot.web.embedded.jetty.JettyReactiveWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; @@ -47,13 +45,13 @@ public void customize(JettyReactiveWebServerFactory factory) { if (servletContextHandler != null) { ServletContext servletContext = servletContextHandler.getServletContext(); if (JettyWebSocketServerContainer.getContainer(servletContext) == null) { - WebSocketServerComponents.ensureWebSocketComponents(server, servletContext); + WebSocketServerComponents.ensureWebSocketComponents(server, servletContextHandler); JettyWebSocketServerContainer.ensureContainer(servletContext); } if (JakartaWebSocketServerContainer.getContainer(servletContext) == null) { - WebSocketServerComponents.ensureWebSocketComponents(server, servletContext); + WebSocketServerComponents.ensureWebSocketComponents(server, servletContextHandler); WebSocketUpgradeFilter.ensureFilter(servletContext); - WebSocketMappings.ensureMappings(servletContext); + WebSocketMappings.ensureMappings(servletContextHandler); JakartaWebSocketServerContainer.ensureContainer(servletContext); } } @@ -64,10 +62,10 @@ private ServletContextHandler findServletContextHandler(Handler handler) { if (handler instanceof ServletContextHandler servletContextHandler) { return servletContextHandler; } - if (handler instanceof HandlerWrapper handlerWrapper) { + if (handler instanceof Handler.Wrapper handlerWrapper) { return findServletContextHandler(handlerWrapper.getHandler()); } - if (handler instanceof HandlerCollection handlerCollection) { + if (handler instanceof Handler.Collection handlerCollection) { for (Handler contained : handlerCollection.getHandlers()) { ServletContextHandler servletContextHandler = findServletContextHandler(contained); if (servletContextHandler != null) { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfiguration.java index 2f26b1698582..3592ba14b3cd 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfiguration.java @@ -20,7 +20,7 @@ import jakarta.websocket.server.ServerContainer; import org.apache.catalina.startup.Tomcat; import org.apache.tomcat.websocket.server.WsSci; -import org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer; +import org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/JettyWebSocketServletWebServerCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/JettyWebSocketServletWebServerCustomizer.java index 8ee41bc966ca..ccf0ef8f379e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/JettyWebSocketServletWebServerCustomizer.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/JettyWebSocketServletWebServerCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -16,13 +16,13 @@ package org.springframework.boot.autoconfigure.websocket.servlet; -import org.eclipse.jetty.webapp.AbstractConfiguration; -import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.ee10.webapp.AbstractConfiguration; +import org.eclipse.jetty.ee10.webapp.WebAppContext; +import org.eclipse.jetty.ee10.websocket.jakarta.server.JakartaWebSocketServerContainer; +import org.eclipse.jetty.ee10.websocket.server.JettyWebSocketServerContainer; +import org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter; import org.eclipse.jetty.websocket.core.server.WebSocketMappings; import org.eclipse.jetty.websocket.core.server.WebSocketServerComponents; -import org.eclipse.jetty.websocket.jakarta.server.internal.JakartaWebSocketServerContainer; -import org.eclipse.jetty.websocket.server.JettyWebSocketServerContainer; -import org.eclipse.jetty.websocket.servlet.WebSocketUpgradeFilter; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; @@ -41,20 +41,20 @@ public class JettyWebSocketServletWebServerCustomizer @Override public void customize(JettyServletWebServerFactory factory) { - factory.addConfigurations(new AbstractConfiguration() { + factory.addConfigurations(new AbstractConfiguration(new AbstractConfiguration.Builder()) { @Override public void configure(WebAppContext context) throws Exception { if (JettyWebSocketServerContainer.getContainer(context.getServletContext()) == null) { WebSocketServerComponents.ensureWebSocketComponents(context.getServer(), - context.getServletContext()); + context.getContext().getContextHandler()); JettyWebSocketServerContainer.ensureContainer(context.getServletContext()); } if (JakartaWebSocketServerContainer.getContainer(context.getServletContext()) == null) { WebSocketServerComponents.ensureWebSocketComponents(context.getServer(), - context.getServletContext()); + context.getContext().getContextHandler()); WebSocketUpgradeFilter.ensureFilter(context.getServletContext()); - WebSocketMappings.ensureMappings(context.getServletContext()); + WebSocketMappings.ensureMappings(context.getContext().getContextHandler()); JakartaWebSocketServerContainer.ensureContainer(context.getServletContext()); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfiguration.java index 939827774f3f..b65ed91535ed 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketMessagingAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -64,8 +64,7 @@ static class WebSocketMessageConverterConfiguration implements WebSocketMessageB @Override public boolean configureMessageConverters(List messageConverters) { - MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); - converter.setObjectMapper(this.objectMapper); + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(this.objectMapper); DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON); converter.setContentTypeResolver(resolver); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfiguration.java index f7fed2b0d9d6..71dda5859aef 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfiguration.java @@ -24,8 +24,8 @@ import jakarta.websocket.server.ServerContainer; import org.apache.catalina.startup.Tomcat; import org.apache.tomcat.websocket.server.WsSci; -import org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer; -import org.eclipse.jetty.websocket.servlet.WebSocketUpgradeFilter; +import org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer; +import org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 97a85af2b384..c61b327b6ee0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -115,6 +115,13 @@ "level": "error" } }, + { + "name": "server.max-http-header-size", + "deprecation": { + "replacement": "server.max-http-request-header-size", + "level": "error" + } + }, { "name": "server.max-http-post-size", "type": "java.lang.Integer", @@ -125,6 +132,13 @@ "level": "error" } }, + { + "name": "server.netty.max-chunk-size", + "deprecation": { + "reason": "Deprecated for removal in Reactor Netty.", + "level": "error" + } + }, { "name": "server.port", "defaultValue": 8080 @@ -210,7 +224,10 @@ }, { "name": "server.servlet.session.cookie.comment", - "description": "Comment for the cookie." + "description": "Comment for the cookie.", + "deprecation": { + "level": "error" + } }, { "name": "server.servlet.session.cookie.domain", @@ -653,6 +670,26 @@ "level": "error" } }, + { + "name": "spring.couchbase.env.ssl.key-store", + "type": "java.lang.String", + "description": "Path to the JVM key store that holds the certificates.", + "deprecation": { + "replacement": "spring.couchbase.env.ssl.bundle", + "level": "error", + "since": "3.1.0" + } + }, + { + "name": "spring.couchbase.env.ssl.key-store-password", + "type": "java.lang.String", + "description": "Password used to access the key store.", + "deprecation": { + "replacement": "spring.couchbase.env.ssl.bundle", + "level": "error", + "since": "3.1.0" + } + }, { "name": "spring.couchbase.env.timeouts.socket-connect", "type": "java.time.Duration", @@ -1542,6 +1579,10 @@ "name": "spring.jackson.constructor-detector", "defaultValue": "default" }, + { + "name": "spring.jackson.datatype.enum", + "description": "Jackson on/off features for enums." + }, { "name": "spring.jackson.joda-date-time-format", "type": "java.lang.String", @@ -1554,6 +1595,14 @@ "name": "spring.jersey.type", "defaultValue": "servlet" }, + { + "name": "spring.jms.listener.session.acknowledge-mode", + "defaultValue": "auto" + }, + { + "name": "spring.jms.template.session.acknowledge-mode", + "defaultValue": "auto" + }, { "name": "spring.jmx.registration-policy", "defaultValue": "fail-on-existing" @@ -1571,167 +1620,6 @@ "name": "spring.jpa.open-in-view", "defaultValue": true }, - { - "name": "spring.jta.bitronix.properties.allow-multiple-lrc", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.asynchronous2-pc", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.background-recovery-interval", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.background-recovery-interval-seconds", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.current-node-only-recovery", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.debug-zero-resource-transaction", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.default-transaction-timeout", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.disable-jmx", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.exception-analyzer", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.filter-log-status", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.force-batching-enabled", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.forced-write-enabled", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.graceful-shutdown-interval", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.jndi-transaction-synchronization-registry-name", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.jndi-user-transaction-name", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.journal", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.log-part1-filename", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.log-part2-filename", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.max-log-size-in-mb", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.resource-configuration-filename", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.server-id", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.skip-corrupted-logs", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.properties.warn-about-zero-resource-transaction", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, { "name": "spring.jta.enabled", "type": "java.lang.Boolean", @@ -2033,10 +1921,19 @@ "name": "spring.kafka.streams.cache-max-bytes-buffering", "type": "java.lang.Integer", "deprecation": { - "replacement": "spring.kafka.streams.cache-max-size-buffering", + "replacement": "spring.kafka.streams.state-store-cache-max-size", "level": "error" } }, + { + "name": "spring.kafka.streams.cache-max-size-buffering", + "type": "java.lang.Integer", + "deprecation": { + "replacement": "spring.kafka.streams.state-store-cache-max-size", + "level": "error", + "since": "3.1.0" + } + }, { "name": "spring.liquibase.check-change-log-location", "type": "java.lang.Boolean", @@ -2047,6 +1944,21 @@ "level": "error" } }, + { + "name": "spring.liquibase.labels", + "deprecation": { + "replacement": "spring.liquibase.label-filter", + "level": "error" + } + }, + { + "name": "spring.liquibase.show-summary", + "defaultValue": "summary" + }, + { + "name": "spring.liquibase.show-summary-output", + "defaultValue": "log" + }, { "name": "spring.mail.test-connection", "description": "Whether to test that the mail server is available on startup.", @@ -2109,6 +2021,13 @@ "description": "Whether to enable Spring's HiddenHttpMethodFilter.", "defaultValue": false }, + { + "name": "spring.mvc.ignore-default-model-on-redirect", + "deprecation": { + "reason": "Deprecated for removal in Spring MVC.", + "level": "error" + } + }, { "name": "spring.mvc.locale", "type": "java.util.Locale", @@ -2137,6 +2056,18 @@ "name": "spring.neo4j.uri", "defaultValue": "bolt://localhost:7687" }, + { + "name": "spring.pulsar.function.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable function support.", + "defaultValue": true + }, + { + "name": "spring.pulsar.producer.cache.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable caching in the PulsarProducerFactory.", + "defaultValue": true + }, { "name": "spring.quartz.jdbc.comment-prefix", "defaultValue": [ @@ -2200,6 +2131,10 @@ "level": "error" } }, + { + "name": "spring.reactor.context-propagation", + "defaultValue": "limited" + }, { "name": "spring.reactor.stacktrace-mode.enabled", "description": "Whether Reactor should collect stacktrace information at runtime.", @@ -2829,6 +2764,12 @@ "name": "spring.sql.init.mode", "defaultValue": "embedded" }, + { + "name": "spring.threads.virtual.enabled", + "type": "java.lang.Boolean", + "description": "Whether to use virtual threads.", + "defaultValue": false + }, { "name": "spring.thymeleaf.prefix", "defaultValue": "classpath:/templates/" @@ -3111,6 +3052,40 @@ } ] }, + { + "name": "spring.jms.listener.session.acknowledge-mode", + "values": [ + { + "value": "auto", + "description": "Messages sent or received from the session are automatically acknowledged. This is the simplest mode and enables once-only message delivery guarantee." + }, + { + "value": "client", + "description": "Messages are acknowledged once the message listener implementation has called \"jakarta.jms.Message#acknowledge()\". This mode gives the application (rather than the JMS provider) complete control over message acknowledgement." + }, + { + "value": "dups_ok", + "description": "Similar to auto acknowledgment except that said acknowledgment is lazy. As a consequence, the messages might be delivered more than once. This mode enables at-least-once message delivery guarantee." + } + ] + }, + { + "name": "spring.jms.template.session.acknowledge-mode", + "values": [ + { + "value": "auto", + "description": "Messages sent or received from the session are automatically acknowledged. This is the simplest mode and enables once-only message delivery guarantee." + }, + { + "value": "client", + "description": "Messages are acknowledged once the message listener implementation has called \"jakarta.jms.Message#acknowledge()\". This mode gives the application (rather than the JMS provider) complete control over message acknowledgement." + }, + { + "value": "dups_ok", + "description": "Similar to auto acknowledgment except that said acknowledgment is lazy. As a consequence, the messages might be delivered more than once. This mode enables at-least-once message delivery guarantee." + } + ] + }, { "name": "spring.jmx.server", "providers": [ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index f0018406978d..38fe003d37fd 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -68,6 +68,7 @@ org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration +org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration org.springframework.boot.autoconfigure.jdbc.JndiDataSourceAutoConfiguration org.springframework.boot.autoconfigure.jdbc.XADataSourceAutoConfiguration @@ -93,9 +94,12 @@ org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration org.springframework.boot.autoconfigure.netty.NettyAutoConfiguration org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration +org.springframework.boot.autoconfigure.pulsar.PulsarAutoConfiguration +org.springframework.boot.autoconfigure.pulsar.PulsarReactiveAutoConfiguration org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration org.springframework.boot.autoconfigure.r2dbc.R2dbcTransactionManagerAutoConfiguration +org.springframework.boot.autoconfigure.reactor.ReactorAutoConfiguration org.springframework.boot.autoconfigure.rsocket.RSocketMessagingAutoConfiguration org.springframework.boot.autoconfigure.rsocket.RSocketRequesterAutoConfiguration org.springframework.boot.autoconfigure.rsocket.RSocketServerAutoConfiguration @@ -121,8 +125,10 @@ org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration +org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration org.springframework.boot.autoconfigure.transaction.jta.JtaAutoConfiguration org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration +org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration @@ -143,4 +149,4 @@ org.springframework.boot.autoconfigure.websocket.reactive.WebSocketReactiveAutoC org.springframework.boot.autoconfigure.websocket.servlet.WebSocketServletAutoConfiguration org.springframework.boot.autoconfigure.websocket.servlet.WebSocketMessagingAutoConfiguration org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration -org.springframework.boot.autoconfigure.webservices.client.WebServiceTemplateAutoConfiguration \ No newline at end of file +org.springframework.boot.autoconfigure.webservices.client.WebServiceTemplateAutoConfiguration diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfigurationTests.java index 608b631bb60a..072058f02e24 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,10 @@ import com.rabbitmq.client.impl.CredentialsRefreshService; import com.rabbitmq.client.impl.DefaultCredentialsProvider; import org.aopalliance.aop.Advice; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InOrder; @@ -58,6 +61,7 @@ import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -69,6 +73,7 @@ import org.springframework.context.annotation.Primary; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.core.task.VirtualThreadTaskExecutor; import org.springframework.retry.RetryPolicy; import org.springframework.retry.backoff.BackOffPolicy; import org.springframework.retry.backoff.ExponentialBackOffPolicy; @@ -100,12 +105,13 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Scott Frederick */ @ExtendWith(OutputCaptureExtension.class) class RabbitAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(RabbitAutoConfiguration.class)) + .withConfiguration(AutoConfigurations.of(RabbitAutoConfiguration.class, SslAutoConfiguration.class)) .withClassLoader(new FilteredClassLoader("org.springframework.rabbit.stream")); // gh-38750 @Test @@ -149,6 +155,8 @@ void testDefaultConnectionFactoryConfiguration() { com.rabbitmq.client.ConnectionFactory rabbitConnectionFactory = getTargetConnectionFactory(context); assertThat(rabbitConnectionFactory.getUsername()).isEqualTo(properties.getUsername()); assertThat(rabbitConnectionFactory.getPassword()).isEqualTo(properties.getPassword()); + assertThat(rabbitConnectionFactory).extracting("maxInboundMessageBodySize") + .isEqualTo((int) properties.getMaxInboundMessageBodySize().toBytes()); }); } @@ -159,7 +167,8 @@ void testConnectionFactoryWithOverrides() { .withPropertyValues("spring.rabbitmq.host:remote-server", "spring.rabbitmq.port:9000", "spring.rabbitmq.address-shuffle-mode=random", "spring.rabbitmq.username:alice", "spring.rabbitmq.password:secret", "spring.rabbitmq.virtual_host:/vhost", - "spring.rabbitmq.connection-timeout:123", "spring.rabbitmq.channel-rpc-timeout:140") + "spring.rabbitmq.connection-timeout:123", "spring.rabbitmq.channel-rpc-timeout:140", + "spring.rabbitmq.max-inbound-message-body-size:128MB") .run((context) -> { CachingConnectionFactory connectionFactory = context.getBean(CachingConnectionFactory.class); assertThat(connectionFactory.getHost()).isEqualTo("remote-server"); @@ -171,6 +180,7 @@ void testConnectionFactoryWithOverrides() { assertThat(rcf.getConnectionTimeout()).isEqualTo(123); assertThat(rcf.getChannelRpcTimeout()).isEqualTo(140); assertThat((List
) ReflectionTestUtils.getField(connectionFactory, "addresses")).hasSize(1); + assertThat(rcf).hasFieldOrPropertyWithValue("maxInboundMessageBodySize", 1024 * 1024 * 128); }); } @@ -362,6 +372,16 @@ void testRabbitTemplateExchangeAndRoutingKey() { }); } + @Test + void shouldConfigureObservationEnabledOnTemplate() { + this.contextRunner.withUserConfiguration(TestConfiguration.class) + .withPropertyValues("spring.rabbitmq.template.observation-enabled:true") + .run((context) -> { + RabbitTemplate rabbitTemplate = context.getBean(RabbitTemplate.class); + assertThat(rabbitTemplate).extracting("observationEnabled", InstanceOfAssertFactories.BOOLEAN).isTrue(); + }); + } + @Test void testRabbitTemplateDefaultReceiveQueue() { this.contextRunner.withUserConfiguration(TestConfiguration.class) @@ -521,7 +541,9 @@ void testSimpleRabbitListenerContainerFactoryWithCustomSettings() { "spring.rabbitmq.listener.simple.defaultRequeueRejected:false", "spring.rabbitmq.listener.simple.idleEventInterval:5", "spring.rabbitmq.listener.simple.batchSize:20", - "spring.rabbitmq.listener.simple.missingQueuesFatal:false") + "spring.rabbitmq.listener.simple.missingQueuesFatal:false", + "spring.rabbitmq.listener.simple.force-stop:true", + "spring.rabbitmq.listener.simple.observation-enabled:true") .run((context) -> { SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory = context .getBean("rabbitListenerContainerFactory", SimpleRabbitListenerContainerFactory.class); @@ -529,10 +551,33 @@ void testSimpleRabbitListenerContainerFactoryWithCustomSettings() { assertThat(rabbitListenerContainerFactory).hasFieldOrPropertyWithValue("maxConcurrentConsumers", 10); assertThat(rabbitListenerContainerFactory).hasFieldOrPropertyWithValue("batchSize", 20); assertThat(rabbitListenerContainerFactory).hasFieldOrPropertyWithValue("missingQueuesFatal", false); + assertThat(rabbitListenerContainerFactory).hasFieldOrPropertyWithValue("observationEnabled", true); checkCommonProps(context, rabbitListenerContainerFactory); }); } + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void shouldConfigureVirtualThreads() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory = context + .getBean("rabbitListenerContainerFactory", SimpleRabbitListenerContainerFactory.class); + assertThat(rabbitListenerContainerFactory).extracting("taskExecutor") + .isInstanceOf(VirtualThreadTaskExecutor.class); + }); + } + + @Test + void testSimpleRabbitListenerContainerFactoryWithDefaultForceStop() { + this.contextRunner + .withUserConfiguration(MessageConvertersConfiguration.class, MessageRecoverersConfiguration.class) + .run((context) -> { + SimpleRabbitListenerContainerFactory containerFactory = context + .getBean("rabbitListenerContainerFactory", SimpleRabbitListenerContainerFactory.class); + assertThat(containerFactory).hasFieldOrPropertyWithValue("forceStop", false); + }); + } + @Test void testDirectRabbitListenerContainerFactoryWithCustomSettings() { this.contextRunner @@ -549,16 +594,31 @@ void testDirectRabbitListenerContainerFactoryWithCustomSettings() { "spring.rabbitmq.listener.direct.prefetch:40", "spring.rabbitmq.listener.direct.defaultRequeueRejected:false", "spring.rabbitmq.listener.direct.idleEventInterval:5", - "spring.rabbitmq.listener.direct.missingQueuesFatal:true") + "spring.rabbitmq.listener.direct.missingQueuesFatal:true", + "spring.rabbitmq.listener.direct.force-stop:true", + "spring.rabbitmq.listener.direct.observation-enabled:true") .run((context) -> { DirectRabbitListenerContainerFactory rabbitListenerContainerFactory = context .getBean("rabbitListenerContainerFactory", DirectRabbitListenerContainerFactory.class); assertThat(rabbitListenerContainerFactory).hasFieldOrPropertyWithValue("consumersPerQueue", 5); assertThat(rabbitListenerContainerFactory).hasFieldOrPropertyWithValue("missingQueuesFatal", true); + assertThat(rabbitListenerContainerFactory).hasFieldOrPropertyWithValue("observationEnabled", true); checkCommonProps(context, rabbitListenerContainerFactory); }); } + @Test + void testDirectRabbitListenerContainerFactoryWithDefaultForceStop() { + this.contextRunner + .withUserConfiguration(MessageConvertersConfiguration.class, MessageRecoverersConfiguration.class) + .withPropertyValues("spring.rabbitmq.listener.type:direct") + .run((context) -> { + DirectRabbitListenerContainerFactory containerFactory = context + .getBean("rabbitListenerContainerFactory", DirectRabbitListenerContainerFactory.class); + assertThat(containerFactory).hasFieldOrPropertyWithValue("forceStop", false); + }); + } + @Test void testSimpleRabbitListenerContainerFactoryRetryWithCustomizer() { this.contextRunner.withUserConfiguration(RabbitRetryTemplateCustomizerConfiguration.class) @@ -664,6 +724,7 @@ private void checkCommonProps(AssertableApplicationContext context, context.getBean("myMessageConverter")); assertThat(containerFactory).hasFieldOrPropertyWithValue("defaultRequeueRejected", Boolean.FALSE); assertThat(containerFactory).hasFieldOrPropertyWithValue("idleEventInterval", 5L); + assertThat(containerFactory).hasFieldOrPropertyWithValue("forceStop", true); Advice[] adviceChain = containerFactory.getAdviceChain(); assertThat(adviceChain).isNotNull(); assertThat(adviceChain).hasSize(1); @@ -735,6 +796,16 @@ void enableSsl() { }); } + @Test + void enableSslWithInvalidSslBundleFails() { + this.contextRunner.withUserConfiguration(TestConfiguration.class) + .withPropertyValues("spring.rabbitmq.ssl.bundle=invalid") + .run((context) -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure().hasMessageContaining("SSL bundle name 'invalid' cannot be found"); + }); + } + @Test // Make sure that we at least attempt to load the store void enableSslWithNonExistingKeystoreShouldFail() { @@ -785,6 +856,19 @@ void enableSslWithInvalidTrustStoreTypeShouldFail() { }); } + @Test + void enableSslWithBundle() { + this.contextRunner.withUserConfiguration(TestConfiguration.class) + .withPropertyValues("spring.rabbitmq.ssl.bundle=test-bundle", + "spring.ssl.bundle.jks.test-bundle.keystore.location=classpath:test.jks", + "spring.ssl.bundle.jks.test-bundle.keystore.password=secret", + "spring.ssl.bundle.jks.test-bundle.key.password=password") + .run((context) -> { + com.rabbitmq.client.ConnectionFactory rabbitConnectionFactory = getTargetConnectionFactory(context); + assertThat(rabbitConnectionFactory.isSSL()).isTrue(); + }); + } + @Test void enableSslWithKeystoreTypeAndTrustStoreTypeShouldWork() { this.contextRunner.withUserConfiguration(TestConfiguration.class) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitPropertiesTests.java index 0d251a72129f..06708ae217d0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitPropertiesTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitPropertiesTests.java @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.amqp; +import com.rabbitmq.client.ConnectionFactory; import org.junit.jupiter.api.Test; import org.springframework.amqp.rabbit.config.DirectRabbitListenerContainerFactory; @@ -34,6 +35,7 @@ * @author Andy Wilkinson * @author Stephane Nicoll * @author Rafael Carvalho + * @author Scott Frederick */ class RabbitPropertiesTests { @@ -320,6 +322,19 @@ void determineSslReturnFlagPropertyWhenNoAddresses() { assertThat(this.properties.getSsl().determineEnabled()).isTrue(); } + @Test + void determineSslEnabledIsTrueWhenBundleIsSetAndNoAddresses() { + this.properties.getSsl().setBundle("test"); + assertThat(this.properties.getSsl().determineEnabled()).isTrue(); + } + + @Test + void propertiesUseConsistentDefaultValues() { + ConnectionFactory connectionFactory = new ConnectionFactory(); + assertThat(connectionFactory).hasFieldOrPropertyWithValue("maxInboundMessageBodySize", + (int) this.properties.getMaxInboundMessageBodySize().toBytes()); + } + @Test void simpleContainerUseConsistentDefaultValues() { SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); @@ -329,6 +344,7 @@ void simpleContainerUseConsistentDefaultValues() { assertThat(container).hasFieldOrPropertyWithValue("missingQueuesFatal", simple.isMissingQueuesFatal()); assertThat(container).hasFieldOrPropertyWithValue("deBatchingEnabled", simple.isDeBatchingEnabled()); assertThat(container).hasFieldOrPropertyWithValue("consumerBatchEnabled", simple.isConsumerBatchEnabled()); + assertThat(container).hasFieldOrPropertyWithValue("forceStop", simple.isForceStop()); } @Test @@ -339,6 +355,7 @@ void directContainerUseConsistentDefaultValues() { assertThat(direct.isAutoStartup()).isEqualTo(container.isAutoStartup()); assertThat(container).hasFieldOrPropertyWithValue("missingQueuesFatal", direct.isMissingQueuesFatal()); assertThat(container).hasFieldOrPropertyWithValue("deBatchingEnabled", direct.isDeBatchingEnabled()); + assertThat(container).hasFieldOrPropertyWithValue("forceStop", direct.isForceStop()); } @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfigurationTests.java index eec28db57a32..e7d05326cfaa 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/amqp/RabbitStreamConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ * @author Gary Russell * @author Andy Wilkinson * @author Eddú Meléndez + * @author Moritz Halbritter */ class RabbitStreamConfigurationTests { @@ -88,6 +89,16 @@ void whenNativeListenerIsEnabledThenContainerFactoryIsConfiguredToUseNativeListe .isTrue()); } + @Test + void shouldConfigureObservations() { + this.contextRunner + .withPropertyValues("spring.rabbitmq.listener.type:stream", + "spring.rabbitmq.listener.stream.observation-enabled:true") + .run((context) -> assertThat(context.getBean(StreamRabbitListenerContainerFactory.class)) + .extracting("observationEnabled", InstanceOfAssertFactories.BOOLEAN) + .isTrue()); + } + @Test void environmentIsAutoConfiguredByDefault() { this.contextRunner.run((context) -> assertThat(context).hasSingleBean(Environment.class)); @@ -143,6 +154,24 @@ void whenStreamHostIsSetThenEnvironmentUsesCustomHost() { then(builder).should().host("stream.rabbit.example.com"); } + @Test + void whenStreamVirtualHostIsSetThenEnvironmentUsesCustomVirtualHost() { + EnvironmentBuilder builder = mock(EnvironmentBuilder.class); + RabbitProperties properties = new RabbitProperties(); + properties.getStream().setVirtualHost("stream-virtual-host"); + RabbitStreamConfiguration.configure(builder, properties); + then(builder).should().virtualHost("stream-virtual-host"); + } + + @Test + void whenStreamVirtualHostIsNotSetButDefaultVirtualHostIsSetThenEnvironmentUsesDefaultVirtualHost() { + EnvironmentBuilder builder = mock(EnvironmentBuilder.class); + RabbitProperties properties = new RabbitProperties(); + properties.setVirtualHost("default-virtual-host"); + RabbitStreamConfiguration.configure(builder, properties); + then(builder).should().virtualHost("default-virtual-host"); + } + @Test void whenStreamCredentialsAreNotSetThenEnvironmentUsesRabbitCredentials() { EnvironmentBuilder builder = mock(EnvironmentBuilder.class); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfigurationTests.java index 192dd2f53f56..baab590b90d1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchAutoConfigurationTests.java @@ -39,12 +39,14 @@ import org.springframework.batch.core.configuration.JobRegistry; import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration; -import org.springframework.batch.core.configuration.support.JobRegistryBeanPostProcessor; import org.springframework.batch.core.explore.JobExplorer; import org.springframework.batch.core.job.AbstractJob; import org.springframework.batch.core.launch.JobLauncher; import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.repository.ExecutionContextSerializer; import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.repository.dao.DefaultExecutionContextSerializer; +import org.springframework.batch.core.repository.dao.Jackson2ExecutionContextStringSerializer; import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanPostProcessor; @@ -63,6 +65,7 @@ import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; import org.springframework.boot.autoconfigure.orm.jpa.test.City; import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer; import org.springframework.boot.logging.LogLevel; @@ -97,13 +100,15 @@ * @author Stephane Nicoll * @author Vedran Pavic * @author Kazuki Shimizu + * @author Mahmoud Ben Hassine + * @author Lars Uffmann */ @ExtendWith(OutputCaptureExtension.class) class BatchAutoConfigurationTests { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(BatchAutoConfiguration.class, TransactionAutoConfiguration.class, - DataSourceTransactionManagerAutoConfiguration.class)); + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(BatchAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class, + TransactionAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class)); @Test void testDefaultContext() { @@ -461,6 +466,27 @@ void whenTheUserDefinesAJobNameThatDoesNotExistWithRegisteredJobFailsFast() { .withMessage("No job found with name 'three'"); } + @Test + void customExecutionContextSerializerIsUsed() { + this.contextRunner.withUserConfiguration(TestConfiguration.class, EmbeddedDataSourceConfiguration.class) + .withUserConfiguration(CustomExecutionContextConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(Jackson2ExecutionContextStringSerializer.class); + assertThat(context.getBean(SpringBootBatchConfiguration.class).getExecutionContextSerializer()) + .isInstanceOf(Jackson2ExecutionContextStringSerializer.class); + }); + } + + @Test + void defaultExecutionContextSerializerIsUsed() { + this.contextRunner.withUserConfiguration(TestConfiguration.class, EmbeddedDataSourceConfiguration.class) + .run((context) -> { + assertThat(context).doesNotHaveBean(ExecutionContextSerializer.class); + assertThat(context.getBean(SpringBootBatchConfiguration.class).getExecutionContextSerializer()) + .isInstanceOf(DefaultExecutionContextSerializer.class); + }); + } + private JobLauncherApplicationRunner createInstance(String... registeredJobNames) { JobLauncherApplicationRunner runner = new JobLauncherApplicationRunner(mock(JobLauncher.class), mock(JobExplorer.class), mock(JobRepository.class)); @@ -519,13 +545,6 @@ static class NamedJobConfigurationWithRegisteredAndLocalJob { @Autowired private JobRepository jobRepository; - @Bean - static JobRegistryBeanPostProcessor registryProcessor(JobRegistry jobRegistry) { - JobRegistryBeanPostProcessor processor = new JobRegistryBeanPostProcessor(); - processor.setJobRegistry(jobRegistry); - return processor; - } - @Bean Job discreteJob() { AbstractJob job = new AbstractJob("discreteRegisteredJob") { @@ -690,7 +709,17 @@ protected void doExecute(JobExecution execution) { @Bean Job job2() { - return mock(Job.class); + return new Job() { + @Override + public String getName() { + return "discreteLocalJob2"; + } + + @Override + public void execute(JobExecution execution) { + execution.setStatus(BatchStatus.COMPLETED); + } + }; } } @@ -774,4 +803,14 @@ BatchConversionServiceCustomizer anotherBatchConversionServiceCustomizer() { } + @Configuration(proxyBeanMethods = false) + static class CustomExecutionContextConfiguration { + + @Bean + ExecutionContextSerializer executionContextSerializer() { + return new Jackson2ExecutionContextStringSerializer(); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchDataSourceScriptDatabaseInitializerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchDataSourceScriptDatabaseInitializerTests.java index 6b04e877c619..87a87f90c460 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchDataSourceScriptDatabaseInitializerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/batch/BatchDataSourceScriptDatabaseInitializerTests.java @@ -33,6 +33,7 @@ import org.springframework.boot.jdbc.DatabaseDriver; import org.springframework.boot.sql.init.DatabaseInitializationSettings; import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.test.util.ReflectionTestUtils; @@ -76,7 +77,7 @@ void batchSchemaCanBeLocated(DatabaseDriver driver) throws SQLException { DatabaseInitializationSettings settings = BatchDataSourceScriptDatabaseInitializer.getSettings(dataSource, properties.getJdbc()); List schemaLocations = settings.getSchemaLocations(); - assertThat(schemaLocations) + assertThat(schemaLocations).isNotEmpty() .allSatisfy((location) -> assertThat(resourceLoader.getResource(location).exists()).isTrue()); } @@ -85,7 +86,7 @@ void batchHasExpectedBuiltInSchemas() throws IOException { PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); List schemaNames = Stream .of(resolver.getResources("classpath:org/springframework/batch/core/schema-*.sql")) - .map((resource) -> resource.getFilename()) + .map(Resource::getFilename) .filter((resourceName) -> !resourceName.contains("-drop-")) .toList(); assertThat(schemaNames).containsExactlyInAnyOrder("schema-derby.sql", "schema-sqlserver.sql", diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cache/CacheAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cache/CacheAutoConfigurationTests.java index 2c887c890bd2..9a84e99eb3d3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cache/CacheAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/cache/CacheAutoConfigurationTests.java @@ -69,6 +69,7 @@ import org.springframework.data.couchbase.cache.CouchbaseCache; import org.springframework.data.couchbase.cache.CouchbaseCacheConfiguration; import org.springframework.data.couchbase.cache.CouchbaseCacheManager; +import org.springframework.data.redis.cache.FixedDurationTtlFunction; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -273,7 +274,10 @@ void redisCacheExplicit() { RedisCacheManager cacheManager = getCacheManager(context, RedisCacheManager.class); assertThat(cacheManager.getCacheNames()).isEmpty(); RedisCacheConfiguration redisCacheConfiguration = getDefaultRedisCacheConfiguration(cacheManager); - assertThat(redisCacheConfiguration.getTtl()).isEqualTo(java.time.Duration.ofSeconds(15)); + assertThat(redisCacheConfiguration).extracting(RedisCacheConfiguration::getTtlFunction) + .isInstanceOf(FixedDurationTtlFunction.class) + .extracting("duration") + .isEqualTo(java.time.Duration.ofSeconds(15)); assertThat(redisCacheConfiguration.getAllowCacheNullValues()).isFalse(); assertThat(redisCacheConfiguration.getKeyPrefixFor("MyCache")).isEqualTo("prefixMyCache::"); assertThat(redisCacheConfiguration.usePrefix()).isTrue(); @@ -289,7 +293,10 @@ void redisCacheWithRedisCacheConfiguration() { RedisCacheManager cacheManager = getCacheManager(context, RedisCacheManager.class); assertThat(cacheManager.getCacheNames()).isEmpty(); RedisCacheConfiguration redisCacheConfiguration = getDefaultRedisCacheConfiguration(cacheManager); - assertThat(redisCacheConfiguration.getTtl()).isEqualTo(java.time.Duration.ofSeconds(30)); + assertThat(redisCacheConfiguration).extracting(RedisCacheConfiguration::getTtlFunction) + .isInstanceOf(FixedDurationTtlFunction.class) + .extracting("duration") + .isEqualTo(java.time.Duration.ofSeconds(30)); assertThat(redisCacheConfiguration.getKeyPrefixFor("")).isEqualTo("bar::"); }); } @@ -301,7 +308,10 @@ void redisCacheWithRedisCacheManagerBuilderCustomizer() { .run((context) -> { RedisCacheManager cacheManager = getCacheManager(context, RedisCacheManager.class); RedisCacheConfiguration redisCacheConfiguration = getDefaultRedisCacheConfiguration(cacheManager); - assertThat(redisCacheConfiguration.getTtl()).isEqualTo(java.time.Duration.ofSeconds(10)); + assertThat(redisCacheConfiguration).extracting(RedisCacheConfiguration::getTtlFunction) + .isInstanceOf(FixedDurationTtlFunction.class) + .extracting("duration") + .isEqualTo(java.time.Duration.ofSeconds(10)); }); } @@ -321,7 +331,10 @@ void redisCacheExplicitWithCaches() { RedisCacheManager cacheManager = getCacheManager(context, RedisCacheManager.class); assertThat(cacheManager.getCacheNames()).containsOnly("foo", "bar"); RedisCacheConfiguration redisCacheConfiguration = getDefaultRedisCacheConfiguration(cacheManager); - assertThat(redisCacheConfiguration.getTtl()).isEqualTo(java.time.Duration.ofMinutes(0)); + assertThat(redisCacheConfiguration).extracting(RedisCacheConfiguration::getTtlFunction) + .isInstanceOf(FixedDurationTtlFunction.class) + .extracting("duration") + .isEqualTo(java.time.Duration.ofSeconds(0)); assertThat(redisCacheConfiguration.getAllowCacheNullValues()).isTrue(); assertThat(redisCacheConfiguration.getKeyPrefixFor("test")).isEqualTo("test::"); assertThat(redisCacheConfiguration.usePrefix()).isTrue(); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReportTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReportTests.java index 2e130ff8c955..fc1df6ace290 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReportTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionEvaluationReportTests.java @@ -164,7 +164,7 @@ private void prepareMatches(boolean m1, boolean m2, boolean m3) { void springBootConditionPopulatesReport() { ConditionEvaluationReport report = ConditionEvaluationReport .get(new AnnotationConfigApplicationContext(Config.class).getBeanFactory()); - assertThat(report.getConditionAndOutcomesBySource().size()).isNotZero(); + assertThat(report.getConditionAndOutcomesBySource()).isNotEmpty(); } @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestoreTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestoreTests.java new file mode 100644 index 000000000000..7e50b6423e69 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnCheckpointRestoreTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-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.boot.autoconfigure.condition; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testsupport.classpath.ClassPathOverrides; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ConditionalOnCheckpointRestore @ConditionalOnCheckpointRestore}. + * + * @author Andy Wilkinson + */ +class ConditionalOnCheckpointRestoreTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(BasicConfiguration.class); + + @Test + void whenCracIsUnavailableThenConditionDoesNotMatch() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean("someBean")); + } + + @Test + @ClassPathOverrides("org.crac:crac:1.3.0") + void whenCracIsAvailableThenConditionMatches() { + this.contextRunner.run((context) -> assertThat(context).hasBean("someBean")); + } + + @Configuration(proxyBeanMethods = false) + static class BasicConfiguration { + + @Bean + @ConditionalOnCheckpointRestore + String someBean() { + return "someBean"; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java index 06072ca23151..9ca96314e9fa 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java @@ -155,7 +155,7 @@ void testOnMissingBeanConditionWithFactoryBean() { this.contextRunner .withUserConfiguration(FactoryBeanConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) - .run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory")); } @Test @@ -163,7 +163,7 @@ void testOnMissingBeanConditionWithComponentScannedFactoryBean() { this.contextRunner .withUserConfiguration(ComponentScannedFactoryBeanBeanMethodConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) - .run((context) -> assertThat(context.getBean(ScanBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ScanBean.class)).hasToString("fromFactory")); } @Test @@ -171,7 +171,7 @@ void testOnMissingBeanConditionWithComponentScannedFactoryBeanWithBeanMethodArgu this.contextRunner .withUserConfiguration(ComponentScannedFactoryBeanBeanMethodWithArgumentsConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) - .run((context) -> assertThat(context.getBean(ScanBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ScanBean.class)).hasToString("fromFactory")); } @Test @@ -180,7 +180,7 @@ void testOnMissingBeanConditionWithFactoryBeanWithBeanMethodArguments() { .withUserConfiguration(FactoryBeanWithBeanMethodArgumentsConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) .withPropertyValues("theValue=foo") - .run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory")); } @Test @@ -188,7 +188,7 @@ void testOnMissingBeanConditionWithConcreteFactoryBean() { this.contextRunner .withUserConfiguration(ConcreteFactoryBeanConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) - .run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory")); } @Test @@ -205,7 +205,7 @@ void testOnMissingBeanConditionWithRegisteredFactoryBean() { this.contextRunner .withUserConfiguration(RegisteredFactoryBeanConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) - .run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory")); } @Test @@ -213,7 +213,7 @@ void testOnMissingBeanConditionWithNonspecificFactoryBeanWithClassAttribute() { this.contextRunner .withUserConfiguration(NonspecificFactoryBeanClassAttributeConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) - .run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory")); } @Test @@ -221,7 +221,7 @@ void testOnMissingBeanConditionWithNonspecificFactoryBeanWithStringAttribute() { this.contextRunner .withUserConfiguration(NonspecificFactoryBeanStringAttributeConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) - .run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory")); } @Test @@ -229,7 +229,7 @@ void testOnMissingBeanConditionWithFactoryBeanInXml() { this.contextRunner .withUserConfiguration(FactoryBeanXmlConfiguration.class, ConditionalOnFactoryBean.class, PropertyPlaceholderAutoConfiguration.class) - .run((context) -> assertThat(context.getBean(ExampleBean.class).toString()).isEqualTo("fromFactory")); + .run((context) -> assertThat(context.getBean(ExampleBean.class)).hasToString("fromFactory")); } @Test @@ -468,18 +468,6 @@ static class NonspecificFactoryBeanStringAttributeConfiguration { } - static class NonspecificFactoryBeanStringAttributeRegistrar implements ImportBeanDefinitionRegistrar { - - @Override - public void registerBeanDefinitions(AnnotationMetadata meta, BeanDefinitionRegistry registry) { - BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(NonspecificFactoryBean.class); - builder.addConstructorArgValue("foo"); - builder.getBeanDefinition().setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, ExampleBean.class.getName()); - registry.registerBeanDefinition("exampleBeanFactoryBean", builder.getBeanDefinition()); - } - - } - @Configuration(proxyBeanMethods = false) @Import(FactoryBeanRegistrar.class) static class RegisteredFactoryBeanConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreadingTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreadingTests.java new file mode 100644 index 000000000000..a455b22f0f91 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreadingTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-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.boot.autoconfigure.condition; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ConditionalOnThreading}. + * + * @author Moritz Halbritter + */ +class ConditionalOnThreadingTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(BasicConfiguration.class); + + @Test + @EnabledForJreRange(max = JRE.JAVA_20) + void platformThreadsOnJdkBelow21IfVirtualThreadsPropertyIsEnabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true") + .run((context) -> assertThat(context.getBean(ThreadType.class)).isEqualTo(ThreadType.PLATFORM)); + } + + @Test + @EnabledForJreRange(max = JRE.JAVA_20) + void platformThreadsOnJdkBelow21IfVirtualThreadsPropertyIsDisabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=false") + .run((context) -> assertThat(context.getBean(ThreadType.class)).isEqualTo(ThreadType.PLATFORM)); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void virtualThreadsOnJdk21IfVirtualThreadsPropertyIsEnabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true") + .run((context) -> assertThat(context.getBean(ThreadType.class)).isEqualTo(ThreadType.VIRTUAL)); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void platformThreadsOnJdk21IfVirtualThreadsPropertyIsDisabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=false") + .run((context) -> assertThat(context.getBean(ThreadType.class)).isEqualTo(ThreadType.PLATFORM)); + } + + private enum ThreadType { + + PLATFORM, VIRTUAL + + } + + @Configuration(proxyBeanMethods = false) + static class BasicConfiguration { + + @Bean + @ConditionalOnThreading(Threading.VIRTUAL) + ThreadType virtual() { + return ThreadType.VIRTUAL; + } + + @Bean + @ConditionalOnThreading(Threading.PLATFORM) + ThreadType platform() { + return ThreadType.PLATFORM; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfigurationTests.java index 313624d8779f..e30983164136 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/context/MessageSourceAutoConfigurationTests.java @@ -21,7 +21,10 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration.MessageSourceRuntimeHints; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; @@ -40,6 +43,7 @@ * @author Eddú Meléndez * @author Stephane Nicoll * @author Kedar Joshi + * @author Marc Becker */ class MessageSourceAutoConfigurationTests { @@ -180,6 +184,15 @@ void messageSourceWithNonStandardBeanNameIsIgnored() { .run((context) -> assertThat(context.getMessage("foo", null, Locale.US)).isEqualTo("bar")); } + @Test + void shouldRegisterDefaultHints() { + RuntimeHints hints = new RuntimeHints(); + new MessageSourceRuntimeHints().registerHints(hints, getClass().getClassLoader()); + assertThat(RuntimeHintsPredicates.resource().forResource("messages.properties")).accepts(hints); + assertThat(RuntimeHintsPredicates.resource().forResource("messages_de.properties")).accepts(hints); + assertThat(RuntimeHintsPredicates.resource().forResource("messages_zh-CN.properties")).accepts(hints); + } + @Configuration(proxyBeanMethods = false) @PropertySource("classpath:/switch-messages.properties") static class Config { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationTests.java index 3dc81e27767a..6b38551d5f8d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/couchbase/CouchbaseAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,7 +83,7 @@ void shouldUseCustomConnectionDetailsWhenDefined() { .doesNotHaveBean(PropertiesCouchbaseConnectionDetails.class); Cluster cluster = context.getBean(Cluster.class); assertThat(cluster.core()).extracting("connectionString.hosts") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .extractingResultOf("host") .containsExactly("couchbase.example.com"); }); @@ -109,7 +109,7 @@ void connectionDetailsShouldOverrideProperties() { assertThat(context).hasSingleBean(ClusterEnvironment.class).hasSingleBean(Cluster.class); Cluster cluster = context.getBean(Cluster.class); assertThat(cluster.core()).extracting("connectionString.hosts") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .extractingResultOf("host") .containsExactly("couchbase.example.com"); }); @@ -189,15 +189,6 @@ void enableSsl() { }, "spring.couchbase.env.ssl.enabled=true"); } - @Test - void enableSslWithKeyStore() { - testClusterEnvironment((env) -> { - SecurityConfig securityConfig = env.securityConfig(); - assertThat(securityConfig.tlsEnabled()).isTrue(); - assertThat(securityConfig.trustManagerFactory()).isNotNull(); - }, "spring.couchbase.env.ssl.keyStore=classpath:test.jks", "spring.couchbase.env.ssl.keyStorePassword=secret"); - } - @Test void enableSslWithBundle() { testClusterEnvironment((env) -> { @@ -222,16 +213,6 @@ void enableSslWithInvalidBundle() { }); } - @Test - void disableSslEvenWithKeyStore() { - testClusterEnvironment((env) -> { - SecurityConfig securityConfig = env.securityConfig(); - assertThat(securityConfig.tlsEnabled()).isFalse(); - assertThat(securityConfig.trustManagerFactory()).isNull(); - }, "spring.couchbase.env.ssl.enabled=false", "spring.couchbase.env.ssl.keyStore=classpath:test.jks", - "spring.couchbase.env.ssl.keyStorePassword=secret"); - } - @Test void disableSslEvenWithBundle() { testClusterEnvironment((env) -> { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/cassandra/CassandraDataAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/cassandra/CassandraDataAutoConfigurationIntegrationTests.java index aacd79fe8d6d..cd65a45a6421 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/cassandra/CassandraDataAutoConfigurationIntegrationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/cassandra/CassandraDataAutoConfigurationIntegrationTests.java @@ -79,7 +79,7 @@ static class KeyspaceTestConfiguration { CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) { try (CqlSession session = cqlSessionBuilder.build()) { session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test" - + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); + + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); } return cqlSessionBuilder.withKeyspace("boot_test").build(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/jdbc/JdbcRepositoriesAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/jdbc/JdbcRepositoriesAutoConfigurationTests.java index 0ed97c82ab28..064d5d34c5aa 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/jdbc/JdbcRepositoriesAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/jdbc/JdbcRepositoriesAutoConfigurationTests.java @@ -232,7 +232,7 @@ static class JdbcMappingContextConfiguration { @Bean JdbcMappingContext customJdbcMappingContext() { - return mock(JdbcMappingContext.class); + return mock(JdbcMappingContext.class, Answers.RETURNS_MOCKS); } } @@ -242,7 +242,7 @@ static class JdbcConverterConfiguration { @Bean JdbcConverter customJdbcConverter() { - return mock(JdbcConverter.class); + return mock(JdbcConverter.class, Answers.RETURNS_MOCKS); } } @@ -262,7 +262,7 @@ static class JdbcAggregateTemplateConfiguration { @Bean JdbcAggregateTemplate customJdbcAggregateTemplate() { - return mock(JdbcAggregateTemplate.class); + return mock(JdbcAggregateTemplate.class, Answers.RETURNS_MOCKS); } } @@ -272,7 +272,7 @@ static class DataAccessStrategyConfiguration { @Bean DataAccessStrategy customDataAccessStrategy() { - return mock(DataAccessStrategy.class); + return mock(DataAccessStrategy.class, Answers.RETURNS_MOCKS); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoDataAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoDataAutoConfigurationTests.java index 590fb36bdf1f..caff745e56f1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoDataAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoDataAutoConfigurationTests.java @@ -18,10 +18,14 @@ import java.time.LocalDateTime; import java.util.Arrays; +import java.util.function.Supplier; import com.mongodb.ConnectionString; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.gridfs.GridFSBucket; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.BeanCreationException; @@ -78,32 +82,43 @@ void templateExists() { } @Test + @SuppressWarnings("unchecked") void whenGridFsDatabaseIsConfiguredThenGridFsTemplateIsAutoConfiguredAndUsesIt() { this.contextRunner.withPropertyValues("spring.data.mongodb.gridfs.database:grid").run((context) -> { assertThat(context).hasSingleBean(GridFsTemplate.class); GridFsTemplate template = context.getBean(GridFsTemplate.class); - MongoDatabaseFactory factory = (MongoDatabaseFactory) ReflectionTestUtils.getField(template, "dbFactory"); - assertThat(factory.getMongoDatabase().getName()).isEqualTo("grid"); + GridFSBucket bucket = ((Supplier) ReflectionTestUtils.getField(template, "bucketSupplier")) + .get(); + assertThat(bucket).extracting("filesCollection", InstanceOfAssertFactories.type(MongoCollection.class)) + .extracting((collection) -> collection.getNamespace().getDatabaseName()) + .isEqualTo("grid"); }); } @Test + @SuppressWarnings("unchecked") void usesMongoConnectionDetailsIfAvailable() { this.contextRunner.withUserConfiguration(ConnectionDetailsConfiguration.class).run((context) -> { assertThat(context).hasSingleBean(GridFsTemplate.class); GridFsTemplate template = context.getBean(GridFsTemplate.class); - assertThat(template).hasFieldOrPropertyWithValue("bucket", "connection-details-bucket"); - MongoDatabaseFactory factory = (MongoDatabaseFactory) ReflectionTestUtils.getField(template, "dbFactory"); - assertThat(factory.getMongoDatabase().getName()).isEqualTo("grid-database-1"); + GridFSBucket bucket = ((Supplier) ReflectionTestUtils.getField(template, "bucketSupplier")) + .get(); + assertThat(bucket.getBucketName()).isEqualTo("connection-details-bucket"); + assertThat(bucket).extracting("filesCollection", InstanceOfAssertFactories.type(MongoCollection.class)) + .extracting((collection) -> collection.getNamespace().getDatabaseName()) + .isEqualTo("grid-database-1"); }); } @Test + @SuppressWarnings("unchecked") void whenGridFsBucketIsConfiguredThenGridFsTemplateIsAutoConfiguredAndUsesIt() { this.contextRunner.withPropertyValues("spring.data.mongodb.gridfs.bucket:test-bucket").run((context) -> { assertThat(context).hasSingleBean(GridFsTemplate.class); GridFsTemplate template = context.getBean(GridFsTemplate.class); - assertThat(template).hasFieldOrPropertyWithValue("bucket", "test-bucket"); + GridFSBucket bucket = ((Supplier) ReflectionTestUtils.getField(template, "bucketSupplier")) + .get(); + assertThat(bucket.getBucketName()).isEqualTo("test-bucket"); }); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoReactiveDataAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoReactiveDataAutoConfigurationTests.java index df9a5f264ee2..a64fe5b7031d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoReactiveDataAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/mongo/MongoReactiveDataAutoConfigurationTests.java @@ -16,8 +16,13 @@ package org.springframework.boot.autoconfigure.data.mongo; +import java.time.Duration; + import com.mongodb.ConnectionString; +import com.mongodb.reactivestreams.client.MongoCollection; +import com.mongodb.reactivestreams.client.gridfs.GridFSBucket; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; @@ -68,20 +73,26 @@ void whenGridFsDatabaseIsConfiguredThenGridFsTemplateUsesIt() { } @Test + @SuppressWarnings("unchecked") void usesMongoConnectionDetailsIfAvailable() { this.contextRunner.withUserConfiguration(ConnectionDetailsConfiguration.class).run((context) -> { assertThat(grisFsTemplateDatabaseName(context)).isEqualTo("grid-database-1"); ReactiveGridFsTemplate template = context.getBean(ReactiveGridFsTemplate.class); - assertThat(template).hasFieldOrPropertyWithValue("bucket", "connection-details-bucket"); + GridFSBucket bucket = ((Mono) ReflectionTestUtils.getField(template, "bucketSupplier")) + .block(Duration.ofSeconds(30)); + assertThat(bucket.getBucketName()).isEqualTo("connection-details-bucket"); }); } @Test + @SuppressWarnings("unchecked") void whenGridFsBucketIsConfiguredThenGridFsTemplateUsesIt() { this.contextRunner.withPropertyValues("spring.data.mongodb.gridfs.bucket:test-bucket").run((context) -> { assertThat(context).hasSingleBean(ReactiveGridFsTemplate.class); ReactiveGridFsTemplate template = context.getBean(ReactiveGridFsTemplate.class); - assertThat(template).hasFieldOrPropertyWithValue("bucket", "test-bucket"); + GridFSBucket bucket = ((Mono) ReflectionTestUtils.getField(template, "bucketSupplier")) + .block(Duration.ofSeconds(30)); + assertThat(bucket.getBucketName()).isEqualTo("test-bucket"); }); } @@ -150,12 +161,14 @@ void contextFailsWhenDatabaseNotSet() { .run((context) -> assertThat(context).getFailure().hasMessageContaining("Database name must not be empty")); } + @SuppressWarnings("unchecked") private String grisFsTemplateDatabaseName(AssertableApplicationContext context) { assertThat(context).hasSingleBean(ReactiveGridFsTemplate.class); ReactiveGridFsTemplate template = context.getBean(ReactiveGridFsTemplate.class); - ReactiveMongoDatabaseFactory factory = (ReactiveMongoDatabaseFactory) ReflectionTestUtils.getField(template, - "dbFactory"); - return factory.getMongoDatabase().block().getName(); + GridFSBucket bucket = ((Mono) ReflectionTestUtils.getField(template, "bucketSupplier")) + .block(Duration.ofSeconds(30)); + MongoCollection collection = (MongoCollection) ReflectionTestUtils.getField(bucket, "filesCollection"); + return collection.getNamespace().getDatabaseName(); } @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationJedisTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationJedisTests.java index 164e222fd800..517b4d5a3bef 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationJedisTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationJedisTests.java @@ -19,14 +19,18 @@ import java.time.Duration; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testsupport.assertj.SimpleAsyncTaskExecutorAssert; import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.jedis.JedisClientConfiguration.JedisClientConfigurationBuilder; import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; @@ -270,6 +274,25 @@ void testRedisConfigurationWithSslDisabledAndBundle() { }); } + @Test + void shouldUsePlatformThreadsByDefault() { + this.contextRunner.run((context) -> { + JedisConnectionFactory factory = context.getBean(JedisConnectionFactory.class); + assertThat(factory).extracting("executor").isNull(); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void shouldUseVirtualThreadsIfEnabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + JedisConnectionFactory factory = context.getBean(JedisConnectionFactory.class); + assertThat(factory).extracting("executor") + .satisfies((executor) -> SimpleAsyncTaskExecutorAssert.assertThat((SimpleAsyncTaskExecutor) executor) + .usesVirtualThreads()); + }); + } + private String getUserName(JedisConnectionFactory factory) { return ReflectionTestUtils.invokeMethod(factory, "getRedisUsername"); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java index 3e6ab344b29b..79d33e99f302 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfigurationTests.java @@ -31,6 +31,8 @@ import io.lettuce.core.tracing.Tracing; import org.apache.commons.pool2.impl.GenericObjectPoolConfig; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.data.redis.RedisProperties.Pool; @@ -38,8 +40,10 @@ import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; +import org.springframework.boot.testsupport.assertj.SimpleAsyncTaskExecutorAssert; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.data.redis.connection.RedisClusterConfiguration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisNode; @@ -582,6 +586,25 @@ void testRedisConfigurationWithSslDisabledBundle() { }); } + @Test + void shouldUsePlatformThreadsByDefault() { + this.contextRunner.run((context) -> { + LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class); + assertThat(factory).extracting("executor").isNull(); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void shouldUseVirtualThreadsIfEnabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class); + assertThat(factory).extracting("executor") + .satisfies((executor) -> SimpleAsyncTaskExecutorAssert.assertThat((SimpleAsyncTaskExecutor) executor) + .usesVirtualThreads()); + }); + } + private ContextConsumer assertClientOptions( Class expectedType, Consumer options) { return (context) -> { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway90AutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway90AutoConfigurationTests.java index 6acaa1851ecb..97fd9ad3e6d9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway90AutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway90AutoConfigurationTests.java @@ -25,6 +25,7 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import org.springframework.boot.testsupport.classpath.ClassPathOverrides; import static org.assertj.core.api.Assertions.assertThat; @@ -34,6 +35,7 @@ * * @author Andy Wilkinson */ +@ClassPathExclusions("flyway-*.jar") @ClassPathOverrides("org.flywaydb:flyway-core:9.0.4") class Flyway90AutoConfigurationTests { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway920AutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway91AutoConfigurationTests.java similarity index 86% rename from spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway920AutoConfigurationTests.java rename to spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway91AutoConfigurationTests.java index 770a618e4725..ee7cb7ff751a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway920AutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/Flyway91AutoConfigurationTests.java @@ -23,18 +23,19 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.jdbc.EmbeddedDataSourceConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import org.springframework.boot.testsupport.classpath.ClassPathOverrides; import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link FlywayAutoConfiguration} with Flyway 9.20. + * Tests for {@link FlywayAutoConfiguration} with Flyway 9.19. * * @author Andy Wilkinson */ -@ClassPathOverrides({ "org.flywaydb:flyway-core:9.20.0", "org.flywaydb:flyway-sqlserver:9.20.0", - "com.h2database:h2:2.1.210" }) -class Flyway920AutoConfigurationTests { +@ClassPathExclusions("flyway-*.jar") +@ClassPathOverrides("org.flywaydb:flyway-core:9.19.4") +class Flyway91AutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(FlywayAutoConfiguration.class)) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfigurationTests.java index b2148d973e5d..c462bc02716a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayAutoConfigurationTests.java @@ -31,8 +31,12 @@ import org.flywaydb.core.api.callback.Callback; import org.flywaydb.core.api.callback.Context; import org.flywaydb.core.api.callback.Event; +import org.flywaydb.core.api.configuration.FluentConfiguration; import org.flywaydb.core.api.migration.JavaMigration; +import org.flywaydb.core.internal.database.postgresql.PostgreSQLConfigurationExtension; import org.flywaydb.core.internal.license.FlywayTeamsUpgradeRequiredException; +import org.flywaydb.database.oracle.OracleConfigurationExtension; +import org.flywaydb.database.sqlserver.SQLServerConfigurationExtension; import org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform; import org.jooq.DSLContext; import org.jooq.SQLDialect; @@ -48,6 +52,9 @@ import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.FlywayAutoConfigurationRuntimeHints; +import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.OracleFlywayConfigurationCustomizer; +import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.PostgresqlFlywayConfigurationCustomizer; +import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration.SqlServerFlywayConfigurationCustomizer; import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; import org.springframework.boot.autoconfigure.jdbc.EmbeddedDataSourceConfiguration; import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; @@ -83,6 +90,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -601,18 +609,105 @@ void licenseKeyIsCorrectlyMapped(CapturedOutput output) { + "Enterprise features, download Flyway Teams Edition & Flyway Enterprise Edition")); } + @Test + void oracleExtensionIsNotLoadedByDefault() { + FluentConfiguration configuration = mock(FluentConfiguration.class); + new OracleFlywayConfigurationCustomizer(new FlywayProperties()).customize(configuration); + then(configuration).shouldHaveNoInteractions(); + } + @Test void oracleSqlplusIsCorrectlyMapped() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.flyway.oracle.sqlplus=true") + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(OracleConfigurationExtension.class) + .getSqlplus()).isTrue()); + + } + + @Test + @Deprecated(since = "3.2.0", forRemoval = true) + void oracleSqlplusIsCorrectlyMappedWithDeprecatedProperty() { this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) .withPropertyValues("spring.flyway.oracle-sqlplus=true") - .run(validateFlywayTeamsPropertyOnly("oracle.sqlplus")); + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(OracleConfigurationExtension.class) + .getSqlplus()).isTrue()); + } @Test void oracleSqlplusWarnIsCorrectlyMapped() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.flyway.oracle.sqlplus-warn=true") + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(OracleConfigurationExtension.class) + .getSqlplusWarn()).isTrue()); + } + + @Test + @Deprecated(since = "3.2.0", forRemoval = true) + void oracleSqlplusWarnIsCorrectlyMappedWithDeprecatedProperty() { this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) .withPropertyValues("spring.flyway.oracle-sqlplus-warn=true") - .run(validateFlywayTeamsPropertyOnly("oracle.sqlplusWarn")); + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(OracleConfigurationExtension.class) + .getSqlplusWarn()).isTrue()); + } + + @Test + void oracleWallerLocationIsCorrectlyMapped() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.flyway.oracle.wallet-location=/tmp/my.wallet") + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(OracleConfigurationExtension.class) + .getWalletLocation()).isEqualTo("/tmp/my.wallet")); + } + + @Test + @Deprecated(since = "3.2.0", forRemoval = true) + void oracleWallerLocationIsCorrectlyMappedWithDeprecatedProperty() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.flyway.oracle-wallet-location=/tmp/my.wallet") + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(OracleConfigurationExtension.class) + .getWalletLocation()).isEqualTo("/tmp/my.wallet")); + } + + @Test + void oracleKerberosCacheFileIsCorrectlyMapped() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.flyway.oracle.kerberos-cache-file=/tmp/cache") + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(OracleConfigurationExtension.class) + .getKerberosCacheFile()).isEqualTo("/tmp/cache")); + } + + @Test + @Deprecated(since = "3.2.0", forRemoval = true) + void oracleKerberosCacheFileIsCorrectlyMappedWithDeprecatedProperty() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.flyway.oracle-kerberos-cache-file=/tmp/cache") + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(OracleConfigurationExtension.class) + .getKerberosCacheFile()).isEqualTo("/tmp/cache")); } @Test @@ -683,24 +778,62 @@ void kerberosConfigFileIsCorrectlyMapped() { } @Test - void oracleKerberosCacheFileIsCorrectlyMapped() { + void outputQueryResultsIsCorrectlyMapped() { this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.flyway.oracle-kerberos-cache-file=/tmp/cache") - .run(validateFlywayTeamsPropertyOnly("oracle.kerberosCacheFile")); + .withPropertyValues("spring.flyway.output-query-results=false") + .run(validateFlywayTeamsPropertyOnly("outputQueryResults")); } @Test - void outputQueryResultsIsCorrectlyMapped() { + void postgresqlExtensionIsNotLoadedByDefault() { + FluentConfiguration configuration = mock(FluentConfiguration.class); + new PostgresqlFlywayConfigurationCustomizer(new FlywayProperties()).customize(configuration); + then(configuration).shouldHaveNoInteractions(); + } + + @Test + void postgresqlTransactionalLockIsCorrectlyMapped() { this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.flyway.output-query-results=false") - .run(validateFlywayTeamsPropertyOnly("outputQueryResults")); + .withPropertyValues("spring.flyway.postgresql.transactional-lock=false") + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(PostgreSQLConfigurationExtension.class) + .isTransactionalLock()).isFalse()); + } + + @Test + void sqlServerExtensionIsNotLoadedByDefault() { + FluentConfiguration configuration = mock(FluentConfiguration.class); + new SqlServerFlywayConfigurationCustomizer(new FlywayProperties()).customize(configuration); + then(configuration).shouldHaveNoInteractions(); } @Test void sqlServerKerberosLoginFileIsCorrectlyMapped() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.flyway.sqlserver.kerberos-login-file=/tmp/config") + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(SQLServerConfigurationExtension.class) + .getKerberos() + .getLogin() + .getFile()).isEqualTo("/tmp/config")); + } + + @Test + @Deprecated(since = "3.2.0", forRemoval = true) + void sqlServerKerberosLoginFileIsCorrectlyMappedWithDeprecatedProperty() { this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) .withPropertyValues("spring.flyway.sql-server-kerberos-login-file=/tmp/config") - .run(validateFlywayTeamsPropertyOnly("sqlserver.kerberos.login.file")); + .run((context) -> assertThat(context.getBean(Flyway.class) + .getConfiguration() + .getPluginRegister() + .getPlugin(SQLServerConfigurationExtension.class) + .getKerberos() + .getLogin() + .getFile()).isEqualTo("/tmp/config")); } @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayPropertiesTests.java index 98c3454b227f..0562b6c48e9a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayPropertiesTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/flyway/FlywayPropertiesTests.java @@ -109,14 +109,19 @@ void expectedPropertiesAreManaged() { PropertyAccessorFactory.forBeanPropertyAccess(new ClassicConfiguration())); // Properties specific settings ignoreProperties(properties, "url", "driverClassName", "user", "password", "enabled"); - // Property that moved to a separate SQL plugin - ignoreProperties(properties, "sqlServerKerberosLoginFile"); + // Deprecated properties + ignoreProperties(properties, "oracleKerberosCacheFile", "oracleSqlplus", "oracleSqlplusWarn", + "oracleWalletLocation", "sqlServerKerberosLoginFile"); + // Properties that are managed by specific extensions + ignoreProperties(properties, "oracle", "postgresql", "sqlserver"); + // https://github.com/flyway/flyway/issues/3732 + ignoreProperties(configuration, "environment"); // High level object we can't set with properties ignoreProperties(configuration, "callbacks", "classLoader", "dataSource", "javaMigrations", "javaMigrationClassProvider", "pluginRegister", "resourceProvider", "resolvers"); // Properties we don't want to expose ignoreProperties(configuration, "resolversAsClassNames", "callbacksAsClassNames", "driver", "modernConfig", - "currentResolvedEnvironment", "reportFilename"); + "currentResolvedEnvironment", "reportFilename", "reportEnabled", "workingDirectory"); // Handled by the conversion service ignoreProperties(configuration, "baselineVersionAsString", "encodingAsString", "locationsAsStrings", "targetAsString"); @@ -128,7 +133,7 @@ void expectedPropertiesAreManaged() { // Handled as createSchemas ignoreProperties(configuration, "shouldCreateSchemas"); // Getters for the DataSource settings rather than actual properties - ignoreProperties(configuration, "password", "url", "user"); + ignoreProperties(configuration, "databaseType", "password", "url", "user"); // Properties not exposed by Flyway ignoreProperties(configuration, "failOnMissingTarget"); List configurationKeys = new ArrayList<>(configuration.keySet()); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java index 2719747656e1..8b121538210d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.autoconfigure.graphql; import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executor; import graphql.GraphQL; import graphql.execution.instrumentation.ChainedInstrumentation; @@ -25,16 +26,21 @@ import graphql.schema.GraphQLOutputType; import graphql.schema.GraphQLSchema; import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.TypeDefinitionRegistry; import graphql.schema.visibility.DefaultGraphqlFieldVisibility; import graphql.schema.visibility.NoIntrospectionGraphqlFieldVisibility; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration.GraphQlResourcesRuntimeHints; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ByteArrayResource; @@ -47,6 +53,7 @@ import org.springframework.graphql.execution.DataLoaderRegistrar; import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.execution.TypeDefinitionConfigurer; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -54,6 +61,7 @@ /** * Tests for {@link GraphQlAutoConfiguration}. */ +@ExtendWith(OutputCaptureExtension.class) class GraphQlAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() @@ -120,7 +128,9 @@ void shouldConfigureDataFetcherExceptionResolvers() { assertThat(graphQL.getQueryStrategy()).extracting("dataFetcherExceptionHandler") .satisfies((exceptionHandler) -> { assertThat(exceptionHandler.getClass().getName()).endsWith("ExceptionResolversExceptionHandler"); - assertThat(exceptionHandler).extracting("resolvers").asList().hasSize(2); + assertThat(exceptionHandler).extracting("resolvers") + .asInstanceOf(InstanceOfAssertFactories.LIST) + .hasSize(2); }); }); } @@ -156,6 +166,11 @@ void shouldApplyGraphQlSourceBuilderCustomizer() { }); } + @Test + void schemaInspectionShouldBeEnabledByDefault(CapturedOutput output) { + this.contextRunner.run((context) -> assertThat(output).contains("GraphQL schema inspection")); + } + @Test void fieldIntrospectionShouldBeEnabledByDefault() { this.contextRunner.run((context) -> { @@ -203,12 +218,40 @@ void shouldContributeConnectionTypeDefinitionConfigurer() { GraphQlSource graphQlSource = context.getBean(GraphQlSource.class); GraphQLSchema schema = graphQlSource.schema(); GraphQLOutputType bookConnection = schema.getQueryType().getField("books").getType(); - assertThat(bookConnection).isNotNull().isInstanceOf(GraphQLObjectType.class); + assertThat(bookConnection).isInstanceOf(GraphQLObjectType.class); assertThat((GraphQLObjectType) bookConnection) .satisfies((connection) -> assertThat(connection.getFieldDefinition("edges")).isNotNull()); }); } + @Test + void shouldUseCustomTypeDefinitionConfigurerWhenDefined() { + this.contextRunner.withUserConfiguration(CustomTypeDefinitionConfigurer.class).run((context) -> { + TestTypeDefinitionConfigurer configurer = context.getBean(TestTypeDefinitionConfigurer.class); + assertThat(configurer.applied).isTrue(); + }); + } + + @Test + void whenApplicationTaskExecutorIsDefinedThenAnnotatedControllerConfigurerShouldUseIt() { + this.contextRunner.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class)) + .run((context) -> { + AnnotatedControllerConfigurer annotatedControllerConfigurer = context + .getBean(AnnotatedControllerConfigurer.class); + assertThat(annotatedControllerConfigurer).extracting("executor") + .isSameAs(context.getBean("applicationTaskExecutor")); + }); + } + + @Test + void whenCustomExecutorIsDefinedThenAnnotatedControllerConfigurerDoesNotUseIt() { + this.contextRunner.withUserConfiguration(CustomExecutorConfiguration.class).run((context) -> { + AnnotatedControllerConfigurer annotatedControllerConfigurer = context + .getBean(AnnotatedControllerConfigurer.class); + assertThat(annotatedControllerConfigurer).extracting("executor").isNull(); + }); + } + @Configuration(proxyBeanMethods = false) static class CustomGraphQlBuilderConfiguration { @@ -294,4 +337,35 @@ public void customize(GraphQlSource.SchemaResourceBuilder builder) { } + @Configuration(proxyBeanMethods = false) + static class CustomExecutorConfiguration { + + @Bean + Executor customExecutor() { + return mock(Executor.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomTypeDefinitionConfigurer { + + @Bean + TestTypeDefinitionConfigurer testTypeDefinitionConfigurer() { + return new TestTypeDefinitionConfigurer(); + } + + } + + static class TestTypeDefinitionConfigurer implements TypeDefinitionConfigurer { + + boolean applied = false; + + @Override + public void configure(TypeDefinitionRegistry registry) { + this.applied = true; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java index 0998bfe10a92..964bf0ebaff6 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java @@ -49,7 +49,7 @@ class GraphQlQueryByExampleAutoConfigurationTests { .withConfiguration( AutoConfigurations.of(GraphQlAutoConfiguration.class, GraphQlQueryByExampleAutoConfiguration.class)) .withUserConfiguration(MockRepositoryConfig.class) - .withPropertyValues("spring.main.web-application-type=reactive"); + .withPropertyValues("spring.main.web-application-type=servlet"); @Test void shouldRegisterDataFetcherForQueryByExampleRepositories() { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java index 3bbb3df8a03b..ff2624099b2d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.graphql.Book; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -33,6 +34,7 @@ import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester; import org.springframework.graphql.test.tester.GraphQlTester; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; @@ -50,7 +52,7 @@ class GraphQlQuerydslAutoConfigurationTests { .withConfiguration( AutoConfigurations.of(GraphQlAutoConfiguration.class, GraphQlQuerydslAutoConfiguration.class)) .withUserConfiguration(MockRepositoryConfig.class) - .withPropertyValues("spring.main.web-application-type=reactive"); + .withPropertyValues("spring.main.web-application-type=servlet"); @Test void shouldRegisterDataFetcherForQueryDslRepositories() { @@ -65,6 +67,13 @@ void shouldRegisterDataFetcherForQueryDslRepositories() { }); } + @Test + void shouldBackOffWithoutQueryDsl() { + this.contextRunner.withClassLoader(new FilteredClassLoader("com.querydsl.core")) + .run((context) -> assertThat(context).doesNotHaveBean("querydslRegistrar") + .doesNotHaveBean(GraphQlQuerydslAutoConfiguration.class)); + } + @Configuration(proxyBeanMethods = false) static class MockRepositoryConfig { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java index 9901d096cb75..8c807fe98984 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java @@ -22,6 +22,7 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.graphql.Book; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -32,6 +33,7 @@ import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester; import org.springframework.graphql.test.tester.GraphQlTester; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; @@ -64,6 +66,13 @@ void shouldRegisterDataFetcherForQueryDslRepositories() { }); } + @Test + void shouldBackOffWithoutQueryDsl() { + this.contextRunner.withClassLoader(new FilteredClassLoader("com.querydsl.core")) + .run((context) -> assertThat(context).doesNotHaveBean("querydslRegistrar") + .doesNotHaveBean(GraphQlReactiveQuerydslAutoConfiguration.class)); + } + @Configuration(proxyBeanMethods = false) static class MockRepositoryConfig { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java index a1e03f3f73db..cd082e39ac6b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -44,7 +44,11 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.support.RouterFunctionMapping; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.socket.server.support.WebSocketHandlerMapping; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -156,8 +160,12 @@ void shouldSupportCors() { @Test void shouldConfigureWebSocketBeans() { - this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws") - .run((context) -> assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class)); + this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws").run((context) -> { + assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class); + assertThat(context.getBeanProvider(HandlerMapping.class).orderedStream().toList()).containsSubsequence( + context.getBean(WebSocketHandlerMapping.class), context.getBean(RouterFunctionMapping.class), + context.getBean(RequestMappingHandlerMapping.class)); + }); } @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfigurationTests.java index 7d37ee994cc4..fade2e3b2816 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/influx/InfluxDbAutoConfigurationTests.java @@ -42,6 +42,8 @@ * @author Andy Wilkinson * @author Phillip Webb */ +@SuppressWarnings("removal") +@Deprecated(since = "3.2.0", forRemoval = true) class InfluxDbAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() @@ -102,6 +104,7 @@ private int getReadTimeoutProperty(AssertableApplicationContext context) { static class CustomOkHttpClientBuilderProviderConfig { @Bean + @SuppressWarnings("removal") InfluxDbOkHttpClientBuilderProvider influxDbOkHttpClientBuilderProvider() { return () -> new OkHttpClient.Builder().readTimeout(40, TimeUnit.SECONDS); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/info/ProjectInfoAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/info/ProjectInfoAutoConfigurationTests.java index 9d8680999811..7a65682b9306 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/info/ProjectInfoAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/info/ProjectInfoAutoConfigurationTests.java @@ -119,7 +119,7 @@ void buildPropertiesCustomLocation() { @Test void buildPropertiesCustomInvalidLocation() { this.contextRunner.withPropertyValues("spring.info.build.location=classpath:/org/acme/no-build-info.properties") - .run((context) -> assertThat(context.getBeansOfType(BuildProperties.class)).hasSize(0)); + .run((context) -> assertThat(context.getBeansOfType(BuildProperties.class)).isEmpty()); } @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java index cdb19e69fe06..3e8dcf01c8f3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jackson/JacksonAutoConfigurationTests.java @@ -45,6 +45,8 @@ import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.cfg.ConstructorDetector; import com.fasterxml.jackson.databind.cfg.ConstructorDetector.SingleArgConstructor; +import com.fasterxml.jackson.databind.cfg.EnumFeature; +import com.fasterxml.jackson.databind.cfg.JsonNodeFeature; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.util.StdDateFormat; @@ -88,6 +90,7 @@ * @author Johannes Edmeier * @author Grzegorz Poznachowski * @author Ralf Ueberfuhr + * @author Eddú Meléndez */ class JacksonAutoConfigurationTests { @@ -289,6 +292,27 @@ void defaultObjectMapperBuilder() { }); } + @Test + void enableEnumFeature() { + this.contextRunner.withPropertyValues("spring.jackson.datatype.enum.write-enums-to-lowercase=true") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(EnumFeature.WRITE_ENUMS_TO_LOWERCASE.enabledByDefault()).isFalse(); + assertThat(mapper.getSerializationConfig().isEnabled(EnumFeature.WRITE_ENUMS_TO_LOWERCASE)).isTrue(); + }); + } + + @Test + void disableJsonNodeFeature() { + this.contextRunner.withPropertyValues("spring.jackson.datatype.json-node.write-null-properties:false") + .run((context) -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat(JsonNodeFeature.WRITE_NULL_PROPERTIES.enabledByDefault()).isTrue(); + assertThat(mapper.getDeserializationConfig().isEnabled(JsonNodeFeature.WRITE_NULL_PROPERTIES)) + .isFalse(); + }); + } + @Test void moduleBeansAndWellKnownModulesAreRegisteredWithTheObjectMapperBuilder() { this.contextRunner.withUserConfiguration(ModuleConfig.class).run((context) -> { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfigurationTests.java index 75cf07536762..2c1c29897228 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -152,10 +152,10 @@ void oracleUcpIsFallback() { } @Test - void oracleUcpValidatesConnectionByDefault() { + void oracleUcpDoesNotValidateConnectionByDefault() { assertDataSource(PoolDataSourceImpl.class, Arrays.asList("com.zaxxer.hikari", "org.apache.tomcat", "org.apache.commons.dbcp2"), (dataSource) -> { - assertThat(dataSource.getValidateConnectionOnBorrow()).isTrue(); + assertThat(dataSource.getValidateConnectionOnBorrow()).isFalse(); // Use an internal ping when using an Oracle JDBC driver assertThat(dataSource.getSQLForValidateConnection()).isNull(); }); @@ -267,8 +267,8 @@ void dbcp2UsesCustomConnectionDetailsWhenDefined() { DataSource dataSource = context.getBean(DataSource.class); assertThat(dataSource).asInstanceOf(InstanceOfAssertFactories.type(BasicDataSource.class)) .satisfies((dbcp2) -> { - assertThat(dbcp2.getUsername()).isEqualTo("user-1"); - assertThat(dbcp2.getPassword()).isEqualTo("password-1"); + assertThat(dbcp2.getUserName()).isEqualTo("user-1"); + assertThat(dbcp2).extracting("password").isEqualTo("password-1"); assertThat(dbcp2.getDriverClassName()).isEqualTo(DatabaseDriver.POSTGRESQL.getDriverClassName()); assertThat(dbcp2.getUrl()).isEqualTo("jdbc:customdb://customdb.example.com:12345/database-1"); }); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfigurationTests.java index 90044440432a..a289503b0240 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/DataSourceTransactionManagerAutoConfigurationTests.java @@ -24,6 +24,7 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.support.JdbcTransactionManager; @@ -44,6 +45,7 @@ class DataSourceTransactionManagerAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(TransactionAutoConfiguration.class, + TransactionManagerCustomizationAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class)) .withPropertyValues("spring.datasource.url:jdbc:hsqldb:mem:test-" + UUID.randomUUID()); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/Dbcp2JdbcConnectionDetailsBeanPostProcessorTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/Dbcp2JdbcConnectionDetailsBeanPostProcessorTests.java index fd3aef26ad38..666ea530fbca 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/Dbcp2JdbcConnectionDetailsBeanPostProcessorTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/Dbcp2JdbcConnectionDetailsBeanPostProcessorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,8 +42,8 @@ void setUsernamePasswordUrlAndDriverClassName() { new Dbcp2JdbcConnectionDetailsBeanPostProcessor(null).processDataSource(dataSource, new TestJdbcConnectionDetails()); assertThat(dataSource.getUrl()).isEqualTo("jdbc:customdb://customdb.example.com:12345/database-1"); - assertThat(dataSource.getUsername()).isEqualTo("user-1"); - assertThat(dataSource.getPassword()).isEqualTo("password-1"); + assertThat(dataSource.getUserName()).isEqualTo("user-1"); + assertThat(dataSource).extracting("password").isEqualTo("password-1"); assertThat(dataSource.getDriverClassName()).isEqualTo(DatabaseDriver.POSTGRESQL.getDriverClassName()); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/HikariDataSourceConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/HikariDataSourceConfigurationTests.java index 653d59cb8f48..3019be543757 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/HikariDataSourceConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/HikariDataSourceConfigurationTests.java @@ -22,10 +22,16 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.jdbc.HikariCheckpointRestoreLifecycle; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testsupport.classpath.ClassPathOverrides; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DelegatingDataSource; import static org.assertj.core.api.Assertions.assertThat; @@ -37,6 +43,7 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Olga Maciaszek-Sharma */ class HikariDataSourceConfigurationTests { @@ -122,6 +129,33 @@ void usesCustomConnectionDetailsWhenDefined() { }); } + @Test + @ClassPathOverrides("org.crac:crac:1.3.0") + void whenCheckpointRestoreIsAvailableHikariAutoConfigRegistersLifecycleBean() { + this.contextRunner.withPropertyValues("spring.datasource.type=" + HikariDataSource.class.getName()) + .run((context) -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycle.class)); + } + + @Test + @ClassPathOverrides("org.crac:crac:1.3.0") + void whenCheckpointRestoreIsAvailableAndDataSourceHasBeenWrappedHikariAutoConfigRegistersLifecycleBean() { + this.contextRunner.withUserConfiguration(DataSourceWrapperConfiguration.class) + .run((context) -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycle.class)); + } + + @Test + void whenCheckpointRestoreIsNotAvailableHikariAutoConfigDoesNotRegisterLifecycleBean() { + this.contextRunner + .run((context) -> assertThat(context).doesNotHaveBean(HikariCheckpointRestoreLifecycle.class)); + } + + @Test + @ClassPathOverrides("org.crac:crac:1.3.0") + void whenCheckpointRestoreIsAvailableAndDataSourceIsFromUserConfigurationHikariAutoConfigRegistersLifecycleBean() { + this.contextRunner.withUserConfiguration(UserDataSourceConfiguration.class) + .run((context) -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycle.class)); + } + @Configuration(proxyBeanMethods = false) static class ConnectionDetailsConfiguration { @@ -132,4 +166,39 @@ JdbcConnectionDetails sqlConnectionDetails() { } + @Configuration(proxyBeanMethods = false) + static class DataSourceWrapperConfiguration { + + @Bean + static BeanPostProcessor dataSourceWrapper() { + return new BeanPostProcessor() { + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof DataSource dataSource) { + return new DelegatingDataSource(dataSource); + } + return bean; + } + + }; + } + + } + + @Configuration(proxyBeanMethods = false) + static class UserDataSourceConfiguration { + + @Bean + DataSource dataSource() { + return DataSourceBuilder.create() + .driverClassName("org.postgresql.Driver") + .url("jdbc:postgresql://localhost:5432/database") + .username("user") + .password("password") + .build(); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfigurationTests.java new file mode 100644 index 000000000000..6c4035dc79e8 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfigurationTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2012-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.boot.autoconfigure.jdbc; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.simple.JdbcClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JdbcClientAutoConfiguration}. + * + * @author Stephane Nicoll + */ +class JdbcClientAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.datasource.generate-unique-name=true") + .withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class, + JdbcClientAutoConfiguration.class)); + + @Test + void jdbcClientWhenNoAvailableJdbcTemplateIsNotCreated() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(DataSourceAutoConfiguration.class, JdbcClientAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(JdbcClient.class)); + } + + @Test + void jdbcClientWhenExistingJdbcTemplateIsCreated() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(JdbcClient.class); + NamedParameterJdbcTemplate namedParameterJdbcTemplate = context.getBean(NamedParameterJdbcTemplate.class); + assertThat(namedParameterJdbcTemplate.getJdbcOperations()).isEqualTo(context.getBean(JdbcOperations.class)); + }); + } + + @Test + void jdbcClientWithCustomJdbcClientIsNotCreated() { + this.contextRunner.withBean("customJdbcClient", JdbcClient.class, () -> mock(JdbcClient.class)) + .run((context) -> { + assertThat(context).hasSingleBean(JdbcClient.class); + assertThat(context.getBean(JdbcClient.class)).isEqualTo(context.getBean("customJdbcClient")); + }); + } + + @Test + void jdbcClientIsOrderedAfterFlywayMigration() { + this.contextRunner.withUserConfiguration(JdbcClientDataSourceMigrationValidator.class) + .withPropertyValues("spring.flyway.locations:classpath:db/city") + .withConfiguration(AutoConfigurations.of(FlywayAutoConfiguration.class)) + .run((context) -> { + assertThat(context).hasNotFailed().hasSingleBean(JdbcClient.class); + assertThat(context.getBean(JdbcClientDataSourceMigrationValidator.class).count).isZero(); + }); + } + + @Test + void jdbcClientIsOrderedAfterLiquibaseMigration() { + this.contextRunner.withUserConfiguration(JdbcClientDataSourceMigrationValidator.class) + .withPropertyValues("spring.liquibase.change-log:classpath:db/changelog/db.changelog-city.yaml") + .withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class)) + .run((context) -> { + assertThat(context).hasNotFailed().hasSingleBean(JdbcClient.class); + assertThat(context.getBean(JdbcClientDataSourceMigrationValidator.class).count).isZero(); + }); + } + + static class JdbcClientDataSourceMigrationValidator { + + private final Long count; + + JdbcClientDataSourceMigrationValidator(JdbcClient jdbcClient) { + this.count = jdbcClient.sql("SELECT COUNT(*) from CITY").query(Long.class).single(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcTemplateAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcTemplateAutoConfigurationTests.java index e9424bd2bd36..7154f618dd49 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcTemplateAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/JdbcTemplateAutoConfigurationTests.java @@ -184,7 +184,7 @@ void testDependencyToFlywayWithJdbcTemplateMixed() { @Test void testDependencyToLiquibase() { this.contextRunner.withUserConfiguration(DataSourceMigrationValidator.class) - .withPropertyValues("spring.liquibase.changeLog:classpath:db/changelog/db.changelog-city.yaml") + .withPropertyValues("spring.liquibase.change-log:classpath:db/changelog/db.changelog-city.yaml") .withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class)) .run((context) -> { assertThat(context).hasNotFailed(); @@ -195,7 +195,7 @@ void testDependencyToLiquibase() { @Test void testDependencyToLiquibaseWithJdbcTemplateMixed() { this.contextRunner.withUserConfiguration(NamedParameterDataSourceMigrationValidator.class) - .withPropertyValues("spring.liquibase.changeLog:classpath:db/changelog/db.changelog-city.yaml") + .withPropertyValues("spring.liquibase.change-log:classpath:db/changelog/db.changelog-city.yaml") .withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class)) .run((context) -> { assertThat(context).hasNotFailed(); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/OracleUcpDataSourceConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/OracleUcpDataSourceConfigurationTests.java index 234374540943..a31f631d3b92 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/OracleUcpDataSourceConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jdbc/OracleUcpDataSourceConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationCustomObjectMapperProviderTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationCustomObjectMapperProviderTests.java index 7cddc14bfe79..fe823f095482 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationCustomObjectMapperProviderTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationCustomObjectMapperProviderTests.java @@ -60,8 +60,8 @@ class JerseyAutoConfigurationCustomObjectMapperProviderTests { @Test void contextLoads() { ResponseEntity response = this.restTemplate.getForEntity("/rest/message", String.class); - assertThat(HttpStatus.OK).isEqualTo(response.getStatusCode()); - assertThat(response.getBody()).isEqualTo("{\"subject\":\"Jersey\"}"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat("{\"subject\":\"Jersey\"}").isEqualTo(response.getBody()); } @MinimalWebConfiguration diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationObjectMapperProviderTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationObjectMapperProviderTests.java index e1336263463c..59cc5167b36e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationObjectMapperProviderTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jersey/JerseyAutoConfigurationObjectMapperProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -62,7 +62,7 @@ class JerseyAutoConfigurationObjectMapperProviderTests { @Test void responseIsSerializedUsingAutoConfiguredObjectMapper() { ResponseEntity response = this.restTemplate.getForEntity("/rest/message", String.class); - assertThat(HttpStatus.OK).isEqualTo(response.getStatusCode()); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isEqualTo("{\"subject\":\"Jersey\"}"); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/AcknowledgeModeTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/AcknowledgeModeTests.java new file mode 100644 index 000000000000..77957f5a9674 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/AcknowledgeModeTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-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.boot.autoconfigure.jms; + +import jakarta.jms.Session; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link AcknowledgeMode}. + * + * @author Andy Wilkinson + */ +class AcknowledgeModeTests { + + @ParameterizedTest + @EnumSource(Mapping.class) + void stringIsMappedToInt(Mapping mapping) { + assertThat(AcknowledgeMode.of(mapping.actual)).extracting(AcknowledgeMode::getMode).isEqualTo(mapping.expected); + } + + @Test + void mapShouldThrowWhenMapIsCalledWithUnknownNonIntegerString() { + assertThatIllegalArgumentException().isThrownBy(() -> AcknowledgeMode.of("some-string")) + .withMessage( + "'some-string' is neither a known acknowledge mode (auto, client, or dups_ok) nor an integer value"); + } + + private enum Mapping { + + AUTO_LOWER_CASE("auto", Session.AUTO_ACKNOWLEDGE), + + CLIENT_LOWER_CASE("client", Session.CLIENT_ACKNOWLEDGE), + + DUPS_OK_LOWER_CASE("dups_ok", Session.DUPS_OK_ACKNOWLEDGE), + + AUTO_UPPER_CASE("AUTO", Session.AUTO_ACKNOWLEDGE), + + CLIENT_UPPER_CASE("CLIENT", Session.CLIENT_ACKNOWLEDGE), + + DUPS_OK_UPPER_CASE("DUPS_OK", Session.DUPS_OK_ACKNOWLEDGE), + + AUTO_MIXED_CASE("AuTo", Session.AUTO_ACKNOWLEDGE), + + CLIENT_MIXED_CASE("CliEnT", Session.CLIENT_ACKNOWLEDGE), + + DUPS_OK_MIXED_CASE("dUPs_Ok", Session.DUPS_OK_ACKNOWLEDGE), + + DUPS_OK_KEBAB_CASE("DUPS-OK", Session.DUPS_OK_ACKNOWLEDGE), + + DUPS_OK_NO_SEPARATOR_UPPER_CASE("DUPSOK", Session.DUPS_OK_ACKNOWLEDGE), + + DUPS_OK_NO_SEPARATOR_LOWER_CASE("dupsok", Session.DUPS_OK_ACKNOWLEDGE), + + DUPS_OK_NO_SEPARATOR_MIXED_CASE("duPSok", Session.DUPS_OK_ACKNOWLEDGE), + + INTEGER("36", 36); + + private final String actual; + + private final int expected; + + Mapping(String actual, int expected) { + this.actual = actual; + this.expected = expected; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfigurationTests.java index 17692a1b7c62..10f8db9f5400 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,25 @@ import java.io.IOException; +import io.micrometer.observation.ObservationRegistry; import jakarta.jms.ConnectionFactory; import jakarta.jms.ExceptionListener; import jakarta.jms.Session; import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; import org.junit.jupiter.api.Test; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.aot.test.generate.TestGenerationContext; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.jms.artemis.ArtemisAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; +import org.springframework.context.aot.ApplicationContextAotGenerator; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jms.annotation.EnableJms; import org.springframework.jms.config.DefaultJmsListenerContainerFactory; @@ -57,6 +62,8 @@ * @author Stephane Nicoll * @author Aurélien Leboulanger * @author Eddú Meléndez + * @author Vedran Pavic + * @author Lasse Wulff */ class JmsAutoConfigurationTests { @@ -142,9 +149,11 @@ void jmsListenerContainerFactoryWhenMultipleConnectionFactoryBeansShouldBackOff( @Test void testJmsListenerContainerFactoryWithCustomSettings() { this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class) - .withPropertyValues("spring.jms.listener.autoStartup=false", "spring.jms.listener.acknowledgeMode=client", - "spring.jms.listener.concurrency=2", "spring.jms.listener.receiveTimeout=2s", - "spring.jms.listener.maxConcurrency=10") + .withPropertyValues("spring.jms.listener.autoStartup=false", + "spring.jms.listener.session.acknowledgeMode=client", + "spring.jms.listener.session.transacted=false", "spring.jms.listener.minConcurrency=2", + "spring.jms.listener.receiveTimeout=2s", "spring.jms.listener.maxConcurrency=10", + "spring.jms.subscription-durable=true", "spring.jms.client-id=exampleId") .run(this::testJmsListenerContainerFactoryWithCustomSettings); } @@ -152,9 +161,22 @@ private void testJmsListenerContainerFactoryWithCustomSettings(AssertableApplica DefaultMessageListenerContainer container = getContainer(loaded, "jmsListenerContainerFactory"); assertThat(container.isAutoStartup()).isFalse(); assertThat(container.getSessionAcknowledgeMode()).isEqualTo(Session.CLIENT_ACKNOWLEDGE); + assertThat(container.isSessionTransacted()).isFalse(); assertThat(container.getConcurrentConsumers()).isEqualTo(2); assertThat(container.getMaxConcurrentConsumers()).isEqualTo(10); assertThat(container).hasFieldOrPropertyWithValue("receiveTimeout", 2000L); + assertThat(container.isSubscriptionDurable()).isTrue(); + assertThat(container.getClientId()).isEqualTo("exampleId"); + } + + @Test + void testJmsListenerContainerFactoryWithNonStandardAcknowledgeMode() { + this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class) + .withPropertyValues("spring.jms.listener.session.acknowledge-mode=9") + .run((context) -> { + DefaultMessageListenerContainer container = getContainer(context, "jmsListenerContainerFactory"); + assertThat(container.getSessionAcknowledgeMode()).isEqualTo(9); + }); } @Test @@ -179,6 +201,18 @@ void testDefaultContainerFactoryWithJtaTransactionManager() { }); } + @Test + void testDefaultContainerFactoryWithJtaTransactionManagerAndSessionTransactedEnabled() { + this.contextRunner.withUserConfiguration(TestConfiguration7.class, EnableJmsConfiguration.class) + .withPropertyValues("spring.jms.listener.session.transacted=true") + .run((context) -> { + DefaultMessageListenerContainer container = getContainer(context, "jmsListenerContainerFactory"); + assertThat(container.isSessionTransacted()).isTrue(); + assertThat(container).hasFieldOrPropertyWithValue("transactionManager", + context.getBean(JtaTransactionManager.class)); + }); + } + @Test void testDefaultContainerFactoryNonJtaTransactionManager() { this.contextRunner.withUserConfiguration(TestConfiguration8.class, EnableJmsConfiguration.class) @@ -198,6 +232,17 @@ void testDefaultContainerFactoryNoTransactionManager() { }); } + @Test + void testDefaultContainerFactoryNoTransactionManagerAndSessionTransactedDisabled() { + this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class) + .withPropertyValues("spring.jms.listener.session.transacted=false") + .run((context) -> { + DefaultMessageListenerContainer container = getContainer(context, "jmsListenerContainerFactory"); + assertThat(container.isSessionTransacted()).isFalse(); + assertThat(container).hasFieldOrPropertyWithValue("transactionManager", null); + }); + } + @Test void testDefaultContainerFactoryWithMessageConverters() { this.contextRunner.withUserConfiguration(MessageConvertersConfiguration.class, EnableJmsConfiguration.class) @@ -218,6 +263,17 @@ void testDefaultContainerFactoryWithExceptionListener() { }); } + @Test + void testDefaultContainerFactoryWithObservationRegistry() { + ObservationRegistry observationRegistry = mock(ObservationRegistry.class); + this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class) + .withBean(ObservationRegistry.class, () -> observationRegistry) + .run((context) -> { + DefaultMessageListenerContainer container = getContainer(context, "jmsListenerContainerFactory"); + assertThat(container.getObservationRegistry()).isSameAs(observationRegistry); + }); + } + @Test void testCustomContainerFactoryWithConfigurer() { this.contextRunner.withUserConfiguration(TestConfiguration9.class, EnableJmsConfiguration.class) @@ -250,10 +306,22 @@ void testJmsTemplateWithDestinationResolver() { .isSameAs(context.getBean("myDestinationResolver"))); } + @Test + void testJmsTemplateWithObservationRegistry() { + ObservationRegistry observationRegistry = mock(ObservationRegistry.class); + this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class) + .withBean(ObservationRegistry.class, () -> observationRegistry) + .run((context) -> { + JmsTemplate jmsTemplate = context.getBean(JmsTemplate.class); + assertThat(jmsTemplate).extracting("observationRegistry").isSameAs(observationRegistry); + }); + } + @Test void testJmsTemplateFullCustomization() { this.contextRunner.withUserConfiguration(MessageConvertersConfiguration.class) - .withPropertyValues("spring.jms.template.default-destination=testQueue", + .withPropertyValues("spring.jms.template.session.acknowledge-mode=client", + "spring.jms.template.session.transacted=true", "spring.jms.template.default-destination=testQueue", "spring.jms.template.delivery-delay=500", "spring.jms.template.delivery-mode=non-persistent", "spring.jms.template.priority=6", "spring.jms.template.time-to-live=6000", "spring.jms.template.receive-timeout=2000") @@ -261,6 +329,8 @@ void testJmsTemplateFullCustomization() { JmsTemplate jmsTemplate = context.getBean(JmsTemplate.class); assertThat(jmsTemplate.getMessageConverter()).isSameAs(context.getBean("myMessageConverter")); assertThat(jmsTemplate.isPubSubDomain()).isFalse(); + assertThat(jmsTemplate.getSessionAcknowledgeMode()).isEqualTo(Session.CLIENT_ACKNOWLEDGE); + assertThat(jmsTemplate.isSessionTransacted()).isTrue(); assertThat(jmsTemplate.getDefaultDestinationName()).isEqualTo("testQueue"); assertThat(jmsTemplate.getDeliveryDelay()).isEqualTo(500); assertThat(jmsTemplate.getDeliveryMode()).isOne(); @@ -271,6 +341,16 @@ void testJmsTemplateFullCustomization() { }); } + @Test + void testJmsTemplateWithNonStandardAcknowledgeMode() { + this.contextRunner.withUserConfiguration(EnableJmsConfiguration.class) + .withPropertyValues("spring.jms.template.session.acknowledge-mode=7") + .run((context) -> { + JmsTemplate jmsTemplate = context.getBean(JmsTemplate.class); + assertThat(jmsTemplate.getSessionAcknowledgeMode()).isEqualTo(7); + }); + } + @Test void testJmsMessagingTemplateUseConfiguredDefaultDestination() { this.contextRunner.withPropertyValues("spring.jms.template.default-destination=testQueue").run((context) -> { @@ -338,6 +418,17 @@ void enableJmsAutomatically() { .hasBean(JmsListenerConfigUtils.JMS_LISTENER_ENDPOINT_REGISTRY_BEAN_NAME)); } + @Test + void runtimeHintsAreRegisteredForBindingOfAcknowledgeMode() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(ArtemisAutoConfiguration.class, JmsAutoConfiguration.class); + TestGenerationContext generationContext = new TestGenerationContext(); + new ApplicationContextAotGenerator().processAheadOfTime(context, generationContext); + assertThat(RuntimeHintsPredicates.reflection().onMethod(AcknowledgeMode.class, "of").invoke()) + .accepts(generationContext.getRuntimeHints()); + } + } + @Configuration(proxyBeanMethods = false) static class TestConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsPropertiesTests.java index 8e42211c0661..32b93708dcae 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsPropertiesTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/JmsPropertiesTests.java @@ -40,7 +40,7 @@ void formatConcurrencyNull() { @Test void formatConcurrencyOnlyLowerBound() { JmsProperties properties = new JmsProperties(); - properties.getListener().setConcurrency(2); + properties.getListener().setMinConcurrency(2); assertThat(properties.getListener().formatConcurrency()).isEqualTo("2-2"); } @@ -54,7 +54,7 @@ void formatConcurrencyOnlyHigherBound() { @Test void formatConcurrencyBothBounds() { JmsProperties properties = new JmsProperties(); - properties.getListener().setConcurrency(2); + properties.getListener().setMinConcurrency(2); properties.getListener().setMaxConcurrency(10); assertThat(properties.getListener().formatConcurrency()).isEqualTo("2-10"); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfigurationTests.java index 0edd4270c7ee..2815e3e2ff50 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQAutoConfigurationTests.java @@ -40,6 +40,7 @@ * @author Andy Wilkinson * @author Aurélien Leboulanger * @author Stephane Nicoll + * @author Eddú Meléndez */ class ActiveMQAutoConfigurationTests { @@ -233,6 +234,27 @@ void cachingConnectionFactoryNotOnTheClasspathAndCacheEnabledThenSimpleConnectio .doesNotHaveBean("jmsConnectionFactory")); } + @Test + void definesPropertiesBasedConnectionDetailsByDefault() { + this.contextRunner.run((context) -> assertThat(context) + .hasSingleBean(ActiveMQAutoConfiguration.PropertiesActiveMQConnectionDetails.class)); + } + + @Test + void testConnectionFactoryWithOverridesWhenUsingCustomConnectionDetails() { + this.contextRunner.withClassLoader(new FilteredClassLoader(CachingConnectionFactory.class)) + .withPropertyValues("spring.activemq.pool.enabled=false", "spring.jms.cache.enabled=false") + .withUserConfiguration(TestConnectionDetailsConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(ActiveMQConnectionDetails.class) + .doesNotHaveBean(ActiveMQAutoConfiguration.PropertiesActiveMQConnectionDetails.class); + ActiveMQConnectionFactory connectionFactory = context.getBean(ActiveMQConnectionFactory.class); + assertThat(connectionFactory.getBrokerURL()).isEqualTo("tcp://localhost:12345"); + assertThat(connectionFactory.getUserName()).isEqualTo("springuser"); + assertThat(connectionFactory.getPassword()).isEqualTo("spring"); + }); + } + @Configuration(proxyBeanMethods = false) static class EmptyConfiguration { @@ -261,4 +283,31 @@ ActiveMQConnectionFactoryCustomizer activeMQConnectionFactoryCustomizer() { } + @Configuration(proxyBeanMethods = false) + static class TestConnectionDetailsConfiguration { + + @Bean + ActiveMQConnectionDetails activemqConnectionDetails() { + return new ActiveMQConnectionDetails() { + + @Override + public String getBrokerUrl() { + return "tcp://localhost:12345"; + } + + @Override + public String getUser() { + return "springuser"; + } + + @Override + public String getPassword() { + return "spring"; + } + + }; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQPropertiesTests.java index d6b66bc12390..a839b4d03d89 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQPropertiesTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jms/activemq/ActiveMQPropertiesTests.java @@ -29,6 +29,7 @@ * @author Stephane Nicoll * @author Aurélien Leboulanger * @author Venil Noronha + * @author Eddú Meléndez */ class ActiveMQPropertiesTests { @@ -38,13 +39,13 @@ class ActiveMQPropertiesTests { @Test void getBrokerUrlIsLocalhostByDefault() { - assertThat(createFactory(this.properties).determineBrokerUrl()).isEqualTo(DEFAULT_NETWORK_BROKER_URL); + assertThat(this.properties.determineBrokerUrl()).isEqualTo(DEFAULT_NETWORK_BROKER_URL); } @Test void getBrokerUrlUseExplicitBrokerUrl() { this.properties.setBrokerUrl("tcp://activemq.example.com:71717"); - assertThat(createFactory(this.properties).determineBrokerUrl()).isEqualTo("tcp://activemq.example.com:71717"); + assertThat(this.properties.determineBrokerUrl()).isEqualTo("tcp://activemq.example.com:71717"); } @Test @@ -61,12 +62,13 @@ void setTrustedPackages() { ActiveMQConnectionFactory factory = createFactory(this.properties) .createConnectionFactory(ActiveMQConnectionFactory.class); assertThat(factory.isTrustAllPackages()).isFalse(); - assertThat(factory.getTrustedPackages().size()).isEqualTo(1); + assertThat(factory.getTrustedPackages()).hasSize(1); assertThat(factory.getTrustedPackages().get(0)).isEqualTo("trusted.package"); } private ActiveMQConnectionFactoryFactory createFactory(ActiveMQProperties properties) { - return new ActiveMQConnectionFactoryFactory(properties, Collections.emptyList()); + return new ActiveMQConnectionFactoryFactory(properties, Collections.emptyList(), + new ActiveMQAutoConfiguration.PropertiesActiveMQConnectionDetails(properties)); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/DefaultExceptionTranslatorExecuteListenerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/DefaultExceptionTranslatorExecuteListenerTests.java new file mode 100644 index 000000000000..ae1278889d1b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/DefaultExceptionTranslatorExecuteListenerTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.jooq; + +import java.sql.SQLException; +import java.util.function.Function; + +import org.jooq.Configuration; +import org.jooq.ExecuteContext; +import org.jooq.SQLDialect; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.jdbc.BadSqlGrammarException; +import org.springframework.jdbc.support.SQLExceptionTranslator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.assertArg; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link DefaultExceptionTranslatorExecuteListener}. + * + * @author Andy Wilkinson + * @author Phillip Webb + */ +class DefaultExceptionTranslatorExecuteListenerTests { + + private final ExceptionTranslatorExecuteListener listener = new DefaultExceptionTranslatorExecuteListener(); + + @Test + void createWhenTranslatorFactoryIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new DefaultExceptionTranslatorExecuteListener( + (Function) null)) + .withMessage("TranslatorFactory must not be null"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource + void exceptionTranslatesSqlExceptions(SQLDialect dialect, SQLException sqlException) { + ExecuteContext context = mockContext(dialect, sqlException); + this.listener.exception(context); + then(context).should().exception(assertArg((ex) -> assertThat(ex).isInstanceOf(BadSqlGrammarException.class))); + } + + @Test + void exceptionWhenExceptionCannotBeTranslatedDoesNotCallExecuteContextException() { + ExecuteContext context = mockContext(SQLDialect.POSTGRES, new SQLException(null, null, 123456789)); + this.listener.exception(context); + then(context).should(never()).exception(any()); + } + + @Test + void exceptionWhenHasCustomTranslatorFactory() { + SQLExceptionTranslator translator = BadSqlGrammarException::new; + ExceptionTranslatorExecuteListener listener = new DefaultExceptionTranslatorExecuteListener( + (context) -> translator); + SQLException sqlException = sqlException(123); + ExecuteContext context = mockContext(SQLDialect.DUCKDB, sqlException); + listener.exception(context); + then(context).should().exception(assertArg((ex) -> assertThat(ex).isInstanceOf(BadSqlGrammarException.class))); + } + + private ExecuteContext mockContext(SQLDialect dialect, SQLException sqlException) { + ExecuteContext context = mock(ExecuteContext.class); + Configuration configuration = mock(Configuration.class); + given(context.configuration()).willReturn(configuration); + given(configuration.dialect()).willReturn(dialect); + given(context.sqlException()).willReturn(sqlException); + return context; + } + + static Object[] exceptionTranslatesSqlExceptions() { + return new Object[] { new Object[] { SQLDialect.DERBY, sqlException("42802") }, + new Object[] { SQLDialect.H2, sqlException(42000) }, + new Object[] { SQLDialect.HSQLDB, sqlException(-22) }, + new Object[] { SQLDialect.MARIADB, sqlException(1054) }, + new Object[] { SQLDialect.MYSQL, sqlException(1054) }, + new Object[] { SQLDialect.POSTGRES, sqlException("03000") }, + new Object[] { SQLDialect.SQLITE, sqlException("21000") } }; + } + + private static SQLException sqlException(String sqlState) { + return new SQLException(null, sqlState); + } + + private static SQLException sqlException(int vendorCode) { + return new SQLException(null, null, vendorCode); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfigurationTests.java index 1e78fee1d9ad..a26b63349159 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/JooqAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,6 +55,7 @@ * @author Andy Wilkinson * @author Stephane Nicoll * @author Dmytro Nosan + * @author Dennis Melzer */ class JooqAutoConfigurationTests { @@ -180,6 +181,26 @@ void transactionProviderBacksOffOnExistingTransactionProvider() { }); } + @Test + void jooqExceptionTranslatorProviderFromConfigurationCustomizerOverridesJooqExceptionTranslatorBean() { + this.contextRunner + .withUserConfiguration(JooqDataSourceConfiguration.class, CustomJooqExceptionTranslatorConfiguration.class) + .run((context) -> { + assertThat(context.getBean(ExceptionTranslatorExecuteListener.class)) + .isInstanceOf(CustomJooqExceptionTranslator.class); + assertThat(context.getBean(DefaultExecuteListenerProvider.class).provide()) + .isInstanceOf(CustomJooqExceptionTranslator.class); + }); + } + + @Test + void jooqWithDefaultJooqExceptionTranslator() { + this.contextRunner.withUserConfiguration(JooqDataSourceConfiguration.class).run((context) -> { + ExceptionTranslatorExecuteListener translator = context.getBean(ExceptionTranslatorExecuteListener.class); + assertThat(translator).isInstanceOf(DefaultExceptionTranslatorExecuteListener.class); + }); + } + @Test void transactionProviderFromConfigurationCustomizerOverridesTransactionProviderBean() { this.contextRunner @@ -254,6 +275,16 @@ TransactionProvider transactionProvider() { } + @Configuration(proxyBeanMethods = false) + static class CustomJooqExceptionTranslatorConfiguration { + + @Bean + ExceptionTranslatorExecuteListener jooqExceptionTranslator() { + return new CustomJooqExceptionTranslator(); + } + + } + @Configuration(proxyBeanMethods = false) static class CustomTransactionProviderFromCustomizerConfiguration { @@ -303,4 +334,8 @@ public void rollback(TransactionContext ctx) { } + static class CustomJooqExceptionTranslator implements ExceptionTranslatorExecuteListener { + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/JooqExceptionTranslatorTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/JooqExceptionTranslatorTests.java index 5a9f6685f144..f53b34880b38 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/JooqExceptionTranslatorTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jooq/JooqExceptionTranslatorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,8 @@ * * @author Andy Wilkinson */ +@Deprecated(since = "3.3.0") +@SuppressWarnings("removal") class JooqExceptionTranslatorTests { private final JooqExceptionTranslator exceptionTranslator = new JooqExceptionTranslator(); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurerTests.java new file mode 100644 index 000000000000..779d9974d3f1 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/ConcurrentKafkaListenerContainerFactoryConfigurerTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-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.boot.autoconfigure.kafka; + +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.listener.MessageListenerContainer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link ConcurrentKafkaListenerContainerFactoryConfigurer}. + * + * @author Moritz Halbritter + */ +class ConcurrentKafkaListenerContainerFactoryConfigurerTests { + + private ConcurrentKafkaListenerContainerFactoryConfigurer configurer; + + private ConcurrentKafkaListenerContainerFactory factory; + + private ConsumerFactory consumerFactory; + + private KafkaProperties properties; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + this.configurer = new ConcurrentKafkaListenerContainerFactoryConfigurer(); + this.properties = new KafkaProperties(); + this.configurer.setKafkaProperties(this.properties); + this.factory = spy(new ConcurrentKafkaListenerContainerFactory<>()); + this.consumerFactory = mock(ConsumerFactory.class); + + } + + @Test + void shouldApplyThreadNameSupplier() { + Function function = (container) -> "thread-1"; + this.configurer.setThreadNameSupplier(function); + this.configurer.configure(this.factory, this.consumerFactory); + then(this.factory).should().setThreadNameSupplier(function); + } + + @Test + void shouldApplyChangeConsumerThreadName() { + this.properties.getListener().setChangeConsumerThreadName(true); + this.configurer.configure(this.factory, this.consumerFactory); + then(this.factory).should().setChangeConsumerThreadName(true); + } + + @Test + void shouldApplyListenerTaskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + this.configurer.setListenerTaskExecutor(executor); + this.configurer.configure(this.factory, this.consumerFactory); + assertThat(this.factory.getContainerProperties().getListenerTaskExecutor()).isEqualTo(executor); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationIntegrationTests.java index 2b6d6e849a17..02ff53618300 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationIntegrationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,11 +28,13 @@ import org.apache.kafka.streams.kstream.KStream; import org.apache.kafka.streams.kstream.KTable; import org.apache.kafka.streams.kstream.Materialized; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; @@ -58,6 +60,7 @@ * @author Gary Russell * @author Stephane Nicoll * @author Tomaz Fernandes + * @author Andy Wilkinson */ @DisabledOnOs(OS.WINDOWS) @EmbeddedKafka(topics = KafkaAutoConfigurationIntegrationTests.TEST_TOPIC) @@ -113,7 +116,7 @@ void testEndToEndWithRetryTopics() throws Exception { assertThat(listener).extracting(RetryListener::getKey, RetryListener::getReceived) .containsExactly("foo", "bar"); assertThat(listener).extracting(RetryListener::getTopics) - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .hasSize(5) .containsSequence("testRetryTopic", "testRetryTopic-retry-0", "testRetryTopic-retry-1", "testRetryTopic-retry-2"); @@ -133,6 +136,7 @@ private void load(Class config, String... environment) { private AnnotationConfigApplicationContext doLoad(Class[] configs, String... environment) { AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); applicationContext.register(configs); + applicationContext.register(SslAutoConfiguration.class); applicationContext.register(KafkaAutoConfiguration.class); TestPropertyValues.of(environment).applyTo(applicationContext); applicationContext.refresh(); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationTests.java index f274a3b51779..56b627bd8358 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,15 +40,21 @@ import org.apache.kafka.streams.StreamsConfig; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; +import org.springframework.boot.testsupport.assertj.SimpleAsyncTaskExecutorAssert; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.kafka.annotation.EnableKafkaStreams; import org.springframework.kafka.annotation.KafkaStreamsDefaultConfiguration; import org.springframework.kafka.config.AbstractKafkaListenerContainerFactory; @@ -102,11 +108,12 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Scott Frederick */ class KafkaAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(KafkaAutoConfiguration.class)); + .withConfiguration(AutoConfigurations.of(KafkaAutoConfiguration.class, SslAutoConfiguration.class)); @Test void consumerProperties() { @@ -389,20 +396,6 @@ void connectionDetailsAreAppliedToStreams() { }); } - @SuppressWarnings("deprecation") - @Deprecated(since = "3.1.0", forRemoval = true) - void streamsCacheMaxSizeBuffering() { - this.contextRunner.withUserConfiguration(EnableKafkaStreamsConfiguration.class) - .withPropertyValues("spring.kafka.streams.cache-max-size-buffering=1KB") - .run((context) -> { - Properties configs = context - .getBean(KafkaStreamsDefaultConfiguration.DEFAULT_STREAMS_CONFIG_BEAN_NAME, - KafkaStreamsConfiguration.class) - .asProperties(); - assertThat(configs).containsEntry(StreamsConfig.CACHE_MAX_BYTES_BUFFERING_CONFIG, 1024); - }); - } - @SuppressWarnings("unchecked") @Test void streamsApplicationIdUsesMainApplicationNameByDefault() { @@ -570,6 +563,31 @@ void streamsApplicationIdIsNotMandatoryIfEnableKafkaStreamsIsNotSet() { }); } + @Test + void shouldUsePlatformThreadsByDefault() { + this.contextRunner.run((context) -> { + ConcurrentKafkaListenerContainerFactory factory = context + .getBean(ConcurrentKafkaListenerContainerFactory.class); + assertThat(factory).isNotNull(); + AsyncTaskExecutor listenerTaskExecutor = factory.getContainerProperties().getListenerTaskExecutor(); + assertThat(listenerTaskExecutor).isNull(); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void shouldUseVirtualThreadsIfEnabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + ConcurrentKafkaListenerContainerFactory factory = context + .getBean(ConcurrentKafkaListenerContainerFactory.class); + assertThat(factory).isNotNull(); + AsyncTaskExecutor listenerTaskExecutor = factory.getContainerProperties().getListenerTaskExecutor(); + assertThat(listenerTaskExecutor).isInstanceOf(SimpleAsyncTaskExecutor.class); + SimpleAsyncTaskExecutorAssert.assertThat((SimpleAsyncTaskExecutor) listenerTaskExecutor) + .usesVirtualThreads(); + }); + } + @SuppressWarnings("unchecked") @Test void listenerProperties() { @@ -586,7 +604,8 @@ void listenerProperties() { "spring.kafka.listener.missing-topics-fatal=true", "spring.kafka.jaas.enabled=true", "spring.kafka.listener.immediate-stop=true", "spring.kafka.producer.transaction-id-prefix=foo", "spring.kafka.jaas.login-module=foo", "spring.kafka.jaas.control-flag=REQUISITE", - "spring.kafka.jaas.options.useKeyTab=true", "spring.kafka.listener.async-acks=true") + "spring.kafka.jaas.options.useKeyTab=true", "spring.kafka.listener.async-acks=true", + "spring.kafka.template.observation-enabled=true", "spring.kafka.listener.observation-enabled=true") .run((context) -> { DefaultKafkaProducerFactory producerFactory = context.getBean(DefaultKafkaProducerFactory.class); DefaultKafkaConsumerFactory consumerFactory = context.getBean(DefaultKafkaConsumerFactory.class); @@ -597,6 +616,7 @@ void listenerProperties() { assertThat(kafkaTemplate).hasFieldOrPropertyWithValue("producerFactory", producerFactory); assertThat(kafkaTemplate.getDefaultTopic()).isEqualTo("testTopic"); assertThat(kafkaTemplate).hasFieldOrPropertyWithValue("transactionIdPrefix", "txOverride"); + assertThat(kafkaTemplate).hasFieldOrPropertyWithValue("observationEnabled", true); assertThat(kafkaListenerContainerFactory.getConsumerFactory()).isEqualTo(consumerFactory); ContainerProperties containerProperties = kafkaListenerContainerFactory.getContainerProperties(); assertThat(containerProperties.getAckMode()).isEqualTo(AckMode.MANUAL); @@ -613,6 +633,7 @@ void listenerProperties() { assertThat(containerProperties.isLogContainerConfig()).isTrue(); assertThat(containerProperties.isMissingTopicsFatal()).isTrue(); assertThat(containerProperties.isStopImmediate()).isTrue(); + assertThat(containerProperties.isObservationEnabled()).isTrue(); assertThat(kafkaListenerContainerFactory).extracting("concurrency").isEqualTo(3); assertThat(kafkaListenerContainerFactory.isBatchListener()).isTrue(); assertThat(kafkaListenerContainerFactory).hasFieldOrPropertyWithValue("autoStartup", true); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaPropertiesTests.java index 8ee0486d857b..dbd53fd59d81 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaPropertiesTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/kafka/KafkaPropertiesTests.java @@ -27,6 +27,8 @@ import org.springframework.boot.autoconfigure.kafka.KafkaProperties.IsolationLevel; import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Listener; import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; +import org.springframework.boot.ssl.SslBundle; import org.springframework.core.io.ClassPathResource; import org.springframework.kafka.core.CleanupConfig; import org.springframework.kafka.core.KafkaAdmin; @@ -34,16 +36,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; /** * Tests for {@link KafkaProperties}. * * @author Stephane Nicoll * @author Madhura Bhave + * @author Scott Frederick */ class KafkaPropertiesTests { - @SuppressWarnings("rawtypes") + private final SslBundle sslBundle = mock(SslBundle.class); + @Test void isolationLevelEnumConsistentWithKafkaVersion() { org.apache.kafka.common.IsolationLevel[] original = org.apache.kafka.common.IsolationLevel.values(); @@ -75,20 +80,30 @@ void sslPemConfiguration() { properties.getSsl().setKeyStoreKey("-----BEGINkey"); properties.getSsl().setTrustStoreCertificates("-----BEGINtrust"); properties.getSsl().setKeyStoreCertificateChain("-----BEGINchain"); - Map consumerProperties = properties.buildConsumerProperties(); + Map consumerProperties = properties.buildConsumerProperties(null); assertThat(consumerProperties).containsEntry(SslConfigs.SSL_KEYSTORE_KEY_CONFIG, "-----BEGINkey"); assertThat(consumerProperties).containsEntry(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG, "-----BEGINtrust"); assertThat(consumerProperties).containsEntry(SslConfigs.SSL_KEYSTORE_CERTIFICATE_CHAIN_CONFIG, "-----BEGINchain"); } + @Test + void sslBundleConfiguration() { + KafkaProperties properties = new KafkaProperties(); + properties.getSsl().setBundle("myBundle"); + Map consumerProperties = properties + .buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle)); + assertThat(consumerProperties).containsEntry(SslConfigs.SSL_ENGINE_FACTORY_CLASS_CONFIG, + SslBundleSslEngineFactory.class.getName()); + } + @Test void sslPropertiesWhenKeyStoreLocationAndKeySetShouldThrowException() { KafkaProperties properties = new KafkaProperties(); properties.getSsl().setKeyStoreKey("-----BEGIN"); properties.getSsl().setKeyStoreLocation(new ClassPathResource("ksLoc")); assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class) - .isThrownBy(properties::buildConsumerProperties); + .isThrownBy(() -> properties.buildConsumerProperties(null)); } @Test @@ -97,7 +112,43 @@ void sslPropertiesWhenTrustStoreLocationAndCertificatesSetShouldThrowException() properties.getSsl().setTrustStoreLocation(new ClassPathResource("tsLoc")); properties.getSsl().setTrustStoreCertificates("-----BEGIN"); assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class) - .isThrownBy(properties::buildConsumerProperties); + .isThrownBy(() -> properties.buildConsumerProperties(null)); + } + + @Test + void sslPropertiesWhenKeyStoreLocationAndBundleSetShouldThrowException() { + KafkaProperties properties = new KafkaProperties(); + properties.getSsl().setBundle("myBundle"); + properties.getSsl().setKeyStoreLocation(new ClassPathResource("ksLoc")); + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy( + () -> properties.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle))); + } + + @Test + void sslPropertiesWhenKeyStoreKeyAndBundleSetShouldThrowException() { + KafkaProperties properties = new KafkaProperties(); + properties.getSsl().setBundle("myBundle"); + properties.getSsl().setKeyStoreKey("-----BEGIN"); + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy( + () -> properties.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle))); + } + + @Test + void sslPropertiesWhenTrustStoreLocationAndBundleSetShouldThrowException() { + KafkaProperties properties = new KafkaProperties(); + properties.getSsl().setBundle("myBundle"); + properties.getSsl().setTrustStoreLocation(new ClassPathResource("tsLoc")); + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy( + () -> properties.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle))); + } + + @Test + void sslPropertiesWhenTrustStoreCertificatesAndBundleSetShouldThrowException() { + KafkaProperties properties = new KafkaProperties(); + properties.getSsl().setBundle("myBundle"); + properties.getSsl().setTrustStoreCertificates("-----BEGIN"); + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy( + () -> properties.buildConsumerProperties(new DefaultSslBundleRegistry("myBundle", this.sslBundle))); } @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ldap/LdapAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ldap/LdapAutoConfigurationTests.java index bde394cc67a1..2d51f84a9b7b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ldap/LdapAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ldap/LdapAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ import org.springframework.ldap.core.support.SimpleDirContextAuthenticationStrategy; import org.springframework.ldap.pool2.factory.PoolConfig; import org.springframework.ldap.pool2.factory.PooledContextSource; +import org.springframework.ldap.support.LdapUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -112,6 +113,25 @@ void contextSourceWithNoCustomization() { }); } + @Test + void definesPropertiesBasedConnectionDetailsByDefault() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PropertiesLdapConnectionDetails.class)); + } + + @Test + void usesCustomConnectionDetailsWhenDefined() { + this.contextRunner.withUserConfiguration(ConnectionDetailsConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(LdapContextSource.class) + .hasSingleBean(LdapConnectionDetails.class) + .doesNotHaveBean(PropertiesLdapConnectionDetails.class); + LdapContextSource contextSource = context.getBean(LdapContextSource.class); + assertThat(contextSource.getUrls()).isEqualTo(new String[] { "ldaps://ldap.example.com" }); + assertThat(contextSource.getBaseLdapName()).isEqualTo(LdapUtils.newLdapName("dc=base")); + assertThat(contextSource.getUserDn()).isEqualTo("ldap-user"); + assertThat(contextSource.getPassword()).isEqualTo("ldap-password"); + }); + } + @Test void templateExists() { this.contextRunner.withPropertyValues("spring.ldap.urls:ldap://localhost:389").run((context) -> { @@ -174,6 +194,37 @@ void contextSourceWithCustomNonUniqueDirContextAuthenticationStrategy() { }); } + @Configuration(proxyBeanMethods = false) + static class ConnectionDetailsConfiguration { + + @Bean + LdapConnectionDetails ldapConnectionDetails() { + return new LdapConnectionDetails() { + + @Override + public String[] getUrls() { + return new String[] { "ldaps://ldap.example.com" }; + } + + @Override + public String getBase() { + return "dc=base"; + } + + @Override + public String getUsername() { + return "ldap-user"; + } + + @Override + public String getPassword() { + return "ldap-password"; + } + }; + } + + } + @Configuration(proxyBeanMethods = false) static class PooledContextSourceConfig { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/Liquibase423AutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/Liquibase423AutoConfigurationTests.java new file mode 100644 index 000000000000..850a1e66124d --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/Liquibase423AutoConfigurationTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-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.boot.autoconfigure.liquibase; + +import java.util.function.Consumer; + +import liquibase.integration.spring.SpringLiquibase; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jdbc.EmbeddedDataSourceConfiguration; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ContextConsumer; +import org.springframework.boot.testsupport.classpath.ClassPathOverrides; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LiquibaseAutoConfiguration} with Liquibase 4.23. + * + * @author Andy Wilkinson + */ +@ClassPathOverrides("org.liquibase:liquibase-core:4.23.1") +class Liquibase423AutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class)) + .withPropertyValues("spring.datasource.generate-unique-name=true"); + + @Test + void defaultSpringLiquibase() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .run(assertLiquibase((liquibase) -> { + assertThat(liquibase.getChangeLog()).isEqualTo("classpath:/db/changelog/db.changelog-master.yaml"); + assertThat(liquibase.getContexts()).isNull(); + assertThat(liquibase.getDefaultSchema()).isNull(); + assertThat(liquibase.isDropFirst()).isFalse(); + assertThat(liquibase.isClearCheckSums()).isFalse(); + })); + } + + private ContextConsumer assertLiquibase(Consumer consumer) { + return (context) -> { + assertThat(context).hasSingleBean(SpringLiquibase.class); + SpringLiquibase liquibase = context.getBean(SpringLiquibase.class); + consumer.accept(liquibase); + }; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfigurationTests.java index b05e9b0f010d..ac15c4880e33 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfigurationTests.java @@ -29,6 +29,9 @@ import javax.sql.DataSource; import com.zaxxer.hikari.HikariDataSource; +import liquibase.UpdateSummaryEnum; +import liquibase.UpdateSummaryOutputEnum; +import liquibase.command.core.helpers.ShowSummaryArgument; import liquibase.integration.spring.SpringLiquibase; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -220,6 +223,9 @@ void defaultValues() { assertThat(liquibase.isDropFirst()).isEqualTo(properties.isDropFirst()); assertThat(liquibase.isClearCheckSums()).isEqualTo(properties.isClearChecksums()); assertThat(liquibase.isTestRollbackOnUpdate()).isEqualTo(properties.isTestRollbackOnUpdate()); + assertThat(liquibase).extracting("showSummary").isNull(); + assertThat(ShowSummaryArgument.SHOW_SUMMARY.getDefaultValue()).isEqualTo(UpdateSummaryEnum.SUMMARY); + assertThat(liquibase).extracting("showSummaryOutput").isEqualTo(UpdateSummaryOutputEnum.LOG); })); } @@ -266,8 +272,12 @@ void overrideDropFirst() { @Test void overrideClearChecksums() { + String jdbcUrl = "jdbc:hsqldb:mem:liquibase" + UUID.randomUUID(); + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.liquibase.url:" + jdbcUrl) + .run((context) -> assertThat(context).hasNotFailed()); this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.liquibase.clear-checksums:true") + .withPropertyValues("spring.liquibase.clear-checksums:true", "spring.liquibase.url:" + jdbcUrl) .run(assertLiquibase((liquibase) -> assertThat(liquibase.isClearCheckSums()).isTrue())); } @@ -380,11 +390,25 @@ void overrideLabelFilter() { } @Test - @Deprecated(since = "3.0.0", forRemoval = true) - void overrideLabelFilterWithDeprecatedLabelsProperty() { + void overrideShowSummary() { this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.liquibase.labels:test, production") - .run(assertLiquibase((liquibase) -> assertThat(liquibase.getLabelFilter()).isEqualTo("test, production"))); + .withPropertyValues("spring.liquibase.show-summary=off") + .run(assertLiquibase((liquibase) -> { + UpdateSummaryEnum showSummary = (UpdateSummaryEnum) ReflectionTestUtils.getField(liquibase, + "showSummary"); + assertThat(showSummary).isEqualTo(UpdateSummaryEnum.OFF); + })); + } + + @Test + void overrideShowSummaryOutput() { + this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) + .withPropertyValues("spring.liquibase.show-summary-output=all") + .run(assertLiquibase((liquibase) -> { + UpdateSummaryOutputEnum showSummaryOutput = (UpdateSummaryOutputEnum) ReflectionTestUtils + .getField(liquibase, "showSummaryOutput"); + assertThat(showSummaryOutput).isEqualTo(UpdateSummaryOutputEnum.ALL); + })); } @Test @@ -404,7 +428,7 @@ void testOverrideParameters() { void rollbackFile(@TempDir Path temp) throws IOException { File file = Files.createTempFile(temp, "rollback-file", "sql").toFile(); this.contextRunner.withUserConfiguration(EmbeddedDataSourceConfiguration.class) - .withPropertyValues("spring.liquibase.rollbackFile:" + file.getAbsolutePath()) + .withPropertyValues("spring.liquibase.rollback-file:" + file.getAbsolutePath()) .run((context) -> { SpringLiquibase liquibase = context.getBean(SpringLiquibase.class); File actualFile = (File) ReflectionTestUtils.getField(liquibase, "rollbackFile"); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibasePropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibasePropertiesTests.java new file mode 100644 index 000000000000..57f6025f46a8 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/liquibase/LiquibasePropertiesTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-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.boot.autoconfigure.liquibase; + +import java.util.List; +import java.util.stream.Stream; + +import liquibase.UpdateSummaryEnum; +import liquibase.UpdateSummaryOutputEnum; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties.ShowSummary; +import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties.ShowSummaryOutput; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LiquibaseProperties}. + * + * @author Andy Wilkinson + */ +public class LiquibasePropertiesTests { + + @Test + void valuesOfShowSummaryMatchValuesOfUpdateSummaryEnum() { + assertThat(namesOf(ShowSummary.values())).isEqualTo(namesOf(UpdateSummaryEnum.values())); + } + + @Test + void valuesOfShowSummaryOutputMatchValuesOfUpdateSummaryOutputEnum() { + assertThat(namesOf(ShowSummaryOutput.values())).isEqualTo(namesOf(UpdateSummaryOutputEnum.values())); + } + + private List namesOf(Enum[] input) { + return Stream.of(input).map(Enum::name).toList(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoClientFactorySupportTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoClientFactorySupportTests.java index 87861cf2d0b7..d596a1ff92c8 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoClientFactorySupportTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoClientFactorySupportTests.java @@ -43,7 +43,7 @@ * @author Mark Paluch * @author Artsiom Yudovin * @author Scott Frederick - * @author Mortiz Halbritter + * @author Moritz Halbritter */ abstract class MongoClientFactorySupportTests { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizerTests.java deleted file mode 100644 index 021d298523c0..000000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoPropertiesClientSettingsBuilderCustomizerTests.java +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright 2012-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.boot.autoconfigure.mongo; - -import java.util.Arrays; -import java.util.List; - -import com.mongodb.MongoClientSettings; -import com.mongodb.MongoCredential; -import com.mongodb.ServerAddress; -import org.bson.UuidRepresentation; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link MongoPropertiesClientSettingsBuilderCustomizer}. - * - * @author Scott Frederick - */ -@Deprecated(since = "3.1.0", forRemoval = true) -class MongoPropertiesClientSettingsBuilderCustomizerTests { - - private final MongoProperties properties = new MongoProperties(); - - @Test - void portCanBeCustomized() { - this.properties.setPort(12345); - MongoClientSettings settings = customizeSettings(); - List allAddresses = getAllAddresses(settings); - assertThat(allAddresses).hasSize(1); - assertServerAddress(allAddresses.get(0), "localhost", 12345); - } - - @Test - void hostCanBeCustomized() { - this.properties.setHost("mongo.example.com"); - MongoClientSettings settings = customizeSettings(); - List allAddresses = getAllAddresses(settings); - assertThat(allAddresses).hasSize(1); - assertServerAddress(allAddresses.get(0), "mongo.example.com", 27017); - } - - @Test - void additionalHostCanBeAdded() { - this.properties.setHost("mongo.example.com"); - this.properties.setAdditionalHosts(Arrays.asList("mongo.example.com:33", "mongo.example2.com")); - MongoClientSettings settings = customizeSettings(); - List allAddresses = getAllAddresses(settings); - assertThat(allAddresses).hasSize(3); - assertServerAddress(allAddresses.get(0), "mongo.example.com", 27017); - assertServerAddress(allAddresses.get(1), "mongo.example.com", 33); - assertServerAddress(allAddresses.get(2), "mongo.example2.com", 27017); - } - - @Test - void credentialsCanBeCustomized() { - this.properties.setUsername("user"); - this.properties.setPassword("secret".toCharArray()); - MongoClientSettings settings = customizeSettings(); - assertMongoCredential(settings.getCredential(), "user", "secret", "test"); - } - - @Test - void replicaSetCanBeCustomized() { - this.properties.setReplicaSetName("test"); - MongoClientSettings settings = customizeSettings(); - assertThat(settings.getClusterSettings().getRequiredReplicaSetName()).isEqualTo("test"); - } - - @Test - void databaseCanBeCustomized() { - this.properties.setDatabase("foo"); - this.properties.setUsername("user"); - this.properties.setPassword("secret".toCharArray()); - MongoClientSettings settings = customizeSettings(); - assertMongoCredential(settings.getCredential(), "user", "secret", "foo"); - } - - @Test - void uuidRepresentationDefaultToJavaLegacy() { - MongoClientSettings settings = customizeSettings(); - assertThat(settings.getUuidRepresentation()).isEqualTo(UuidRepresentation.JAVA_LEGACY); - } - - @Test - void uuidRepresentationCanBeCustomized() { - this.properties.setUuidRepresentation(UuidRepresentation.STANDARD); - MongoClientSettings settings = customizeSettings(); - assertThat(settings.getUuidRepresentation()).isEqualTo(UuidRepresentation.STANDARD); - } - - @Test - void authenticationDatabaseCanBeCustomized() { - this.properties.setAuthenticationDatabase("foo"); - this.properties.setUsername("user"); - this.properties.setPassword("secret".toCharArray()); - MongoClientSettings settings = customizeSettings(); - assertMongoCredential(settings.getCredential(), "user", "secret", "foo"); - } - - @Test - void onlyHostAndPortSetShouldUseThat() { - this.properties.setHost("localhost"); - this.properties.setPort(27017); - MongoClientSettings settings = customizeSettings(); - List allAddresses = getAllAddresses(settings); - assertThat(allAddresses).hasSize(1); - assertServerAddress(allAddresses.get(0), "localhost", 27017); - } - - @Test - void onlyUriSetShouldUseThat() { - this.properties.setUri("mongodb://mongo1.example.com:12345"); - MongoClientSettings settings = customizeSettings(); - List allAddresses = getAllAddresses(settings); - assertThat(allAddresses).hasSize(1); - assertServerAddress(allAddresses.get(0), "mongo1.example.com", 12345); - } - - @Test - void noCustomAddressAndNoUriUsesDefaultUri() { - MongoClientSettings settings = customizeSettings(); - List allAddresses = getAllAddresses(settings); - assertThat(allAddresses).hasSize(1); - assertServerAddress(allAddresses.get(0), "localhost", 27017); - } - - @Test - void uriCanBeCustomized() { - this.properties.setUri("mongodb://user:secret@mongo1.example.com:12345,mongo2.example.com:23456/test"); - MongoClientSettings settings = customizeSettings(); - List allAddresses = getAllAddresses(settings); - assertThat(allAddresses).hasSize(2); - assertServerAddress(allAddresses.get(0), "mongo1.example.com", 12345); - assertServerAddress(allAddresses.get(1), "mongo2.example.com", 23456); - assertMongoCredential(settings.getCredential(), "user", "secret", "test"); - } - - @Test - void uriOverridesUsernameAndPassword() { - this.properties.setUri("mongodb://127.0.0.1:1234/mydb"); - this.properties.setUsername("user"); - this.properties.setPassword("secret".toCharArray()); - MongoClientSettings settings = customizeSettings(); - assertThat(settings.getCredential()).isNull(); - } - - @Test - void uriOverridesDatabase() { - this.properties.setUri("mongodb://secret:password@127.0.0.1:1234/mydb"); - this.properties.setDatabase("test"); - MongoClientSettings settings = customizeSettings(); - List allAddresses = getAllAddresses(settings); - assertThat(allAddresses).hasSize(1); - assertServerAddress(allAddresses.get(0), "127.0.0.1", 1234); - assertThat(settings.getCredential().getSource()).isEqualTo("mydb"); - } - - @Test - void uriOverridesHostAndPort() { - this.properties.setUri("mongodb://127.0.0.1:1234/mydb"); - this.properties.setHost("localhost"); - this.properties.setPort(4567); - MongoClientSettings settings = customizeSettings(); - List addresses = getAllAddresses(settings); - assertThat(addresses.get(0).getHost()).isEqualTo("127.0.0.1"); - assertThat(addresses.get(0).getPort()).isEqualTo(1234); - } - - @Test - void retryWritesIsPropagatedFromUri() { - this.properties.setUri("mongodb://localhost/test?retryWrites=false"); - MongoClientSettings settings = customizeSettings(); - assertThat(settings.getRetryWrites()).isFalse(); - } - - @SuppressWarnings("removal") - private MongoClientSettings customizeSettings() { - MongoClientSettings.Builder settings = MongoClientSettings.builder(); - new MongoPropertiesClientSettingsBuilderCustomizer(this.properties).customize(settings); - return settings.build(); - } - - private List getAllAddresses(MongoClientSettings settings) { - return settings.getClusterSettings().getHosts(); - } - - protected void assertServerAddress(ServerAddress serverAddress, String expectedHost, int expectedPort) { - assertThat(serverAddress.getHost()).isEqualTo(expectedHost); - assertThat(serverAddress.getPort()).isEqualTo(expectedPort); - } - - protected void assertMongoCredential(MongoCredential credentials, String expectedUsername, String expectedPassword, - String expectedSource) { - assertThat(credentials.getUserName()).isEqualTo(expectedUsername); - assertThat(credentials.getPassword()).isEqualTo(expectedPassword.toCharArray()); - assertThat(credentials.getSource()).isEqualTo(expectedSource); - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfigurationTests.java index 7f3fe1ef0b12..df46e4b45933 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mongo/MongoReactiveAutoConfigurationTests.java @@ -23,11 +23,9 @@ import com.mongodb.MongoClientSettings; import com.mongodb.MongoCredential; import com.mongodb.ReadPreference; -import com.mongodb.connection.AsynchronousSocketChannelStreamFactoryFactory; +import com.mongodb.connection.NettyTransportSettings; import com.mongodb.connection.SslSettings; -import com.mongodb.connection.StreamFactory; -import com.mongodb.connection.StreamFactoryFactory; -import com.mongodb.connection.netty.NettyStreamFactoryFactory; +import com.mongodb.connection.TransportSettings; import com.mongodb.reactivestreams.client.MongoClient; import com.mongodb.reactivestreams.client.internal.MongoClientImpl; import io.netty.channel.EventLoopGroup; @@ -39,12 +37,8 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; /** * Tests for {@link MongoReactiveAutoConfiguration}. @@ -85,7 +79,7 @@ void settingsSslConfig() { assertThat(context).hasSingleBean(MongoClient.class); MongoClientSettings settings = getSettings(context); assertThat(settings.getApplicationName()).isEqualTo("test-config"); - assertThat(settings.getStreamFactoryFactory()).isSameAs(context.getBean("myStreamFactoryFactory")); + assertThat(settings.getTransportSettings()).isSameAs(context.getBean("myTransportSettings")); }); } @@ -212,13 +206,13 @@ void configuresCredentialsFromUriPropertyWithAuthDatabase() { } @Test - void nettyStreamFactoryFactoryIsConfiguredAutomatically() { + void nettyTransportSettingsAreConfiguredAutomatically() { AtomicReference eventLoopGroupReference = new AtomicReference<>(); this.contextRunner.run((context) -> { assertThat(context).hasSingleBean(MongoClient.class); - StreamFactoryFactory factory = getSettings(context).getStreamFactoryFactory(); - assertThat(factory).isInstanceOf(NettyStreamFactoryFactory.class); - EventLoopGroup eventLoopGroup = (EventLoopGroup) ReflectionTestUtils.getField(factory, "eventLoopGroup"); + TransportSettings transportSettings = getSettings(context).getTransportSettings(); + assertThat(transportSettings).isInstanceOf(NettyTransportSettings.class); + EventLoopGroup eventLoopGroup = ((NettyTransportSettings) transportSettings).getEventLoopGroup(); assertThat(eventLoopGroup.isShutdown()).isFalse(); eventLoopGroupReference.set(eventLoopGroup); }); @@ -226,14 +220,17 @@ void nettyStreamFactoryFactoryIsConfiguredAutomatically() { } @Test - void customizerOverridesAutoConfig() { + @SuppressWarnings("deprecation") + void customizerWithTransportSettingsOverridesAutoConfig() { this.contextRunner.withPropertyValues("spring.data.mongodb.uri:mongodb://localhost/test?appname=auto-config") - .withUserConfiguration(SimpleCustomizerConfig.class) + .withUserConfiguration(SimpleTransportSettingsCustomizerConfig.class) .run((context) -> { assertThat(context).hasSingleBean(MongoClient.class); MongoClientSettings settings = getSettings(context); - assertThat(settings.getApplicationName()).isEqualTo("overridden-name"); - assertThat(settings.getStreamFactoryFactory()).isEqualTo(SimpleCustomizerConfig.streamFactoryFactory); + assertThat(settings.getApplicationName()).isEqualTo("custom-transport-settings"); + assertThat(settings.getTransportSettings()) + .isSameAs(SimpleTransportSettingsCustomizerConfig.transportSettings); + assertThat(settings.getStreamFactoryFactory()).isNull(); }); } @@ -278,32 +275,29 @@ MongoClientSettings mongoClientSettings() { static class SslSettingsConfig { @Bean - MongoClientSettings mongoClientSettings(StreamFactoryFactory streamFactoryFactory) { + MongoClientSettings mongoClientSettings(TransportSettings transportSettings) { return MongoClientSettings.builder() .applicationName("test-config") - .streamFactoryFactory(streamFactoryFactory) + .transportSettings(transportSettings) .build(); } @Bean - StreamFactoryFactory myStreamFactoryFactory() { - StreamFactoryFactory streamFactoryFactory = mock(StreamFactoryFactory.class); - given(streamFactoryFactory.create(any(), any())).willReturn(mock(StreamFactory.class)); - return streamFactoryFactory; + TransportSettings myTransportSettings() { + return TransportSettings.nettyBuilder().build(); } } @Configuration(proxyBeanMethods = false) - static class SimpleCustomizerConfig { + static class SimpleTransportSettingsCustomizerConfig { - private static final StreamFactoryFactory streamFactoryFactory = new AsynchronousSocketChannelStreamFactoryFactory.Builder() - .build(); + private static final TransportSettings transportSettings = TransportSettings.nettyBuilder().build(); @Bean MongoClientSettingsBuilderCustomizer customizer() { - return (clientSettingsBuilder) -> clientSettingsBuilder.applicationName("overridden-name") - .streamFactoryFactory(streamFactoryFactory); + return (clientSettingsBuilder) -> clientSettingsBuilder.applicationName("custom-transport-settings") + .transportSettings(transportSettings); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationIntegrationTests.java index c8febbbe6056..e6cbbe3605eb 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationIntegrationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationIntegrationTests.java @@ -16,9 +16,15 @@ package org.springframework.boot.autoconfigure.neo4j; +import java.net.URI; import java.time.Duration; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.neo4j.driver.AuthToken; +import org.neo4j.driver.AuthTokenManager; +import org.neo4j.driver.AuthTokenManagers; +import org.neo4j.driver.AuthTokens; import org.neo4j.driver.Driver; import org.neo4j.driver.Result; import org.neo4j.driver.Session; @@ -31,6 +37,7 @@ import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -43,7 +50,6 @@ * @author Michael J. Simons * @author Stephane Nicoll */ -@SpringBootTest @Testcontainers(disabledWithoutDocker = true) class Neo4jAutoConfigurationIntegrationTests { @@ -52,28 +58,125 @@ class Neo4jAutoConfigurationIntegrationTests { .withStartupAttempts(5) .withStartupTimeout(Duration.ofMinutes(10)); - @DynamicPropertySource - static void neo4jProperties(DynamicPropertyRegistry registry) { - registry.add("spring.neo4j.uri", neo4jServer::getBoltUrl); - registry.add("spring.neo4j.authentication.username", () -> "neo4j"); - registry.add("spring.neo4j.authentication.password", neo4jServer::getAdminPassword); + @SpringBootTest + @Nested + class DriverWithDefaultAuthToken { + + @DynamicPropertySource + static void neo4jProperties(DynamicPropertyRegistry registry) { + registry.add("spring.neo4j.uri", neo4jServer::getBoltUrl); + registry.add("spring.neo4j.authentication.username", () -> "neo4j"); + registry.add("spring.neo4j.authentication.password", neo4jServer::getAdminPassword); + } + + @Autowired + private Driver driver; + + @Test + void driverCanHandleRequest() { + try (Session session = this.driver.session(); Transaction tx = session.beginTransaction()) { + Result statementResult = tx.run("MATCH (n:Thing) RETURN n LIMIT 1"); + assertThat(statementResult.hasNext()).isFalse(); + tx.commit(); + } + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(Neo4jAutoConfiguration.class) + static class TestConfiguration { + + } + } - @Autowired - private Driver driver; + @SpringBootTest + @Nested + class DriverWithDynamicAuthToken { + + @DynamicPropertySource + static void neo4jProperties(DynamicPropertyRegistry registry) { + registry.add("spring.neo4j.uri", neo4jServer::getBoltUrl); + registry.add("spring.neo4j.authentication.username", () -> "wrong"); + registry.add("spring.neo4j.authentication.password", () -> "alsowrong"); + } + + @Autowired + private Driver driver; + + @Test + void driverCanHandleRequest() { + try (Session session = this.driver.session(); Transaction tx = session.beginTransaction()) { + Result statementResult = tx.run("MATCH (n:Thing) RETURN n LIMIT 1"); + assertThat(statementResult.hasNext()).isFalse(); + tx.commit(); + } + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(Neo4jAutoConfiguration.class) + static class TestConfiguration { + + @Bean + AuthTokenManager authTokenManager() { + return AuthTokenManagers.bearer(() -> AuthTokens.basic("neo4j", neo4jServer.getAdminPassword()) + .expiringAt(System.currentTimeMillis() + 5_000)); + } - @Test - void driverCanHandleRequest() { - try (Session session = this.driver.session(); Transaction tx = session.beginTransaction()) { - Result statementResult = tx.run("MATCH (n:Thing) RETURN n LIMIT 1"); - assertThat(statementResult.hasNext()).isFalse(); - tx.commit(); } + } - @Configuration(proxyBeanMethods = false) - @ImportAutoConfiguration(Neo4jAutoConfiguration.class) - static class TestConfiguration { + @SpringBootTest + @Nested + class DriverWithCustomConnectionDetailsIgnoresAuthTokenManager { + + @DynamicPropertySource + static void neo4jProperties(DynamicPropertyRegistry registry) { + registry.add("spring.neo4j.uri", neo4jServer::getBoltUrl); + registry.add("spring.neo4j.authentication.username", () -> "wrong"); + registry.add("spring.neo4j.authentication.password", () -> "alsowrong"); + } + + @Autowired + private Driver driver; + + @Test + void driverCanHandleRequest() { + try (Session session = this.driver.session(); Transaction tx = session.beginTransaction()) { + Result statementResult = tx.run("MATCH (n:Thing) RETURN n LIMIT 1"); + assertThat(statementResult.hasNext()).isFalse(); + tx.commit(); + } + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(Neo4jAutoConfiguration.class) + static class TestConfiguration { + + @Bean + AuthTokenManager authTokenManager() { + return AuthTokenManagers.bearer(() -> AuthTokens.basic("wrongagain", "stillwrong") + .expiringAt(System.currentTimeMillis() + 5_000)); + } + + @Bean + Neo4jConnectionDetails connectionDetails() { + return new Neo4jConnectionDetails() { + + @Override + public URI getUri() { + return URI.create(neo4jServer.getBoltUrl()); + } + + @Override + public AuthToken getAuthToken() { + return AuthTokens.basic("neo4j", neo4jServer.getAdminPassword()); + } + + }; + } + + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationTests.java index df6821f2c5ef..1a05936d3c65 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/neo4j/Neo4jAutoConfigurationTests.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.neo4j.driver.AuthTokenManagers; import org.neo4j.driver.AuthTokens; import org.neo4j.driver.Config; import org.neo4j.driver.Config.ConfigBuilder; @@ -143,7 +144,7 @@ void maxTransactionRetryTime() { @Test void uriShouldDefaultToLocalhost() { - assertThat(new PropertiesNeo4jConnectionDetails(new Neo4jProperties()).getUri()) + assertThat(new PropertiesNeo4jConnectionDetails(new Neo4jProperties(), null).getUri()) .isEqualTo(URI.create("bolt://localhost:7687")); } @@ -152,12 +153,12 @@ void determineServerUriWithCustomUriShouldOverrideDefault() { URI customUri = URI.create("bolt://localhost:4242"); Neo4jProperties properties = new Neo4jProperties(); properties.setUri(customUri); - assertThat(new PropertiesNeo4jConnectionDetails(properties).getUri()).isEqualTo(customUri); + assertThat(new PropertiesNeo4jConnectionDetails(properties, null).getUri()).isEqualTo(customUri); } @Test void authenticationShouldDefaultToNone() { - assertThat(new PropertiesNeo4jConnectionDetails(new Neo4jProperties()).getAuthToken()) + assertThat(new PropertiesNeo4jConnectionDetails(new Neo4jProperties(), null).getAuthToken()) .isEqualTo(AuthTokens.none()); } @@ -166,8 +167,9 @@ void authenticationWithUsernameShouldEnableBasicAuth() { Neo4jProperties properties = new Neo4jProperties(); properties.getAuthentication().setUsername("Farin"); properties.getAuthentication().setPassword("Urlaub"); - assertThat(new PropertiesNeo4jConnectionDetails(properties).getAuthToken()) - .isEqualTo(AuthTokens.basic("Farin", "Urlaub")); + PropertiesNeo4jConnectionDetails connectionDetails = new PropertiesNeo4jConnectionDetails(properties, null); + assertThat(connectionDetails.getAuthToken()).isEqualTo(AuthTokens.basic("Farin", "Urlaub")); + assertThat(connectionDetails.getAuthTokenManager()).isNull(); } @Test @@ -177,8 +179,22 @@ void authenticationWithUsernameAndRealmShouldEnableBasicAuth() { authentication.setUsername("Farin"); authentication.setPassword("Urlaub"); authentication.setRealm("Test Realm"); - assertThat(new PropertiesNeo4jConnectionDetails(properties).getAuthToken()) - .isEqualTo(AuthTokens.basic("Farin", "Urlaub", "Test Realm")); + PropertiesNeo4jConnectionDetails connectionDetails = new PropertiesNeo4jConnectionDetails(properties, null); + assertThat(connectionDetails.getAuthToken()).isEqualTo(AuthTokens.basic("Farin", "Urlaub", "Test Realm")); + assertThat(connectionDetails.getAuthTokenManager()).isNull(); + } + + @Test + void authenticationWithAuthTokenManagerAndUsernameShouldProvideAuthTokenManger() { + Neo4jProperties properties = new Neo4jProperties(); + Authentication authentication = properties.getAuthentication(); + authentication.setUsername("Farin"); + authentication.setPassword("Urlaub"); + authentication.setRealm("Test Realm"); + assertThat(new PropertiesNeo4jConnectionDetails(properties, + AuthTokenManagers.bearer( + () -> AuthTokens.basic("username", "password").expiringAt(System.currentTimeMillis() + 5000))) + .getAuthTokenManager()).isNotNull(); } @Test @@ -186,7 +202,7 @@ void authenticationWithKerberosTicketShouldEnableKerberos() { Neo4jProperties properties = new Neo4jProperties(); Authentication authentication = properties.getAuthentication(); authentication.setKerberosTicket("AABBCCDDEE"); - assertThat(new PropertiesNeo4jConnectionDetails(properties).getAuthToken()) + assertThat(new PropertiesNeo4jConnectionDetails(properties, null).getAuthToken()) .isEqualTo(AuthTokens.kerberos("AABBCCDDEE")); } @@ -197,7 +213,7 @@ void authenticationWithBothUsernameAndKerberosShouldNotBeAllowed() { authentication.setUsername("Farin"); authentication.setKerberosTicket("AABBCCDDEE"); assertThatIllegalStateException() - .isThrownBy(() -> new PropertiesNeo4jConnectionDetails(properties).getAuthToken()) + .isThrownBy(() -> new PropertiesNeo4jConnectionDetails(properties, null).getAuthToken()) .withMessage("Cannot specify both username ('Farin') and kerberos ticket ('AABBCCDDEE')"); } @@ -313,7 +329,7 @@ void driverConfigShouldBeConfiguredToUseUseSpringJclLogging() { private Config mapDriverConfig(Neo4jProperties properties, ConfigBuilderCustomizer... customizers) { return new Neo4jAutoConfiguration().mapDriverConfig(properties, - new PropertiesNeo4jConnectionDetails(properties), Arrays.asList(customizers)); + new PropertiesNeo4jConnectionDetails(properties, null), Arrays.asList(customizers)); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/AbstractJpaAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/AbstractJpaAutoConfigurationTests.java index 38909c284b3a..869607e7e875 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/AbstractJpaAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/AbstractJpaAutoConfigurationTests.java @@ -39,6 +39,7 @@ import org.springframework.boot.autoconfigure.orm.jpa.test.City; import org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration; import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizationAutoConfiguration; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -82,7 +83,8 @@ protected AbstractJpaAutoConfigurationTests(Class autoConfiguredClass) { "spring.jta.log-dir=" + new File(new BuildOutput(getClass()).getRootLocation(), "transaction-logs")) .withUserConfiguration(TestConfiguration.class) .withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, - TransactionAutoConfiguration.class, SqlInitializationAutoConfiguration.class, autoConfiguredClass)); + TransactionAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class, + SqlInitializationAutoConfiguration.class, autoConfiguredClass)); } protected ApplicationContextRunner contextRunner() { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfigurationTests.java index d1ddd7e00972..b5c385699f95 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaAutoConfigurationTests.java @@ -40,7 +40,8 @@ import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy; import org.hibernate.boot.model.naming.ImplicitNamingStrategy; import org.hibernate.boot.model.naming.PhysicalNamingStrategy; -import org.hibernate.cfg.AvailableSettings; +import org.hibernate.cfg.ManagedBeanSettings; +import org.hibernate.cfg.SchemaToolingSettings; import org.hibernate.dialect.H2Dialect; import org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform; import org.hibernate.engine.transaction.jta.platform.spi.JtaPlatform; @@ -129,7 +130,8 @@ void testDmlScript() { void testDmlScriptRunsEarly() { contextRunner().withUserConfiguration(TestInitializedJpaConfiguration.class) .withClassLoader(new HideDataScriptClassLoader()) - .withPropertyValues("spring.jpa.show-sql=true", "spring.jpa.hibernate.ddl-auto:create-drop", + .withPropertyValues("spring.jpa.show-sql=true", "spring.jpa.properties.hibernate.format_sql=true", + "spring.jpa.properties.hibernate.highlight_sql=true", "spring.jpa.hibernate.ddl-auto:create-drop", "spring.sql.init.data-locations:/city.sql", "spring.jpa.defer-datasource-initialization=true") .run((context) -> assertThat(context.getBean(TestInitializedJpaConfiguration.class).called).isTrue()); } @@ -153,7 +155,7 @@ void testFlywayPlusValidation() { @Test void testLiquibasePlusValidation() { contextRunner() - .withPropertyValues("spring.liquibase.changeLog:classpath:db/changelog/db.changelog-city.yaml", + .withPropertyValues("spring.liquibase.change-log:classpath:db/changelog/db.changelog-city.yaml", "spring.jpa.hibernate.ddl-auto:validate") .withConfiguration(AutoConfigurations.of(LiquibaseAutoConfiguration.class)) .run((context) -> assertThat(context).hasNotFailed()); @@ -386,8 +388,8 @@ void hibernatePropertiesCustomizerCanDisableBeanContainer() { @Test void vendorPropertiesWithEmbeddedDatabaseAndNoDdlProperty() { contextRunner().run(vendorProperties((vendorProperties) -> { - assertThat(vendorProperties).doesNotContainKeys(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION); - assertThat(vendorProperties).containsEntry(AvailableSettings.HBM2DDL_AUTO, "create-drop"); + assertThat(vendorProperties).doesNotContainKeys(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION); + assertThat(vendorProperties).containsEntry(SchemaToolingSettings.HBM2DDL_AUTO, "create-drop"); })); } @@ -395,8 +397,8 @@ void vendorPropertiesWithEmbeddedDatabaseAndNoDdlProperty() { void vendorPropertiesWhenDdlAutoPropertyIsSet() { contextRunner().withPropertyValues("spring.jpa.hibernate.ddl-auto=update") .run(vendorProperties((vendorProperties) -> { - assertThat(vendorProperties).doesNotContainKeys(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION); - assertThat(vendorProperties).containsEntry(AvailableSettings.HBM2DDL_AUTO, "update"); + assertThat(vendorProperties).doesNotContainKeys(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION); + assertThat(vendorProperties).containsEntry(SchemaToolingSettings.HBM2DDL_AUTO, "update"); })); } @@ -406,8 +408,8 @@ void vendorPropertiesWhenDdlAutoPropertyAndHibernatePropertiesAreSet() { .withPropertyValues("spring.jpa.hibernate.ddl-auto=update", "spring.jpa.properties.hibernate.hbm2ddl.auto=create-drop") .run(vendorProperties((vendorProperties) -> { - assertThat(vendorProperties).doesNotContainKeys(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION); - assertThat(vendorProperties).containsEntry(AvailableSettings.HBM2DDL_AUTO, "create-drop"); + assertThat(vendorProperties).doesNotContainKeys(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION); + assertThat(vendorProperties).containsEntry(SchemaToolingSettings.HBM2DDL_AUTO, "create-drop"); })); } @@ -415,7 +417,7 @@ void vendorPropertiesWhenDdlAutoPropertyAndHibernatePropertiesAreSet() { void vendorPropertiesWhenDdlAutoPropertyIsSetToNone() { contextRunner().withPropertyValues("spring.jpa.hibernate.ddl-auto=none") .run(vendorProperties((vendorProperties) -> assertThat(vendorProperties).doesNotContainKeys( - AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, AvailableSettings.HBM2DDL_AUTO))); + SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, SchemaToolingSettings.HBM2DDL_AUTO))); } @Test @@ -423,8 +425,9 @@ void vendorPropertiesWhenJpaDdlActionIsSet() { contextRunner() .withPropertyValues("spring.jpa.properties.jakarta.persistence.schema-generation.database.action=create") .run(vendorProperties((vendorProperties) -> { - assertThat(vendorProperties).containsEntry(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, "create"); - assertThat(vendorProperties).doesNotContainKeys(AvailableSettings.HBM2DDL_AUTO); + assertThat(vendorProperties).containsEntry(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, + "create"); + assertThat(vendorProperties).doesNotContainKeys(SchemaToolingSettings.HBM2DDL_AUTO); })); } @@ -434,8 +437,9 @@ void vendorPropertiesWhenBothDdlAutoPropertiesAreSet() { .withPropertyValues("spring.jpa.properties.jakarta.persistence.schema-generation.database.action=create", "spring.jpa.hibernate.ddl-auto=create-only") .run(vendorProperties((vendorProperties) -> { - assertThat(vendorProperties).containsEntry(AvailableSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, "create"); - assertThat(vendorProperties).containsEntry(AvailableSettings.HBM2DDL_AUTO, "create-only"); + assertThat(vendorProperties).containsEntry(SchemaToolingSettings.JAKARTA_HBM2DDL_DATABASE_ACTION, + "create"); + assertThat(vendorProperties).containsEntry(SchemaToolingSettings.HBM2DDL_AUTO, "create-only"); })); } @@ -570,7 +574,7 @@ static class DisableBeanContainerConfiguration { @Bean HibernatePropertiesCustomizer disableBeanContainerHibernatePropertiesCustomizer() { - return (hibernateProperties) -> hibernateProperties.remove(AvailableSettings.BEAN_CONTAINER); + return (hibernateProperties) -> hibernateProperties.remove(ManagedBeanSettings.BEAN_CONTAINER); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java new file mode 100644 index 000000000000..7f8de3556175 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java @@ -0,0 +1,106 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import java.util.List; +import java.util.function.BiConsumer; + +import org.assertj.core.api.AssertDelegateTarget; +import org.mockito.InOrder; + +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +/** + * Test utility used to check customizers are called correctly. + * + * @param the customizer type + * @param the target class that is customized + * @author Phillip Webb + * @author Chris Bono + */ +final class Customizers { + + private final BiConsumer customizeAction; + + private final Class targetClass; + + @SuppressWarnings("unchecked") + private Customizers(Class targetClass, BiConsumer customizeAction) { + this.customizeAction = customizeAction; + this.targetClass = (Class) targetClass; + } + + /** + * Create an instance by getting the value from a field. + * @param source the source to extract the customizers from + * @param fieldName the field name + * @return a new {@link CustomizersAssert} instance + */ + @SuppressWarnings("unchecked") + CustomizersAssert fromField(Object source, String fieldName) { + return new CustomizersAssert(ReflectionTestUtils.getField(source, fieldName)); + } + + /** + * Create a new {@link Customizers} instance. + * @param the customizer class + * @param the target class that is customized + * @param targetClass the target class that is customized + * @param customizeAction the customizer action to take + * @return a new {@link Customizers} instance + */ + static Customizers of(Class targetClass, BiConsumer customizeAction) { + return new Customizers<>(targetClass, customizeAction); + } + + /** + * Assertions that can be applied to customizers. + */ + final class CustomizersAssert implements AssertDelegateTarget { + + private final List customizers; + + @SuppressWarnings("unchecked") + private CustomizersAssert(Object customizers) { + this.customizers = (customizers instanceof List) ? (List) customizers : List.of((C) customizers); + } + + /** + * Assert that the customize method is called in a specified order. It is expected + * that each customizer has set a unique value so the expected values can be used + * as a verify step. + * @param the value type + * @param call the call the customizer makes + * @param expectedValues the expected values + */ + @SuppressWarnings("unchecked") + void callsInOrder(BiConsumer call, V... expectedValues) { + T target = mock(Customizers.this.targetClass); + BiConsumer customizeAction = Customizers.this.customizeAction; + this.customizers.forEach((customizer) -> customizeAction.accept(customizer, target)); + InOrder ordered = inOrder(target); + for (V expectedValue : expectedValues) { + call.accept(ordered.verify(target), expectedValue); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java new file mode 100644 index 000000000000..afc18050f64b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link DeadLetterPolicyMapper}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class DeadLetterPolicyMapperTests { + + @Test + void map() { + PulsarProperties.Consumer.DeadLetterPolicy properties = new PulsarProperties.Consumer.DeadLetterPolicy(); + properties.setMaxRedeliverCount(100); + properties.setRetryLetterTopic("my-retry-topic"); + properties.setDeadLetterTopic("my-dlt-topic"); + properties.setInitialSubscriptionName("my-initial-subscription"); + DeadLetterPolicy policy = DeadLetterPolicyMapper.map(properties); + assertThat(policy.getMaxRedeliverCount()).isEqualTo(100); + assertThat(policy.getRetryLetterTopic()).isEqualTo("my-retry-topic"); + assertThat(policy.getDeadLetterTopic()).isEqualTo("my-dlt-topic"); + assertThat(policy.getInitialSubscriptionName()).isEqualTo("my-initial-subscription"); + } + + @Test + void mapWhenMaxRedeliverCountIsNotPositiveThrowsException() { + PulsarProperties.Consumer.DeadLetterPolicy properties = new PulsarProperties.Consumer.DeadLetterPolicy(); + properties.setMaxRedeliverCount(0); + assertThatIllegalStateException().isThrownBy(() -> DeadLetterPolicyMapper.map(properties)) + .withMessage("Pulsar DeadLetterPolicy must have a positive 'max-redelivery-count' property value"); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/MockAuthentication.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/MockAuthentication.java new file mode 100644 index 000000000000..33216b086bb7 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/MockAuthentication.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.apache.pulsar.client.api.Authentication; +import org.apache.pulsar.client.api.AuthenticationDataProvider; +import org.apache.pulsar.client.api.PulsarClientException; + +/** + * Test plugin-class-name for Authentication + * + * @author Swamy Mavuri + */ +@SuppressWarnings("deprecation") +public class MockAuthentication implements Authentication { + + public Map authParamsMap = new HashMap<>(); + + @Override + public String getAuthMethodName() { + return null; + } + + @Override + public AuthenticationDataProvider getAuthData() { + return null; + } + + @Override + public void configure(Map authParams) { + this.authParamsMap = authParams; + } + + @Override + public void start() throws PulsarClientException { + + } + + @Override + public void close() throws IOException { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetailsTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetailsTests.java new file mode 100644 index 000000000000..3abff9be7346 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PropertiesPulsarConnectionDetailsTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertiesPulsarConnectionDetails}. + * + * @author Chris Bono + */ +class PropertiesPulsarConnectionDetailsTests { + + @Test + void getClientServiceUrlReturnsValueFromProperties() { + PulsarProperties properties = new PulsarProperties(); + properties.getClient().setServiceUrl("foo"); + PulsarConnectionDetails connectionDetails = new PropertiesPulsarConnectionDetails(properties); + assertThat(connectionDetails.getBrokerUrl()).isEqualTo("foo"); + } + + @Test + void getAdminServiceHttpUrlReturnsValueFromProperties() { + PulsarProperties properties = new PulsarProperties(); + properties.getAdmin().setServiceUrl("foo"); + PulsarConnectionDetails connectionDetails = new PropertiesPulsarConnectionDetails(properties); + assertThat(connectionDetails.getAdminUrl()).isEqualTo("foo"); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java new file mode 100644 index 000000000000..14c7a37baf5f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.pulsar.client.api.PulsarClientException; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PulsarContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link PulsarAutoConfiguration}. + * + * @author Chris Bono + * @author Phillip Webb + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Testcontainers(disabledWithoutDocker = true) +class PulsarAutoConfigurationIntegrationTests { + + @Container + private static final PulsarContainer PULSAR_CONTAINER = new PulsarContainer(DockerImageNames.pulsar()) + .withStartupAttempts(2) + .withStartupTimeout(Duration.ofMinutes(3)); + + private static final CountDownLatch LISTEN_LATCH = new CountDownLatch(1); + + private static final String TOPIC = "pacit-hello-topic"; + + @DynamicPropertySource + static void pulsarProperties(DynamicPropertyRegistry registry) { + registry.add("spring.pulsar.client.service-url", PULSAR_CONTAINER::getPulsarBrokerUrl); + registry.add("spring.pulsar.admin.service-url", PULSAR_CONTAINER::getHttpServiceUrl); + } + + @Test + void appStartsWithAutoConfiguredSpringPulsarComponents( + @Autowired(required = false) PulsarTemplate pulsarTemplate) { + assertThat(pulsarTemplate).isNotNull(); + } + + @Test + void templateCanBeAccessedDuringWebRequest(@Autowired TestRestTemplate restTemplate) throws InterruptedException { + assertThat(restTemplate.getForObject("/hello", String.class)).startsWith("Hello World -> "); + assertThat(LISTEN_LATCH.await(5, TimeUnit.SECONDS)).isTrue(); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration({ DispatcherServletAutoConfiguration.class, ServletWebServerFactoryAutoConfiguration.class, + WebMvcAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, JacksonAutoConfiguration.class, + PulsarAutoConfiguration.class, PulsarReactiveAutoConfiguration.class }) + @Import(TestWebController.class) + static class TestConfiguration { + + @PulsarListener(subscriptionName = TOPIC + "-sub", topics = TOPIC) + void listen(String ignored) { + LISTEN_LATCH.countDown(); + } + + } + + @RestController + static class TestWebController { + + private final PulsarTemplate pulsarTemplate; + + TestWebController(PulsarTemplate pulsarTemplate) { + this.pulsarTemplate = pulsarTemplate; + } + + @GetMapping("/hello") + String sayHello() throws PulsarClientException { + return "Hello World -> " + this.pulsarTemplate.send(TOPIC, "hello"); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java new file mode 100644 index 000000000000..9cc201bbbdab --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java @@ -0,0 +1,566 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.ReaderBuilder; +import org.apache.pulsar.client.api.interceptor.ProducerInterceptor; +import org.apache.pulsar.common.schema.SchemaType; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.core.task.VirtualThreadTaskExecutor; +import org.springframework.pulsar.annotation.PulsarBootstrapConfiguration; +import org.springframework.pulsar.annotation.PulsarListenerAnnotationBeanPostProcessor; +import org.springframework.pulsar.annotation.PulsarReaderAnnotationBeanPostProcessor; +import org.springframework.pulsar.cache.provider.caffeine.CaffeineCacheProvider; +import org.springframework.pulsar.config.ConcurrentPulsarListenerContainerFactory; +import org.springframework.pulsar.config.DefaultPulsarReaderContainerFactory; +import org.springframework.pulsar.config.PulsarListenerContainerFactory; +import org.springframework.pulsar.config.PulsarListenerEndpointRegistry; +import org.springframework.pulsar.config.PulsarReaderEndpointRegistry; +import org.springframework.pulsar.core.CachingPulsarProducerFactory; +import org.springframework.pulsar.core.ConsumerBuilderCustomizer; +import org.springframework.pulsar.core.DefaultPulsarClientFactory; +import org.springframework.pulsar.core.DefaultPulsarConsumerFactory; +import org.springframework.pulsar.core.DefaultPulsarProducerFactory; +import org.springframework.pulsar.core.DefaultPulsarReaderFactory; +import org.springframework.pulsar.core.DefaultSchemaResolver; +import org.springframework.pulsar.core.DefaultTopicResolver; +import org.springframework.pulsar.core.ProducerBuilderCustomizer; +import org.springframework.pulsar.core.PulsarAdministration; +import org.springframework.pulsar.core.PulsarConsumerFactory; +import org.springframework.pulsar.core.PulsarProducerFactory; +import org.springframework.pulsar.core.PulsarReaderFactory; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.pulsar.core.ReaderBuilderCustomizer; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.TopicResolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarAutoConfiguration}. + * + * @author Chris Bono + * @author Alexander Preuß + * @author Soby Chacko + * @author Phillip Webb + */ +class PulsarAutoConfigurationTests { + + private static final String INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalPulsarListenerAnnotationProcessor"; + + private static final String INTERNAL_PULSAR_READER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalPulsarReaderAnnotationProcessor"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PulsarAutoConfiguration.class)) + .withBean(PulsarClient.class, () -> mock(PulsarClient.class)); + + @Test + void whenPulsarNotOnClasspathAutoConfigurationIsSkipped() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(PulsarClient.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarAutoConfiguration.class)); + } + + @Test + void whenSpringPulsarNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(PulsarTemplate.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarAutoConfiguration.class)); + } + + @Test + void whenCustomPulsarListenerAnnotationProcessorDefinedAutoConfigurationIsSkipped() { + this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR, String.class, () -> "bean") + .run((context) -> assertThat(context).doesNotHaveBean(PulsarBootstrapConfiguration.class)); + } + + @Test + void whenCustomPulsarReaderAnnotationProcessorDefinedAutoConfigurationIsSkipped() { + this.contextRunner.withBean(INTERNAL_PULSAR_READER_ANNOTATION_PROCESSOR, String.class, () -> "bean") + .run((context) -> assertThat(context).doesNotHaveBean(PulsarBootstrapConfiguration.class)); + } + + @Test + void autoConfiguresBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PulsarConfiguration.class) + .hasSingleBean(PulsarConnectionDetails.class) + .hasSingleBean(DefaultPulsarClientFactory.class) + .hasSingleBean(PulsarClient.class) + .hasSingleBean(PulsarAdministration.class) + .hasSingleBean(DefaultSchemaResolver.class) + .hasSingleBean(DefaultTopicResolver.class) + .hasSingleBean(CachingPulsarProducerFactory.class) + .hasSingleBean(PulsarTemplate.class) + .hasSingleBean(DefaultPulsarConsumerFactory.class) + .hasSingleBean(ConcurrentPulsarListenerContainerFactory.class) + .hasSingleBean(DefaultPulsarReaderFactory.class) + .hasSingleBean(DefaultPulsarReaderContainerFactory.class) + .hasSingleBean(PulsarListenerAnnotationBeanPostProcessor.class) + .hasSingleBean(PulsarListenerEndpointRegistry.class) + .hasSingleBean(PulsarReaderAnnotationBeanPostProcessor.class) + .hasSingleBean(PulsarReaderEndpointRegistry.class)); + } + + @Nested + class ProducerFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarProducerFactory producerFactory = mock(PulsarProducerFactory.class); + this.contextRunner + .withBean("customPulsarProducerFactory", PulsarProducerFactory.class, () -> producerFactory) + .run((context) -> assertThat(context).getBean(PulsarProducerFactory.class).isSameAs(producerFactory)); + } + + @Test + void whenNoPropertiesUsesCachingPulsarProducerFactory() { + this.contextRunner.run((context) -> assertThat(context).getBean(PulsarProducerFactory.class) + .isExactlyInstanceOf(CachingPulsarProducerFactory.class)); + } + + @Test + void whenCachingDisabledUsesDefaultPulsarProducerFactory() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=false") + .run((context) -> assertThat(context).getBean(PulsarProducerFactory.class) + .isExactlyInstanceOf(DefaultPulsarProducerFactory.class)); + } + + @Test + void whenCachingEnabledUsesCachingPulsarProducerFactory() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run((context) -> assertThat(context).getBean(PulsarProducerFactory.class) + .isExactlyInstanceOf(CachingPulsarProducerFactory.class)); + } + + @Test + void whenCachingEnabledAndCaffeineNotOnClasspathStillUsesCaffeine() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Caffeine.class)) + .withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run((context) -> { + assertThat(context).getBean(CachingPulsarProducerFactory.class) + .extracting("producerCache") + .extracting(Object::getClass) + .isEqualTo(CaffeineCacheProvider.class); + assertThat(context).getBean(CachingPulsarProducerFactory.class) + .extracting("producerCache.cache") + .extracting(Object::getClass) + .extracting(Class::getName) + .asString() + .startsWith("org.springframework.pulsar.shade.com.github.benmanes.caffeine.cache."); + }); + } + + @Test + void whenCustomCachingPropertiesCreatesConfiguredBean() { + this.contextRunner + .withPropertyValues("spring.pulsar.producer.cache.expire-after-access=100s", + "spring.pulsar.producer.cache.maximum-size=5150", + "spring.pulsar.producer.cache.initial-capacity=200") + .run((context) -> assertThat(context).getBean(CachingPulsarProducerFactory.class) + .extracting("producerCache.cache.cache") + .hasFieldOrPropertyWithValue("maximum", 5150L) + .hasFieldOrPropertyWithValue("expiresAfterAccessNanos", TimeUnit.SECONDS.toNanos(100))); + } + + @Test + void whenHasTopicNamePropertyCreatesConfiguredBean() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.topic-name=my-topic") + .run((context) -> assertThat(context).getBean(DefaultPulsarProducerFactory.class) + .hasFieldOrPropertyWithValue("defaultTopic", "my-topic")); + } + + @Test + void injectsExpectedBeans() { + this.contextRunner + .withPropertyValues("spring.pulsar.producer.topic-name=my-topic", + "spring.pulsar.producer.cache.enabled=false") + .run((context) -> assertThat(context).getBean(DefaultPulsarProducerFactory.class) + .hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class)) + .hasFieldOrPropertyWithValue("topicResolver", context.getBean(TopicResolver.class))); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void whenHasUserDefinedCustomizersAppliesInCorrectOrder(boolean cachingEnabled) { + this.contextRunner + .withPropertyValues("spring.pulsar.producer.cache.enabled=" + cachingEnabled, + "spring.pulsar.producer.name=fromPropsCustomizer") + .withUserConfiguration(ProducerBuilderCustomizersConfig.class) + .run((context) -> { + DefaultPulsarProducerFactory producerFactory = context + .getBean(DefaultPulsarProducerFactory.class); + Customizers, ProducerBuilder> customizers = Customizers + .of(ProducerBuilder.class, ProducerBuilderCustomizer::customize); + assertThat(customizers.fromField(producerFactory, "defaultConfigCustomizers")).callsInOrder( + ProducerBuilder::producerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ProducerBuilderCustomizersConfig { + + @Bean + @Order(200) + ProducerBuilderCustomizer customizerFoo() { + return (builder) -> builder.producerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ProducerBuilderCustomizer customizerBar() { + return (builder) -> builder.producerName("fromCustomizer1"); + } + + } + + } + + @Nested + class TemplateTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarTemplate template = mock(PulsarTemplate.class); + this.contextRunner.withBean("customPulsarTemplate", PulsarTemplate.class, () -> template) + .run((context) -> assertThat(context).getBean(PulsarTemplate.class).isSameAs(template)); + } + + @Test + void injectsExpectedBeans() { + PulsarProducerFactory producerFactory = mock(PulsarProducerFactory.class); + SchemaResolver schemaResolver = mock(SchemaResolver.class); + TopicResolver topicResolver = mock(TopicResolver.class); + this.contextRunner + .withBean("customPulsarProducerFactory", PulsarProducerFactory.class, () -> producerFactory) + .withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver) + .withBean("topicResolver", TopicResolver.class, () -> topicResolver) + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .hasFieldOrPropertyWithValue("producerFactory", producerFactory) + .hasFieldOrPropertyWithValue("schemaResolver", schemaResolver) + .hasFieldOrPropertyWithValue("topicResolver", topicResolver)); + } + + @Test + void whenHasUseDefinedProducerInterceptorInjectsBean() { + ProducerInterceptor interceptor = mock(ProducerInterceptor.class); + this.contextRunner.withBean("customProducerInterceptor", ProducerInterceptor.class, () -> interceptor) + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .extracting("interceptors") + .asInstanceOf(InstanceOfAssertFactories.LIST) + .contains(interceptor)); + } + + @Test + void whenHasUseDefinedProducerInterceptorsInjectsBeansInCorrectOrder() { + this.contextRunner.withUserConfiguration(InterceptorTestConfiguration.class) + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .extracting("interceptors") + .asInstanceOf(InstanceOfAssertFactories.LIST) + .containsExactly(context.getBean("interceptorBar"), context.getBean("interceptorFoo"))); + } + + @Test + void whenNoPropertiesEnablesObservation() { + this.contextRunner.run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .hasFieldOrPropertyWithValue("observationEnabled", true)); + } + + @Test + void whenObservationsEnabledEnablesObservation() { + this.contextRunner.withPropertyValues("spring.pulsar.template.observations-enabled=true") + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .hasFieldOrPropertyWithValue("observationEnabled", true)); + } + + @Test + void whenObservationsDisabledDoesNotEnableObservation() { + this.contextRunner.withPropertyValues("spring.pulsar.template.observations-enabled=false") + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .hasFieldOrPropertyWithValue("observationEnabled", false)); + } + + @Configuration(proxyBeanMethods = false) + static class InterceptorTestConfiguration { + + @Bean + @Order(200) + ProducerInterceptor interceptorFoo() { + return mock(ProducerInterceptor.class); + } + + @Bean + @Order(100) + ProducerInterceptor interceptorBar() { + return mock(ProducerInterceptor.class); + } + + } + + } + + @Nested + class ConsumerFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarConsumerFactory consumerFactory = mock(PulsarConsumerFactory.class); + this.contextRunner + .withBean("customPulsarConsumerFactory", PulsarConsumerFactory.class, () -> consumerFactory) + .run((context) -> assertThat(context).getBean(PulsarConsumerFactory.class).isSameAs(consumerFactory)); + } + + @Test + void injectsExpectedBeans() { + this.contextRunner.run((context) -> assertThat(context).getBean(DefaultPulsarConsumerFactory.class) + .hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class))); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.consumer.name=fromPropsCustomizer") + .withUserConfiguration(ConsumerBuilderCustomizersConfig.class) + .run((context) -> { + DefaultPulsarConsumerFactory consumerFactory = context + .getBean(DefaultPulsarConsumerFactory.class); + Customizers, ConsumerBuilder> customizers = Customizers + .of(ConsumerBuilder.class, ConsumerBuilderCustomizer::customize); + assertThat(customizers.fromField(consumerFactory, "defaultConfigCustomizers")).callsInOrder( + ConsumerBuilder::consumerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ConsumerBuilderCustomizersConfig { + + @Bean + @Order(200) + ConsumerBuilderCustomizer customizerFoo() { + return (builder) -> builder.consumerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ConsumerBuilderCustomizer customizerBar() { + return (builder) -> builder.consumerName("fromCustomizer1"); + } + + } + + } + + @Nested + class ListenerTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedListenerContainerFactoryBeanDoesNotAutoConfigureBean() { + PulsarListenerContainerFactory listenerContainerFactory = mock(PulsarListenerContainerFactory.class); + this.contextRunner + .withBean("pulsarListenerContainerFactory", PulsarListenerContainerFactory.class, + () -> listenerContainerFactory) + .run((context) -> assertThat(context).getBean(PulsarListenerContainerFactory.class) + .isSameAs(listenerContainerFactory)); + } + + @Test + @SuppressWarnings("rawtypes") + void injectsExpectedBeans() { + PulsarConsumerFactory consumerFactory = mock(PulsarConsumerFactory.class); + SchemaResolver schemaResolver = mock(SchemaResolver.class); + TopicResolver topicResolver = mock(TopicResolver.class); + this.contextRunner.withBean("pulsarConsumerFactory", PulsarConsumerFactory.class, () -> consumerFactory) + .withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver) + .withBean("topicResolver", TopicResolver.class, () -> topicResolver) + .run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class) + .hasFieldOrPropertyWithValue("consumerFactory", consumerFactory) + .extracting(ConcurrentPulsarListenerContainerFactory::getContainerProperties) + .hasFieldOrPropertyWithValue("schemaResolver", schemaResolver) + .hasFieldOrPropertyWithValue("topicResolver", topicResolver)); + } + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedListenerAnnotationBeanPostProcessorBeanDoesNotAutoConfigureBean() { + PulsarListenerAnnotationBeanPostProcessor listenerAnnotationBeanPostProcessor = mock( + PulsarListenerAnnotationBeanPostProcessor.class); + this.contextRunner + .withBean("org.springframework.pulsar.config.internalPulsarListenerAnnotationProcessor", + PulsarListenerAnnotationBeanPostProcessor.class, () -> listenerAnnotationBeanPostProcessor) + .run((context) -> assertThat(context).getBean(PulsarListenerAnnotationBeanPostProcessor.class) + .isSameAs(listenerAnnotationBeanPostProcessor)); + } + + @Test + void whenHasCustomProperties() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.listener.schema-type=avro"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)).run((context) -> { + ConcurrentPulsarListenerContainerFactory factory = context + .getBean(ConcurrentPulsarListenerContainerFactory.class); + assertThat(factory.getContainerProperties().getSchemaType()).isEqualTo(SchemaType.AVRO); + }); + } + + @Test + void whenNoPropertiesEnablesObservation() { + this.contextRunner + .run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class) + .hasFieldOrPropertyWithValue("containerProperties.observationEnabled", true)); + } + + @Test + void whenObservationsEnabledEnablesObservation() { + this.contextRunner.withPropertyValues("spring.pulsar.listener.observation-enabled=true") + .run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class) + .hasFieldOrPropertyWithValue("containerProperties.observationEnabled", true)); + } + + @Test + void whenObservationsDisabledDoesNotEnableObservation() { + this.contextRunner.withPropertyValues("spring.pulsar.listener.observation-enabled=false") + .run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class) + .hasFieldOrPropertyWithValue("containerProperties.observationEnabled", false)); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenVirtualThreadsAreEnabledOnJava21AndLaterListenerContainerShouldUseVirtualThreads() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + ConcurrentPulsarListenerContainerFactory factory = context + .getBean(ConcurrentPulsarListenerContainerFactory.class); + assertThat(factory.getContainerProperties().getConsumerTaskExecutor()) + .isInstanceOf(VirtualThreadTaskExecutor.class); + }); + } + + @Test + @EnabledForJreRange(max = JRE.JAVA_20) + void whenVirtualThreadsAreEnabledOnJava20AndEarlierListenerContainerShouldNotUseVirtualThreads() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + ConcurrentPulsarListenerContainerFactory factory = context + .getBean(ConcurrentPulsarListenerContainerFactory.class); + assertThat(factory.getContainerProperties().getConsumerTaskExecutor()).isNull(); + }); + } + + } + + @Nested + class ReaderFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarReaderFactory readerFactory = mock(PulsarReaderFactory.class); + this.contextRunner.withBean("customPulsarReaderFactory", PulsarReaderFactory.class, () -> readerFactory) + .run((context) -> assertThat(context).getBean(PulsarReaderFactory.class).isSameAs(readerFactory)); + } + + @Test + void injectsExpectedBeans() { + this.contextRunner.run((context) -> assertThat(context).getBean(DefaultPulsarReaderFactory.class) + .hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class))); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.reader.name=fromPropsCustomizer") + .withUserConfiguration(ReaderBuilderCustomizersConfig.class) + .run((context) -> { + DefaultPulsarReaderFactory readerFactory = context.getBean(DefaultPulsarReaderFactory.class); + Customizers, ReaderBuilder> customizers = Customizers + .of(ReaderBuilder.class, ReaderBuilderCustomizer::customize); + assertThat(customizers.fromField(readerFactory, "defaultConfigCustomizers")).callsInOrder( + ReaderBuilder::readerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2"); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenVirtualThreadsAreEnabledOnJava21AndLaterReaderShouldUseVirtualThreads() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + DefaultPulsarReaderContainerFactory factory = context + .getBean(DefaultPulsarReaderContainerFactory.class); + assertThat(factory.getContainerProperties().getReaderTaskExecutor()) + .isInstanceOf(VirtualThreadTaskExecutor.class); + }); + } + + @Test + @EnabledForJreRange(max = JRE.JAVA_20) + void whenVirtualThreadsAreEnabledOnJava20AndEarlierReaderShouldNotUseVirtualThreads() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + DefaultPulsarReaderContainerFactory factory = context + .getBean(DefaultPulsarReaderContainerFactory.class); + assertThat(factory.getContainerProperties().getReaderTaskExecutor()).isNull(); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ReaderBuilderCustomizersConfig { + + @Bean + @Order(200) + ReaderBuilderCustomizer customizerFoo() { + return (builder) -> builder.readerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ReaderBuilderCustomizer customizerBar() { + return (builder) -> builder.readerName("fromCustomizer1"); + } + + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java new file mode 100644 index 000000000000..7d604e7a820b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java @@ -0,0 +1,375 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.ClientBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.client.impl.AutoClusterFailover; +import org.apache.pulsar.common.schema.KeyValueEncodingType; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.api.InstanceOfAssertFactory; +import org.assertj.core.api.MapAssert; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.pulsar.core.DefaultPulsarClientFactory; +import org.springframework.pulsar.core.DefaultSchemaResolver; +import org.springframework.pulsar.core.DefaultTopicResolver; +import org.springframework.pulsar.core.PulsarAdminBuilderCustomizer; +import org.springframework.pulsar.core.PulsarAdministration; +import org.springframework.pulsar.core.PulsarClientBuilderCustomizer; +import org.springframework.pulsar.core.PulsarClientFactory; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.SchemaResolver.SchemaResolverCustomizer; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.function.PulsarFunctionAdministration; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarConfiguration}. + * + * @author Chris Bono + * @author Alexander Preuß + * @author Soby Chacko + * @author Phillip Webb + * @author Swamy Mavuri + */ +class PulsarConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PulsarConfiguration.class)) + .withBean(PulsarClient.class, () -> mock(PulsarClient.class)); + + @Test + void whenHasUserDefinedConnectionDetailsBeanDoesNotAutoConfigureBean() { + PulsarConnectionDetails customConnectionDetails = mock(PulsarConnectionDetails.class); + this.contextRunner + .withBean("customPulsarConnectionDetails", PulsarConnectionDetails.class, () -> customConnectionDetails) + .run((context) -> assertThat(context).getBean(PulsarConnectionDetails.class) + .isSameAs(customConnectionDetails)); + } + + @Nested + class ClientTests { + + @Test + void whenHasUserDefinedClientFactoryBeanDoesNotAutoConfigureBean() { + PulsarClientFactory customFactory = mock(PulsarClientFactory.class); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarConfiguration.class)) + .withBean("customPulsarClientFactory", PulsarClientFactory.class, () -> customFactory) + .run((context) -> assertThat(context).getBean(PulsarClientFactory.class).isSameAs(customFactory)); + } + + @Test + void whenHasUserDefinedClientBeanDoesNotAutoConfigureBean() { + PulsarClient customClient = mock(PulsarClient.class); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarConfiguration.class)) + .withBean("customPulsarClient", PulsarClient.class, () -> customClient) + .run((context) -> assertThat(context).getBean(PulsarClient.class).isSameAs(customClient)); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class); + given(connectionDetails.getBrokerUrl()).willReturn("connectiondetails"); + PulsarConfigurationTests.this.contextRunner + .withUserConfiguration(PulsarClientBuilderCustomizersConfig.class) + .withBean(PulsarConnectionDetails.class, () -> connectionDetails) + .withPropertyValues("spring.pulsar.client.service-url=properties") + .run((context) -> { + DefaultPulsarClientFactory clientFactory = context.getBean(DefaultPulsarClientFactory.class); + Customizers customizers = Customizers + .of(ClientBuilder.class, PulsarClientBuilderCustomizer::customize); + assertThat(customizers.fromField(clientFactory, "customizer")).callsInOrder( + ClientBuilder::serviceUrl, "connectiondetails", "fromCustomizer1", "fromCustomizer2"); + }); + } + + @Test + void whenHasUserDefinedFailoverPropertiesAddsToClient() { + PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class); + given(connectionDetails.getBrokerUrl()).willReturn("connectiondetails"); + PulsarConfigurationTests.this.contextRunner.withBean(PulsarConnectionDetails.class, () -> connectionDetails) + .withPropertyValues("spring.pulsar.client.service-url=properties", + "spring.pulsar.client.failover.backup-clusters[0].service-url=backup-cluster-1", + "spring.pulsar.client.failover.failover-delay=15s", + "spring.pulsar.client.failover.switch-back-delay=30s", + "spring.pulsar.client.failover.check-interval=5s", + "spring.pulsar.client.failover.backup-clusters[1].service-url=backup-cluster-2", + "spring.pulsar.client.failover.backup-clusters[1].authentication.plugin-class-name=org.springframework.boot.autoconfigure.pulsar.MockAuthentication", + "spring.pulsar.client.failover.backup-clusters[1].authentication.param.token=1234") + .run((context) -> { + DefaultPulsarClientFactory clientFactory = context.getBean(DefaultPulsarClientFactory.class); + PulsarProperties pulsarProperties = context.getBean(PulsarProperties.class); + ClientBuilder target = mock(ClientBuilder.class); + BiConsumer customizeAction = PulsarClientBuilderCustomizer::customize; + PulsarClientBuilderCustomizer pulsarClientBuilderCustomizer = (PulsarClientBuilderCustomizer) ReflectionTestUtils + .getField(clientFactory, "customizer"); + customizeAction.accept(pulsarClientBuilderCustomizer, target); + InOrder ordered = inOrder(target); + ordered.verify(target).serviceUrlProvider(Mockito.any(AutoClusterFailover.class)); + assertThat(pulsarProperties.getClient().getFailover().getFailOverDelay()) + .isEqualTo(Duration.ofSeconds(15)); + assertThat(pulsarProperties.getClient().getFailover().getSwitchBackDelay()) + .isEqualTo(Duration.ofSeconds(30)); + assertThat(pulsarProperties.getClient().getFailover().getCheckInterval()) + .isEqualTo(Duration.ofSeconds(5)); + assertThat(pulsarProperties.getClient().getFailover().getBackupClusters().size()).isEqualTo(2); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class PulsarClientBuilderCustomizersConfig { + + @Bean + @Order(200) + PulsarClientBuilderCustomizer customizerFoo() { + return (builder) -> builder.serviceUrl("fromCustomizer2"); + } + + @Bean + @Order(100) + PulsarClientBuilderCustomizer customizerBar() { + return (builder) -> builder.serviceUrl("fromCustomizer1"); + } + + } + + } + + @Nested + class AdministrationTests { + + private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarAdministration pulsarAdministration = mock(PulsarAdministration.class); + this.contextRunner + .withBean("customPulsarAdministration", PulsarAdministration.class, () -> pulsarAdministration) + .run((context) -> assertThat(context).getBean(PulsarAdministration.class) + .isSameAs(pulsarAdministration)); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class); + given(connectionDetails.getAdminUrl()).willReturn("connectiondetails"); + this.contextRunner.withUserConfiguration(PulsarAdminBuilderCustomizersConfig.class) + .withBean(PulsarConnectionDetails.class, () -> connectionDetails) + .withPropertyValues("spring.pulsar.admin.service-url=property") + .run((context) -> { + PulsarAdministration pulsarAdmin = context.getBean(PulsarAdministration.class); + Customizers customizers = Customizers + .of(PulsarAdminBuilder.class, PulsarAdminBuilderCustomizer::customize); + assertThat(customizers.fromField(pulsarAdmin, "adminCustomizers")).callsInOrder( + PulsarAdminBuilder::serviceHttpUrl, "connectiondetails", "fromCustomizer1", + "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class PulsarAdminBuilderCustomizersConfig { + + @Bean + @Order(200) + PulsarAdminBuilderCustomizer customizerFoo() { + return (builder) -> builder.serviceHttpUrl("fromCustomizer2"); + } + + @Bean + @Order(100) + PulsarAdminBuilderCustomizer customizerBar() { + return (builder) -> builder.serviceHttpUrl("fromCustomizer1"); + } + + } + + } + + @Nested + class SchemaResolverTests { + + @SuppressWarnings("rawtypes") + private static final InstanceOfAssertFactory> CLASS_SCHEMA_MAP = InstanceOfAssertFactories + .map(Class.class, Schema.class); + + private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + SchemaResolver schemaResolver = mock(SchemaResolver.class); + this.contextRunner.withBean("customSchemaResolver", SchemaResolver.class, () -> schemaResolver) + .run((context) -> assertThat(context).getBean(SchemaResolver.class).isSameAs(schemaResolver)); + } + + @Test + void whenHasUserDefinedSchemaResolverCustomizer() { + SchemaResolverCustomizer customizer = (schemaResolver) -> schemaResolver + .addCustomSchemaMapping(TestRecord.class, Schema.STRING); + this.contextRunner.withBean("schemaResolverCustomizer", SchemaResolverCustomizer.class, () -> customizer) + .run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class) + .extracting(DefaultSchemaResolver::getCustomSchemaMappings, InstanceOfAssertFactories.MAP) + .containsEntry(TestRecord.class, Schema.STRING)); + } + + @Test + void whenHasDefaultsTypeMappingForPrimitiveAddsToSchemaResolver() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME); + properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=STRING"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class) + .extracting(DefaultSchemaResolver::getCustomSchemaMappings, InstanceOfAssertFactories.MAP) + .containsOnly(entry(TestRecord.class, Schema.STRING))); + } + + @Test + void whenHasDefaultsTypeMappingForStructAddsToSchemaResolver() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME); + properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=JSON"); + Schema expectedSchema = Schema.JSON(TestRecord.class); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class) + .extracting(DefaultSchemaResolver::getCustomSchemaMappings, CLASS_SCHEMA_MAP) + .hasEntrySatisfying(TestRecord.class, schemaEqualTo(expectedSchema))); + } + + @Test + void whenHasDefaultsTypeMappingForKeyValueAddsToSchemaResolver() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME); + properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=key-value"); + properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type=java.lang.String"); + Schema expectedSchema = Schema.KeyValue(Schema.STRING, Schema.JSON(TestRecord.class), + KeyValueEncodingType.INLINE); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class) + .extracting(DefaultSchemaResolver::getCustomSchemaMappings, CLASS_SCHEMA_MAP) + .hasEntrySatisfying(TestRecord.class, schemaEqualTo(expectedSchema))); + } + + @SuppressWarnings("rawtypes") + private Consumer schemaEqualTo(Schema expected) { + return (actual) -> assertThat(actual.getSchemaInfo()).isEqualTo(expected.getSchemaInfo()); + } + + } + + @Nested + class TopicResolverTests { + + private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + TopicResolver topicResolver = mock(TopicResolver.class); + this.contextRunner.withBean("customTopicResolver", TopicResolver.class, () -> topicResolver) + .run((context) -> assertThat(context).getBean(TopicResolver.class).isSameAs(topicResolver)); + } + + @Test + void whenHasDefaultsTypeMappingAddsToSchemaResolver() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME); + properties.add("spring.pulsar.defaults.type-mappings[0].topic-name=foo-topic"); + properties.add("spring.pulsar.defaults.type-mappings[1].message-type=java.lang.String"); + properties.add("spring.pulsar.defaults.type-mappings[1].topic-name=string-topic"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(TopicResolver.class) + .asInstanceOf(InstanceOfAssertFactories.type(DefaultTopicResolver.class)) + .extracting(DefaultTopicResolver::getCustomTopicMappings, InstanceOfAssertFactories.MAP) + .containsOnly(entry(TestRecord.class, "foo-topic"), entry(String.class, "string-topic"))); + } + + } + + @Nested + class FunctionAdministrationTests { + + private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner; + + @Test + void whenNoPropertiesAddsFunctionAdministrationBean() { + this.contextRunner.run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class) + .hasFieldOrPropertyWithValue("failFast", Boolean.TRUE) + .hasFieldOrPropertyWithValue("propagateFailures", Boolean.TRUE) + .hasFieldOrPropertyWithValue("propagateStopFailures", Boolean.FALSE) + .hasNoNullFieldsOrProperties() // ensures object providers set + .extracting("pulsarAdministration") + .isSameAs(context.getBean(PulsarAdministration.class))); + } + + @Test + void whenHasFunctionPropertiesAppliesPropertiesToBean() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.function.fail-fast=false"); + properties.add("spring.pulsar.function.propagate-failures=false"); + properties.add("spring.pulsar.function.propagate-stop-failures=true"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class) + .hasFieldOrPropertyWithValue("failFast", Boolean.FALSE) + .hasFieldOrPropertyWithValue("propagateFailures", Boolean.FALSE) + .hasFieldOrPropertyWithValue("propagateStopFailures", Boolean.TRUE)); + } + + @Test + void whenHasFunctionDisabledPropertyDoesNotCreateBean() { + this.contextRunner.withPropertyValues("spring.pulsar.function.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(PulsarFunctionAdministration.class)); + } + + @Test + void whenHasCustomFunctionAdministrationBean() { + PulsarFunctionAdministration functionAdministration = mock(PulsarFunctionAdministration.class); + this.contextRunner.withBean(PulsarFunctionAdministration.class, () -> functionAdministration) + .run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class) + .isSameAs(functionAdministration)); + } + + } + + record TestRecord() { + + private static final String CLASS_NAME = TestRecord.class.getName(); + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java new file mode 100644 index 000000000000..e26b6ac35ce0 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java @@ -0,0 +1,249 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.AutoClusterFailoverBuilder.FailoverPolicy; +import org.apache.pulsar.client.api.ClientBuilder; +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerAccessMode; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClientException.UnsupportedAuthenticationException; +import org.apache.pulsar.client.api.ReaderBuilder; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.client.impl.AutoClusterFailover; +import org.apache.pulsar.common.schema.SchemaType; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Failover.BackupCluster; +import org.springframework.pulsar.listener.PulsarContainerProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarPropertiesMapper}. + * + * @author Chris Bono + * @author Phillip Webb + * @author Swamy Mavuri + */ +class PulsarPropertiesMapperTests { + + @Test + void customizeClientBuilderWhenHasNoAuthentication() { + PulsarProperties properties = new PulsarProperties(); + properties.getClient().setServiceUrl("https://example.com"); + properties.getClient().setConnectionTimeout(Duration.ofSeconds(1)); + properties.getClient().setOperationTimeout(Duration.ofSeconds(2)); + properties.getClient().setLookupTimeout(Duration.ofSeconds(3)); + ClientBuilder builder = mock(ClientBuilder.class); + new PulsarPropertiesMapper(properties).customizeClientBuilder(builder, + new PropertiesPulsarConnectionDetails(properties)); + then(builder).should().serviceUrl("https://example.com"); + then(builder).should().connectionTimeout(1000, TimeUnit.MILLISECONDS); + then(builder).should().operationTimeout(2000, TimeUnit.MILLISECONDS); + then(builder).should().lookupTimeout(3000, TimeUnit.MILLISECONDS); + } + + @Test + void customizeClientBuilderWhenHasAuthentication() throws UnsupportedAuthenticationException { + PulsarProperties properties = new PulsarProperties(); + Map params = Map.of("param", "name"); + String authParamString = "{\"param\":\"name\"}"; + properties.getClient().getAuthentication().setPluginClassName("myclass"); + properties.getClient().getAuthentication().setParam(params); + ClientBuilder builder = mock(ClientBuilder.class); + new PulsarPropertiesMapper(properties).customizeClientBuilder(builder, + new PropertiesPulsarConnectionDetails(properties)); + then(builder).should().authentication("myclass", authParamString); + } + + @Test + void customizeClientBuilderWhenHasConnectionDetails() { + PulsarProperties properties = new PulsarProperties(); + properties.getClient().setServiceUrl("https://ignored.example.com"); + ClientBuilder builder = mock(ClientBuilder.class); + PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class); + given(connectionDetails.getBrokerUrl()).willReturn("https://used.example.com"); + new PulsarPropertiesMapper(properties).customizeClientBuilder(builder, connectionDetails); + then(builder).should().serviceUrl("https://used.example.com"); + } + + @Test + void customizeClientBuilderWhenHasFailover() { + BackupCluster backupCluster1 = new BackupCluster(); + backupCluster1.setServiceUrl("backup-cluster-1"); + Map params = Map.of("param", "name"); + backupCluster1.getAuthentication() + .setPluginClassName("org.springframework.boot.autoconfigure.pulsar.MockAuthentication"); + backupCluster1.getAuthentication().setParam(params); + BackupCluster backupCluster2 = new BackupCluster(); + backupCluster2.setServiceUrl("backup-cluster-2"); + PulsarProperties properties = new PulsarProperties(); + properties.getClient().setServiceUrl("https://used.example.com"); + properties.getClient().getFailover().setFailoverPolicy(FailoverPolicy.ORDER); + properties.getClient().getFailover().setCheckInterval(Duration.ofSeconds(5)); + properties.getClient().getFailover().setFailOverDelay(Duration.ofSeconds(30)); + properties.getClient().getFailover().setSwitchBackDelay(Duration.ofSeconds(30)); + properties.getClient().getFailover().setBackupClusters(List.of(backupCluster1, backupCluster2)); + PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class); + given(connectionDetails.getBrokerUrl()).willReturn("https://used.example.com"); + ClientBuilder builder = mock(ClientBuilder.class); + new PulsarPropertiesMapper(properties).customizeClientBuilder(builder, + new PropertiesPulsarConnectionDetails(properties)); + then(builder).should().serviceUrlProvider(Mockito.any(AutoClusterFailover.class)); + } + + @Test + void customizeAdminBuilderWhenHasNoAuthentication() { + PulsarProperties properties = new PulsarProperties(); + properties.getAdmin().setServiceUrl("https://example.com"); + properties.getAdmin().setConnectionTimeout(Duration.ofSeconds(1)); + properties.getAdmin().setReadTimeout(Duration.ofSeconds(2)); + properties.getAdmin().setRequestTimeout(Duration.ofSeconds(3)); + PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class); + new PulsarPropertiesMapper(properties).customizeAdminBuilder(builder, + new PropertiesPulsarConnectionDetails(properties)); + then(builder).should().serviceHttpUrl("https://example.com"); + then(builder).should().connectionTimeout(1000, TimeUnit.MILLISECONDS); + then(builder).should().readTimeout(2000, TimeUnit.MILLISECONDS); + then(builder).should().requestTimeout(3000, TimeUnit.MILLISECONDS); + } + + @Test + void customizeAdminBuilderWhenHasAuthentication() throws UnsupportedAuthenticationException { + PulsarProperties properties = new PulsarProperties(); + Map params = Map.of("param", "name"); + String authParamString = "{\"param\":\"name\"}"; + properties.getAdmin().getAuthentication().setPluginClassName("myclass"); + properties.getAdmin().getAuthentication().setParam(params); + PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class); + new PulsarPropertiesMapper(properties).customizeAdminBuilder(builder, + new PropertiesPulsarConnectionDetails(properties)); + then(builder).should().authentication("myclass", authParamString); + } + + @Test + void customizeAdminBuilderWhenHasConnectionDetails() { + PulsarProperties properties = new PulsarProperties(); + properties.getAdmin().setServiceUrl("https://ignored.example.com"); + PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class); + PulsarConnectionDetails connectionDetails = mock(PulsarConnectionDetails.class); + given(connectionDetails.getAdminUrl()).willReturn("https://used.example.com"); + new PulsarPropertiesMapper(properties).customizeAdminBuilder(builder, connectionDetails); + then(builder).should().serviceHttpUrl("https://used.example.com"); + } + + @Test + @SuppressWarnings("unchecked") + void customizeProducerBuilder() { + PulsarProperties properties = new PulsarProperties(); + properties.getProducer().setName("name"); + properties.getProducer().setTopicName("topicname"); + properties.getProducer().setSendTimeout(Duration.ofSeconds(1)); + properties.getProducer().setMessageRoutingMode(MessageRoutingMode.RoundRobinPartition); + properties.getProducer().setHashingScheme(HashingScheme.JavaStringHash); + properties.getProducer().setBatchingEnabled(false); + properties.getProducer().setChunkingEnabled(true); + properties.getProducer().setCompressionType(CompressionType.SNAPPY); + properties.getProducer().setAccessMode(ProducerAccessMode.Exclusive); + ProducerBuilder builder = mock(ProducerBuilder.class); + new PulsarPropertiesMapper(properties).customizeProducerBuilder(builder); + then(builder).should().producerName("name"); + then(builder).should().topic("topicname"); + then(builder).should().sendTimeout(1000, TimeUnit.MILLISECONDS); + then(builder).should().messageRoutingMode(MessageRoutingMode.RoundRobinPartition); + then(builder).should().hashingScheme(HashingScheme.JavaStringHash); + then(builder).should().enableBatching(false); + then(builder).should().enableChunking(true); + then(builder).should().compressionType(CompressionType.SNAPPY); + then(builder).should().accessMode(ProducerAccessMode.Exclusive); + } + + @Test + @SuppressWarnings("unchecked") + void customizeConsumerBuilder() { + PulsarProperties properties = new PulsarProperties(); + List topics = List.of("mytopic"); + Pattern topisPattern = Pattern.compile("my-pattern"); + properties.getConsumer().setName("name"); + properties.getConsumer().setTopics(topics); + properties.getConsumer().setTopicsPattern(topisPattern); + properties.getConsumer().setPriorityLevel(123); + properties.getConsumer().setReadCompacted(true); + Consumer.DeadLetterPolicy deadLetterPolicy = new Consumer.DeadLetterPolicy(); + deadLetterPolicy.setDeadLetterTopic("my-dlt"); + deadLetterPolicy.setMaxRedeliverCount(1); + properties.getConsumer().setDeadLetterPolicy(deadLetterPolicy); + ConsumerBuilder builder = mock(ConsumerBuilder.class); + new PulsarPropertiesMapper(properties).customizeConsumerBuilder(builder); + then(builder).should().consumerName("name"); + then(builder).should().topics(topics); + then(builder).should().topicsPattern(topisPattern); + then(builder).should().priorityLevel(123); + then(builder).should().readCompacted(true); + then(builder).should().deadLetterPolicy(new DeadLetterPolicy(1, null, "my-dlt", null)); + } + + @Test + void customizeContainerProperties() { + PulsarProperties properties = new PulsarProperties(); + properties.getConsumer().getSubscription().setType(SubscriptionType.Shared); + properties.getListener().setSchemaType(SchemaType.AVRO); + properties.getListener().setObservationEnabled(false); + PulsarContainerProperties containerProperties = new PulsarContainerProperties("my-topic-pattern"); + new PulsarPropertiesMapper(properties).customizeContainerProperties(containerProperties); + assertThat(containerProperties.getSubscriptionType()).isEqualTo(SubscriptionType.Shared); + assertThat(containerProperties.getSchemaType()).isEqualTo(SchemaType.AVRO); + assertThat(containerProperties.isObservationEnabled()).isFalse(); + } + + @Test + @SuppressWarnings("unchecked") + void customizeReaderBuilder() { + PulsarProperties properties = new PulsarProperties(); + List topics = List.of("mytopic"); + properties.getReader().setName("name"); + properties.getReader().setTopics(topics); + properties.getReader().setSubscriptionName("subname"); + properties.getReader().setSubscriptionRolePrefix("subroleprefix"); + properties.getReader().setReadCompacted(true); + ReaderBuilder builder = mock(ReaderBuilder.class); + new PulsarPropertiesMapper(properties).customizeReaderBuilder(builder); + then(builder).should().readerName("name"); + then(builder).should().topics(topics); + then(builder).should().subscriptionName("subname"); + then(builder).should().subscriptionRolePrefix("subroleprefix"); + then(builder).should().readCompacted(true); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java new file mode 100644 index 000000000000..204676956152 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java @@ -0,0 +1,399 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerAccessMode; +import org.apache.pulsar.client.api.RegexSubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.SubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.schema.SchemaType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.SchemaInfo; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.TypeMapping; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Failover; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Failover.BackupCluster; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link PulsarProperties}. + * + * @author Chris Bono + * @author Christophe Bornet + * @author Soby Chacko + * @author Phillip Webb + * @author Swamy Mavuri + */ +class PulsarPropertiesTests { + + private PulsarProperties bindPropeties(Map map) { + return new Binder(new MapConfigurationPropertySource(map)).bind("spring.pulsar", PulsarProperties.class).get(); + } + + @Nested + class ClientProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.client.service-url", "my-service-url"); + map.put("spring.pulsar.client.operation-timeout", "1s"); + map.put("spring.pulsar.client.lookup-timeout", "2s"); + map.put("spring.pulsar.client.connection-timeout", "12s"); + PulsarProperties.Client properties = bindPropeties(map).getClient(); + assertThat(properties.getServiceUrl()).isEqualTo("my-service-url"); + assertThat(properties.getOperationTimeout()).isEqualTo(Duration.ofMillis(1000)); + assertThat(properties.getLookupTimeout()).isEqualTo(Duration.ofMillis(2000)); + assertThat(properties.getConnectionTimeout()).isEqualTo(Duration.ofMillis(12000)); + } + + @Test + void bindAuthentication() { + Map map = new HashMap<>(); + map.put("spring.pulsar.client.authentication.plugin-class-name", "com.example.MyAuth"); + map.put("spring.pulsar.client.authentication.param.token", "1234"); + PulsarProperties.Client properties = bindPropeties(map).getClient(); + assertThat(properties.getAuthentication().getPluginClassName()).isEqualTo("com.example.MyAuth"); + assertThat(properties.getAuthentication().getParam()).containsEntry("token", "1234"); + } + + @Test + void bindFailover() { + Map map = new HashMap<>(); + map.put("spring.pulsar.client.service-url", "my-service-url"); + map.put("spring.pulsar.client.failover.failover-delay", "30s"); + map.put("spring.pulsar.client.failover.switch-back-delay", "15s"); + map.put("spring.pulsar.client.failover.check-interval", "1s"); + map.put("spring.pulsar.client.failover.backup-clusters[0].service-url", "backup-service-url-1"); + map.put("spring.pulsar.client.failover.backup-clusters[0].authentication.plugin-class-name", + "com.example.MyAuth1"); + map.put("spring.pulsar.client.failover.backup-clusters[0].authentication.param.token", "1234"); + map.put("spring.pulsar.client.failover.backup-clusters[1].service-url", "backup-service-url-2"); + map.put("spring.pulsar.client.failover.backup-clusters[1].authentication.plugin-class-name", + "com.example.MyAuth2"); + map.put("spring.pulsar.client.failover.backup-clusters[1].authentication.param.token", "5678"); + PulsarProperties.Client properties = bindPropeties(map).getClient(); + Failover failoverProperties = properties.getFailover(); + List backupClusters = properties.getFailover().getBackupClusters(); + assertThat(properties.getServiceUrl()).isEqualTo("my-service-url"); + assertThat(failoverProperties.getFailOverDelay()).isEqualTo(Duration.ofMillis(30000)); + assertThat(failoverProperties.getSwitchBackDelay()).isEqualTo(Duration.ofMillis(15000)); + assertThat(failoverProperties.getCheckInterval()).isEqualTo(Duration.ofMillis(1000)); + assertThat(backupClusters.get(0).getServiceUrl()).isEqualTo("backup-service-url-1"); + assertThat(backupClusters.get(0).getAuthentication().getPluginClassName()).isEqualTo("com.example.MyAuth1"); + assertThat(backupClusters.get(0).getAuthentication().getParam()).containsEntry("token", "1234"); + assertThat(backupClusters.get(1).getServiceUrl()).isEqualTo("backup-service-url-2"); + assertThat(backupClusters.get(1).getAuthentication().getPluginClassName()).isEqualTo("com.example.MyAuth2"); + assertThat(backupClusters.get(1).getAuthentication().getParam()).containsEntry("token", "5678"); + } + + } + + @Nested + class AdminProperties { + + private final String authPluginClassName = "org.apache.pulsar.client.impl.auth.AuthenticationToken"; + + private final String authToken = "1234"; + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.admin.service-url", "my-service-url"); + map.put("spring.pulsar.admin.connection-timeout", "12s"); + map.put("spring.pulsar.admin.read-timeout", "13s"); + map.put("spring.pulsar.admin.request-timeout", "14s"); + PulsarProperties.Admin properties = bindPropeties(map).getAdmin(); + assertThat(properties.getServiceUrl()).isEqualTo("my-service-url"); + assertThat(properties.getConnectionTimeout()).isEqualTo(Duration.ofSeconds(12)); + assertThat(properties.getReadTimeout()).isEqualTo(Duration.ofSeconds(13)); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(14)); + } + + @Test + void bindAuthentication() { + Map map = new HashMap<>(); + map.put("spring.pulsar.admin.authentication.plugin-class-name", this.authPluginClassName); + map.put("spring.pulsar.admin.authentication.param.token", this.authToken); + PulsarProperties.Admin properties = bindPropeties(map).getAdmin(); + assertThat(properties.getAuthentication().getPluginClassName()).isEqualTo(this.authPluginClassName); + assertThat(properties.getAuthentication().getParam()).containsEntry("token", this.authToken); + } + + } + + @Nested + class DefaultsProperties { + + @Test + void bindWhenNoTypeMappings() { + assertThat(new PulsarProperties().getDefaults().getTypeMappings()).isEmpty(); + } + + @Test + void bindWhenTypeMappingsWithTopicsOnly() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].topic-name", "foo-topic"); + map.put("spring.pulsar.defaults.type-mappings[1].message-type", String.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[1].topic-name", "string-topic"); + PulsarProperties.Defaults properties = bindPropeties(map).getDefaults(); + TypeMapping expectedTopic1 = new TypeMapping(TestMessage.class, "foo-topic", null); + TypeMapping expectedTopic2 = new TypeMapping(String.class, "string-topic", null); + assertThat(properties.getTypeMappings()).containsExactly(expectedTopic1, expectedTopic2); + } + + @Test + void bindWhenTypeMappingsWithSchemaOnly() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON"); + PulsarProperties.Defaults properties = bindPropeties(map).getDefaults(); + TypeMapping expected = new TypeMapping(TestMessage.class, null, new SchemaInfo(SchemaType.JSON, null)); + assertThat(properties.getTypeMappings()).containsExactly(expected); + } + + @Test + void bindWhenTypeMappingsWithTopicAndSchema() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].topic-name", "foo-topic"); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON"); + PulsarProperties.Defaults properties = bindPropeties(map).getDefaults(); + TypeMapping expected = new TypeMapping(TestMessage.class, "foo-topic", + new SchemaInfo(SchemaType.JSON, null)); + assertThat(properties.getTypeMappings()).containsExactly(expected); + } + + @Test + void bindWhenTypeMappingsWithKeyValueSchema() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "KEY_VALUE"); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName()); + PulsarProperties.Defaults properties = bindPropeties(map).getDefaults(); + TypeMapping expected = new TypeMapping(TestMessage.class, null, + new SchemaInfo(SchemaType.KEY_VALUE, String.class)); + assertThat(properties.getTypeMappings()).containsExactly(expected); + } + + @Test + void bindWhenNoSchemaThrowsException() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName()); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map)) + .havingRootCause() + .withMessageContaining("schemaType must not be null"); + } + + @Test + void bindWhenSchemaTypeNoneThrowsException() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "NONE"); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map)) + .havingRootCause() + .withMessageContaining("schemaType 'NONE' not supported"); + } + + @Test + void bindWhenMessageKeyTypeSetOnNonKeyValueSchemaThrowsException() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON"); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName()); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map)) + .havingRootCause() + .withMessageContaining("messageKeyType can only be set when schemaType is KEY_VALUE"); + } + + record TestMessage(String value) { + } + + } + + @Nested + class FunctionProperties { + + @Test + void defaults() { + PulsarProperties.Function properties = new PulsarProperties.Function(); + assertThat(properties.isFailFast()).isTrue(); + assertThat(properties.isPropagateFailures()).isTrue(); + assertThat(properties.isPropagateStopFailures()).isFalse(); + } + + @Test + void bind() { + Map props = new HashMap<>(); + props.put("spring.pulsar.function.fail-fast", "false"); + props.put("spring.pulsar.function.propagate-failures", "false"); + props.put("spring.pulsar.function.propagate-stop-failures", "true"); + PulsarProperties.Function properties = bindPropeties(props).getFunction(); + assertThat(properties.isFailFast()).isFalse(); + assertThat(properties.isPropagateFailures()).isFalse(); + assertThat(properties.isPropagateStopFailures()).isTrue(); + } + + } + + @Nested + class ProducerProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.producer.name", "my-producer"); + map.put("spring.pulsar.producer.topic-name", "my-topic"); + map.put("spring.pulsar.producer.send-timeout", "2s"); + map.put("spring.pulsar.producer.message-routing-mode", "custompartition"); + map.put("spring.pulsar.producer.hashing-scheme", "murmur3_32hash"); + map.put("spring.pulsar.producer.batching-enabled", "false"); + map.put("spring.pulsar.producer.chunking-enabled", "true"); + map.put("spring.pulsar.producer.compression-type", "lz4"); + map.put("spring.pulsar.producer.access-mode", "exclusive"); + map.put("spring.pulsar.producer.cache.expire-after-access", "2s"); + map.put("spring.pulsar.producer.cache.maximum-size", "3"); + map.put("spring.pulsar.producer.cache.initial-capacity", "5"); + PulsarProperties.Producer properties = bindPropeties(map).getProducer(); + assertThat(properties.getName()).isEqualTo("my-producer"); + assertThat(properties.getTopicName()).isEqualTo("my-topic"); + assertThat(properties.getSendTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getMessageRoutingMode()).isEqualTo(MessageRoutingMode.CustomPartition); + assertThat(properties.getHashingScheme()).isEqualTo(HashingScheme.Murmur3_32Hash); + assertThat(properties.isBatchingEnabled()).isFalse(); + assertThat(properties.isChunkingEnabled()).isTrue(); + assertThat(properties.getCompressionType()).isEqualTo(CompressionType.LZ4); + assertThat(properties.getAccessMode()).isEqualTo(ProducerAccessMode.Exclusive); + assertThat(properties.getCache().getExpireAfterAccess()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getCache().getMaximumSize()).isEqualTo(3); + assertThat(properties.getCache().getInitialCapacity()).isEqualTo(5); + } + + } + + @Nested + class ConsumerPropertiesTests { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.consumer.name", "my-consumer"); + map.put("spring.pulsar.consumer.subscription.initial-position", "earliest"); + map.put("spring.pulsar.consumer.subscription.mode", "nondurable"); + map.put("spring.pulsar.consumer.subscription.name", "my-subscription"); + map.put("spring.pulsar.consumer.subscription.topics-mode", "all-topics"); + map.put("spring.pulsar.consumer.subscription.type", "shared"); + map.put("spring.pulsar.consumer.topics[0]", "my-topic"); + map.put("spring.pulsar.consumer.topics-pattern", "my-pattern"); + map.put("spring.pulsar.consumer.priority-level", "8"); + map.put("spring.pulsar.consumer.read-compacted", "true"); + map.put("spring.pulsar.consumer.dead-letter-policy.max-redeliver-count", "4"); + map.put("spring.pulsar.consumer.dead-letter-policy.retry-letter-topic", "my-retry-topic"); + map.put("spring.pulsar.consumer.dead-letter-policy.dead-letter-topic", "my-dlt-topic"); + map.put("spring.pulsar.consumer.dead-letter-policy.initial-subscription-name", "my-initial-subscription"); + map.put("spring.pulsar.consumer.retry-enable", "true"); + PulsarProperties.Consumer properties = bindPropeties(map).getConsumer(); + assertThat(properties.getName()).isEqualTo("my-consumer"); + assertThat(properties.getSubscription()).satisfies((subscription) -> { + assertThat(subscription.getName()).isEqualTo("my-subscription"); + assertThat(subscription.getType()).isEqualTo(SubscriptionType.Shared); + assertThat(subscription.getMode()).isEqualTo(SubscriptionMode.NonDurable); + assertThat(subscription.getInitialPosition()).isEqualTo(SubscriptionInitialPosition.Earliest); + assertThat(subscription.getTopicsMode()).isEqualTo(RegexSubscriptionMode.AllTopics); + }); + assertThat(properties.getTopics()).containsExactly("my-topic"); + assertThat(properties.getTopicsPattern().toString()).isEqualTo("my-pattern"); + assertThat(properties.getPriorityLevel()).isEqualTo(8); + assertThat(properties.isReadCompacted()).isTrue(); + assertThat(properties.getDeadLetterPolicy()).satisfies((policy) -> { + assertThat(policy.getMaxRedeliverCount()).isEqualTo(4); + assertThat(policy.getRetryLetterTopic()).isEqualTo("my-retry-topic"); + assertThat(policy.getDeadLetterTopic()).isEqualTo("my-dlt-topic"); + assertThat(policy.getInitialSubscriptionName()).isEqualTo("my-initial-subscription"); + }); + assertThat(properties.isRetryEnable()).isTrue(); + } + + } + + @Nested + class ListenerProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.listener.schema-type", "avro"); + map.put("spring.pulsar.listener.observation-enabled", "false"); + PulsarProperties.Listener properties = bindPropeties(map).getListener(); + assertThat(properties.getSchemaType()).isEqualTo(SchemaType.AVRO); + assertThat(properties.isObservationEnabled()).isFalse(); + } + + } + + @Nested + class ReaderProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.reader.name", "my-reader"); + map.put("spring.pulsar.reader.topics", "my-topic"); + map.put("spring.pulsar.reader.subscription-name", "my-subscription"); + map.put("spring.pulsar.reader.subscription-role-prefix", "sub-role"); + map.put("spring.pulsar.reader.read-compacted", "true"); + PulsarProperties.Reader properties = bindPropeties(map).getReader(); + assertThat(properties.getName()).isEqualTo("my-reader"); + assertThat(properties.getTopics()).containsExactly("my-topic"); + assertThat(properties.getSubscriptionName()).isEqualTo("my-subscription"); + assertThat(properties.getSubscriptionRolePrefix()).isEqualTo("sub-role"); + assertThat(properties.isReadCompacted()).isTrue(); + } + + } + + @Nested + class TemplateProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.template.observations-enabled", "false"); + PulsarProperties.Template properties = bindPropeties(map).getTemplate(); + assertThat(properties.isObservationsEnabled()).isFalse(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java new file mode 100644 index 000000000000..4f3ab011ea2e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java @@ -0,0 +1,473 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.common.schema.SchemaType; +import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider; +import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache; +import org.apache.pulsar.reactive.client.api.ReactivePulsarClient; +import org.apache.pulsar.reactive.client.producercache.CaffeineShadedProducerCacheProvider; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.pulsar.core.DefaultSchemaResolver; +import org.springframework.pulsar.core.DefaultTopicResolver; +import org.springframework.pulsar.core.PulsarAdministration; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.reactive.config.DefaultReactivePulsarListenerContainerFactory; +import org.springframework.pulsar.reactive.config.ReactivePulsarListenerContainerFactory; +import org.springframework.pulsar.reactive.config.ReactivePulsarListenerEndpointRegistry; +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarBootstrapConfiguration; +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListenerAnnotationBeanPostProcessor; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarConsumerFactory; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarReaderFactory; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarSenderFactory; +import org.springframework.pulsar.reactive.core.ReactiveMessageConsumerBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactiveMessageSenderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactivePulsarConsumerFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarSenderFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate; +import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarReactiveAutoConfiguration}. + * + * @author Chris Bono + * @author Christophe Bornet + * @author Phillip Webb + */ +class PulsarReactiveAutoConfigurationTests { + + private static final String INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalReactivePulsarListenerAnnotationProcessor"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PulsarReactiveAutoConfiguration.class)) + .withBean(PulsarClient.class, () -> mock(PulsarClient.class)); + + @Test + void whenPulsarNotOnClasspathAutoConfigurationIsSkipped() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarReactiveAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(PulsarClient.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class)); + } + + @Test + void whenReactivePulsarNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ReactivePulsarClient.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class)); + } + + @Test + void whenReactiveSpringPulsarNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ReactivePulsarTemplate.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class)); + } + + @Test + void whenCustomPulsarListenerAnnotationProcessorDefinedAutoConfigurationIsSkipped() { + this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR, String.class, () -> "bean") + .run((context) -> assertThat(context).doesNotHaveBean(ReactivePulsarBootstrapConfiguration.class)); + } + + @Test + void autoConfiguresBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PulsarConfiguration.class) + .hasSingleBean(PulsarClient.class) + .hasSingleBean(PulsarAdministration.class) + .hasSingleBean(DefaultSchemaResolver.class) + .hasSingleBean(DefaultTopicResolver.class) + .hasSingleBean(ReactivePulsarClient.class) + .hasSingleBean(CaffeineShadedProducerCacheProvider.class) + .hasSingleBean(ReactiveMessageSenderCache.class) + .hasSingleBean(DefaultReactivePulsarSenderFactory.class) + .hasSingleBean(ReactivePulsarTemplate.class) + .hasSingleBean(DefaultReactivePulsarConsumerFactory.class) + .hasSingleBean(DefaultReactivePulsarListenerContainerFactory.class) + .hasSingleBean(ReactivePulsarListenerAnnotationBeanPostProcessor.class) + .hasSingleBean(ReactivePulsarListenerEndpointRegistry.class)); + } + + @Test + @SuppressWarnings("rawtypes") + void injectsExpectedBeansIntoReactivePulsarClient() { + this.contextRunner.run((context) -> { + PulsarClient pulsarClient = context.getBean(PulsarClient.class); + assertThat(context).hasNotFailed() + .getBean(ReactivePulsarClient.class) + .extracting("reactivePulsarResourceAdapter") + .extracting("pulsarClientSupplier", InstanceOfAssertFactories.type(Supplier.class)) + .extracting(Supplier::get) + .isSameAs(pulsarClient); + }); + } + + @ParameterizedTest + @ValueSource(classes = { ReactivePulsarClient.class, ProducerCacheProvider.class, ReactiveMessageSenderCache.class, + ReactivePulsarSenderFactory.class, ReactivePulsarConsumerFactory.class, ReactivePulsarReaderFactory.class, + ReactivePulsarTemplate.class }) + void whenHasUserDefinedBeanDoesNotAutoConfigureBean(Class beanClass) { + T bean = mock(beanClass); + this.contextRunner.withBean(beanClass.getName(), beanClass, () -> bean) + .run((context) -> assertThat(context).getBean(beanClass).isSameAs(bean)); + } + + @Nested + class SenderFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void injectsExpectedBeans() { + ReactivePulsarClient client = mock(ReactivePulsarClient.class); + ReactiveMessageSenderCache cache = mock(ReactiveMessageSenderCache.class); + this.contextRunner.withPropertyValues("spring.pulsar.producer.topic-name=test-topic") + .withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client) + .withBean("customReactiveMessageSenderCache", ReactiveMessageSenderCache.class, () -> cache) + .run((context) -> { + DefaultReactivePulsarSenderFactory senderFactory = context + .getBean(DefaultReactivePulsarSenderFactory.class); + assertThat(senderFactory) + .extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class)) + .isSameAs(client); + assertThat(senderFactory) + .extracting("reactiveMessageSenderCache", + InstanceOfAssertFactories.type(ReactiveMessageSenderCache.class)) + .isSameAs(cache); + assertThat(senderFactory) + .extracting("topicResolver", InstanceOfAssertFactories.type(TopicResolver.class)) + .isSameAs(context.getBean(TopicResolver.class)); + }); + } + + @Test + void injectsExpectedBeansIntoReactiveMessageSenderCache() { + ProducerCacheProvider provider = mock(ProducerCacheProvider.class); + this.contextRunner.withBean("customProducerCacheProvider", ProducerCacheProvider.class, () -> provider) + .run((context) -> assertThat(context).getBean(ReactiveMessageSenderCache.class) + .extracting("cacheProvider", InstanceOfAssertFactories.type(ProducerCacheProvider.class)) + .isSameAs(provider)); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.name=fromPropsCustomizer") + .withUserConfiguration(ReactiveMessageSenderBuilderCustomizerConfig.class) + .run((context) -> { + DefaultReactivePulsarSenderFactory producerFactory = context + .getBean(DefaultReactivePulsarSenderFactory.class); + Customizers, ReactiveMessageSenderBuilder> customizers = Customizers + .of(ReactiveMessageSenderBuilder.class, ReactiveMessageSenderBuilderCustomizer::customize); + assertThat(customizers.fromField(producerFactory, "defaultConfigCustomizers")).callsInOrder( + ReactiveMessageSenderBuilder::producerName, "fromPropsCustomizer", "fromCustomizer1", + "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ReactiveMessageSenderBuilderCustomizerConfig { + + @Bean + @Order(200) + ReactiveMessageSenderBuilderCustomizer customizerFoo() { + return (builder) -> builder.producerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ReactiveMessageSenderBuilderCustomizer customizerBar() { + return (builder) -> builder.producerName("fromCustomizer1"); + } + + } + + } + + @Nested + class TemplateTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("rawtypes") + void injectsExpectedBeans() { + ReactivePulsarSenderFactory senderFactory = mock(ReactivePulsarSenderFactory.class); + SchemaResolver schemaResolver = mock(SchemaResolver.class); + this.contextRunner + .withBean("customReactivePulsarSenderFactory", ReactivePulsarSenderFactory.class, () -> senderFactory) + .withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver) + .run((context) -> assertThat(context).getBean(ReactivePulsarTemplate.class).satisfies((template) -> { + assertThat(template).extracting("reactiveMessageSenderFactory").isSameAs(senderFactory); + assertThat(template).extracting("schemaResolver").isSameAs(schemaResolver); + })); + } + + } + + @Nested + class ConsumerFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void injectsExpectedBeans() { + ReactivePulsarClient client = mock(ReactivePulsarClient.class); + this.contextRunner.withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client) + .run((context) -> { + ReactivePulsarConsumerFactory consumerFactory = context + .getBean(DefaultReactivePulsarConsumerFactory.class); + assertThat(consumerFactory) + .extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class)) + .isSameAs(client); + }); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.consumer.name=fromPropsCustomizer") + .withUserConfiguration(ReactiveMessageConsumerBuilderCustomizerConfig.class) + .run((context) -> { + DefaultReactivePulsarConsumerFactory consumerFactory = context + .getBean(DefaultReactivePulsarConsumerFactory.class); + Customizers, ReactiveMessageConsumerBuilder> customizers = Customizers + .of(ReactiveMessageConsumerBuilder.class, ReactiveMessageConsumerBuilderCustomizer::customize); + assertThat(customizers.fromField(consumerFactory, "defaultConfigCustomizers")).callsInOrder( + ReactiveMessageConsumerBuilder::consumerName, "fromPropsCustomizer", "fromCustomizer1", + "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ReactiveMessageConsumerBuilderCustomizerConfig { + + @Bean + @Order(200) + ReactiveMessageConsumerBuilderCustomizer customizerFoo() { + return (builder) -> builder.consumerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ReactiveMessageConsumerBuilderCustomizer customizerBar() { + return (builder) -> builder.consumerName("fromCustomizer1"); + } + + } + + } + + @Nested + class ListenerTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + ReactivePulsarListenerContainerFactory listenerContainerFactory = mock( + ReactivePulsarListenerContainerFactory.class); + this.contextRunner + .withBean("reactivePulsarListenerContainerFactory", ReactivePulsarListenerContainerFactory.class, + () -> listenerContainerFactory) + .run((context) -> assertThat(context).getBean(ReactivePulsarListenerContainerFactory.class) + .isSameAs(listenerContainerFactory)); + } + + @Test + void whenHasUserDefinedReactivePulsarListenerAnnotationBeanPostProcessorDoesNotAutoConfigureBean() { + ReactivePulsarListenerAnnotationBeanPostProcessor listenerAnnotationBeanPostProcessor = mock( + ReactivePulsarListenerAnnotationBeanPostProcessor.class); + this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR, + ReactivePulsarListenerAnnotationBeanPostProcessor.class, () -> listenerAnnotationBeanPostProcessor) + .run((context) -> assertThat(context).getBean(ReactivePulsarListenerAnnotationBeanPostProcessor.class) + .isSameAs(listenerAnnotationBeanPostProcessor)); + } + + @Test + void whenHasCustomProperties() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.listener.schema-type=avro"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)).run((context) -> { + DefaultReactivePulsarListenerContainerFactory factory = context + .getBean(DefaultReactivePulsarListenerContainerFactory.class); + assertThat(factory.getContainerProperties().getSchemaType()).isEqualTo(SchemaType.AVRO); + }); + } + + @Test + void injectsExpectedBeans() { + ReactivePulsarConsumerFactory consumerFactory = mock(ReactivePulsarConsumerFactory.class); + SchemaResolver schemaResolver = mock(SchemaResolver.class); + this.contextRunner + .withBean("customReactivePulsarConsumerFactory", ReactivePulsarConsumerFactory.class, + () -> consumerFactory) + .withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver) + .run((context) -> { + DefaultReactivePulsarListenerContainerFactory containerFactory = context + .getBean(DefaultReactivePulsarListenerContainerFactory.class); + assertThat(containerFactory).extracting("consumerFactory").isSameAs(consumerFactory); + assertThat(containerFactory) + .extracting(DefaultReactivePulsarListenerContainerFactory::getContainerProperties) + .extracting(ReactivePulsarContainerProperties::getSchemaResolver) + .isSameAs(schemaResolver); + }); + } + + } + + @Nested + class ReaderFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void injectsExpectedBeans() { + ReactivePulsarClient client = mock(ReactivePulsarClient.class); + this.contextRunner.withPropertyValues("spring.pulsar.reader.name=test-reader") + .withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client) + .run((context) -> { + DefaultReactivePulsarReaderFactory readerFactory = context + .getBean(DefaultReactivePulsarReaderFactory.class); + assertThat(readerFactory) + .extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class)) + .isSameAs(client); + }); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.reader.name=fromPropsCustomizer") + .withUserConfiguration(ReactiveMessageReaderBuilderCustomizerConfig.class) + .run((context) -> { + DefaultReactivePulsarReaderFactory readerFactory = context + .getBean(DefaultReactivePulsarReaderFactory.class); + Customizers, ReactiveMessageReaderBuilder> customizers = Customizers + .of(ReactiveMessageReaderBuilder.class, ReactiveMessageReaderBuilderCustomizer::customize); + assertThat(customizers.fromField(readerFactory, "defaultConfigCustomizers")).callsInOrder( + ReactiveMessageReaderBuilder::readerName, "fromPropsCustomizer", "fromCustomizer1", + "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ReactiveMessageReaderBuilderCustomizerConfig { + + @Bean + @Order(200) + ReactiveMessageReaderBuilderCustomizer customizerFoo() { + return (builder) -> builder.readerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ReactiveMessageReaderBuilderCustomizer customizerBar() { + return (builder) -> builder.readerName("fromCustomizer1"); + } + + } + + } + + @Nested + class SenderCacheAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void whenNoPropertiesEnablesCaching() { + this.contextRunner.run(this::assertCaffeineProducerCacheProvider); + } + + @Test + void whenCachingEnabledEnablesCaching() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run(this::assertCaffeineProducerCacheProvider); + } + + @Test + void whenCachingDisabledDoesNotEnableCaching() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(ProducerCacheProvider.class) + .doesNotHaveBean(ReactiveMessageSenderCache.class)); + } + + @Test + void whenCachingEnabledAndCaffeineNotOnClasspathStillUsesCaffeine() { + // The reactive client shades Caffeine - it should still be used + this.contextRunner.withClassLoader(new FilteredClassLoader(Caffeine.class)) + .withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run(this::assertCaffeineProducerCacheProvider); + } + + @Test + void whenCachingEnabledAndNoCacheProviderAvailable() { + // The reactive client uses a shaded caffeine cache provider as its internal + // cache + this.contextRunner.withClassLoader(new FilteredClassLoader(CaffeineShadedProducerCacheProvider.class)) + .withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run((context) -> assertThat(context).doesNotHaveBean(ProducerCacheProvider.class) + .getBean(ReactiveMessageSenderCache.class) + .extracting("cacheProvider") + .isExactlyInstanceOf(CaffeineShadedProducerCacheProvider.class)); + } + + @Test + void whenCustomCachingPropertiesCreatesConfiguredBean() { + this.contextRunner + .withPropertyValues("spring.pulsar.producer.cache.expire-after-access=100s", + "spring.pulsar.producer.cache.maximum-size=5150", + "spring.pulsar.producer.cache.initial-capacity=200") + .run((context) -> assertCaffeineProducerCacheProvider(context).extracting("cache.cache") + .hasFieldOrPropertyWithValue("expiresAfterAccessNanos", Duration.ofSeconds(100).toNanos()) + .hasFieldOrPropertyWithValue("maximum", 5150L)); + } + + private AbstractObjectAssert assertCaffeineProducerCacheProvider( + AssertableApplicationContext context) { + return assertThat(context).hasSingleBean(ReactiveMessageSenderCache.class) + .getBean(ProducerCacheProvider.class) + .isExactlyInstanceOf(CaffeineShadedProducerCacheProvider.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java new file mode 100644 index 000000000000..df078b21a354 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2012-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.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.List; +import java.util.regex.Pattern; + +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerAccessMode; +import org.apache.pulsar.client.api.RegexSubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.SubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.schema.SchemaType; +import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer.Subscription; +import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarReactivePropertiesMapper}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class PulsarReactivePropertiesMapperTests { + + @Test + @SuppressWarnings("unchecked") + void customizeMessageSenderBuilder() { + PulsarProperties properties = new PulsarProperties(); + properties.getProducer().setName("name"); + properties.getProducer().setTopicName("topicname"); + properties.getProducer().setSendTimeout(Duration.ofSeconds(1)); + properties.getProducer().setMessageRoutingMode(MessageRoutingMode.RoundRobinPartition); + properties.getProducer().setHashingScheme(HashingScheme.JavaStringHash); + properties.getProducer().setBatchingEnabled(false); + properties.getProducer().setChunkingEnabled(true); + properties.getProducer().setCompressionType(CompressionType.SNAPPY); + properties.getProducer().setAccessMode(ProducerAccessMode.Exclusive); + ReactiveMessageSenderBuilder builder = mock(ReactiveMessageSenderBuilder.class); + new PulsarReactivePropertiesMapper(properties).customizeMessageSenderBuilder(builder); + then(builder).should().producerName("name"); + then(builder).should().topic("topicname"); + then(builder).should().sendTimeout(Duration.ofSeconds(1)); + then(builder).should().messageRoutingMode(MessageRoutingMode.RoundRobinPartition); + then(builder).should().hashingScheme(HashingScheme.JavaStringHash); + then(builder).should().batchingEnabled(false); + then(builder).should().chunkingEnabled(true); + then(builder).should().compressionType(CompressionType.SNAPPY); + then(builder).should().accessMode(ProducerAccessMode.Exclusive); + } + + @Test + @SuppressWarnings("unchecked") + void customizeMessageConsumerBuilder() { + PulsarProperties properties = new PulsarProperties(); + List topics = List.of("mytopic"); + Pattern topisPattern = Pattern.compile("my-pattern"); + properties.getConsumer().setName("name"); + properties.getConsumer().setTopics(topics); + properties.getConsumer().setTopicsPattern(topisPattern); + properties.getConsumer().setPriorityLevel(123); + properties.getConsumer().setReadCompacted(true); + Consumer.DeadLetterPolicy deadLetterPolicy = new Consumer.DeadLetterPolicy(); + deadLetterPolicy.setDeadLetterTopic("my-dlt"); + deadLetterPolicy.setMaxRedeliverCount(1); + properties.getConsumer().setDeadLetterPolicy(deadLetterPolicy); + properties.getConsumer().setRetryEnable(false); + Subscription subscriptionProperties = properties.getConsumer().getSubscription(); + subscriptionProperties.setName("subname"); + subscriptionProperties.setInitialPosition(SubscriptionInitialPosition.Earliest); + subscriptionProperties.setMode(SubscriptionMode.NonDurable); + subscriptionProperties.setTopicsMode(RegexSubscriptionMode.NonPersistentOnly); + subscriptionProperties.setType(SubscriptionType.Key_Shared); + ReactiveMessageConsumerBuilder builder = mock(ReactiveMessageConsumerBuilder.class); + new PulsarReactivePropertiesMapper(properties).customizeMessageConsumerBuilder(builder); + then(builder).should().consumerName("name"); + then(builder).should().topics(topics); + then(builder).should().topicsPattern(topisPattern); + then(builder).should().priorityLevel(123); + then(builder).should().readCompacted(true); + then(builder).should().deadLetterPolicy(new DeadLetterPolicy(1, null, "my-dlt", null)); + then(builder).should().retryLetterTopicEnable(false); + then(builder).should().subscriptionName("subname"); + then(builder).should().subscriptionInitialPosition(SubscriptionInitialPosition.Earliest); + then(builder).should().subscriptionMode(SubscriptionMode.NonDurable); + then(builder).should().topicsPatternSubscriptionMode(RegexSubscriptionMode.NonPersistentOnly); + then(builder).should().subscriptionType(SubscriptionType.Key_Shared); + } + + @Test + void customizeContainerProperties() { + PulsarProperties properties = new PulsarProperties(); + properties.getConsumer().getSubscription().setType(SubscriptionType.Shared); + properties.getListener().setSchemaType(SchemaType.AVRO); + ReactivePulsarContainerProperties containerProperties = new ReactivePulsarContainerProperties<>(); + new PulsarReactivePropertiesMapper(properties).customizeContainerProperties(containerProperties); + assertThat(containerProperties.getSubscriptionType()).isEqualTo(SubscriptionType.Shared); + assertThat(containerProperties.getSchemaType()).isEqualTo(SchemaType.AVRO); + } + + @Test + @SuppressWarnings("unchecked") + void customizeMessageReaderBuilder() { + List topics = List.of("my-topic"); + PulsarProperties properties = new PulsarProperties(); + properties.getReader().setName("name"); + properties.getReader().setTopics(topics); + properties.getReader().setSubscriptionName("subname"); + properties.getReader().setSubscriptionRolePrefix("srp"); + ReactiveMessageReaderBuilder builder = mock(ReactiveMessageReaderBuilder.class); + new PulsarReactivePropertiesMapper(properties).customizeMessageReaderBuilder(builder); + then(builder).should().readerName("name"); + then(builder).should().topics(topics); + then(builder).should().subscriptionName("subname"); + then(builder).should().generatedSubscriptionNamePrefix("srp"); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfigurationTests.java new file mode 100644 index 000000000000..1587fc2050db --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/reactor/ReactorAutoConfigurationTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-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.boot.autoconfigure.reactor; + +import java.util.concurrent.atomic.AtomicReference; + +import io.micrometer.context.ContextRegistry; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ReactorAutoConfiguration}. + * + * @author Brian Clozel + * @author Moritz Halbritter + */ +class ReactorAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ReactorAutoConfiguration.class)); + + private static final String THREADLOCAL_KEY = "ReactorAutoConfigurationTests"; + + private static final ThreadLocal THREADLOCAL_VALUE = ThreadLocal.withInitial(() -> "initial"); + + @BeforeEach + @AfterEach + void resetStaticState() { + Hooks.disableAutomaticContextPropagation(); + } + + @BeforeAll + static void initializeThreadLocalAccessors() { + ContextRegistry globalRegistry = ContextRegistry.getInstance(); + globalRegistry.registerThreadLocalAccessor(THREADLOCAL_KEY, THREADLOCAL_VALUE); + } + + @AfterAll + static void removeThreadLocalAccessors() { + ContextRegistry globalRegistry = ContextRegistry.getInstance(); + globalRegistry.removeThreadLocalAccessor(THREADLOCAL_KEY); + } + + @Test + void shouldNotConfigurePropagationByDefault() { + AtomicReference threadLocalValue = new AtomicReference<>(); + this.contextRunner.run((applicationContext) -> { + Mono.just("test") + .doOnNext((element) -> threadLocalValue.set(THREADLOCAL_VALUE.get())) + .contextWrite(Context.of(THREADLOCAL_KEY, "updated")) + .block(); + assertThat(threadLocalValue.get()).isEqualTo("initial"); + }); + } + + @Test + void shouldConfigurePropagationIfSetToAuto() { + AtomicReference threadLocalValue = new AtomicReference<>(); + this.contextRunner.withPropertyValues("spring.reactor.context-propagation=auto").run((applicationContext) -> { + Mono.just("test") + .doOnNext((element) -> threadLocalValue.set(THREADLOCAL_VALUE.get())) + .contextWrite(Context.of(THREADLOCAL_KEY, "updated")) + .block(); + assertThat(threadLocalValue.get()).isEqualTo("updated"); + }); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketPropertiesTests.java new file mode 100644 index 000000000000..eefd1b7212de --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketPropertiesTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-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.boot.autoconfigure.rsocket; + +import org.junit.jupiter.api.Test; +import reactor.netty.http.server.WebsocketServerSpec; + +import org.springframework.boot.autoconfigure.rsocket.RSocketProperties.Server.Spec; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link RSocketProperties}. + * + * @author Stephane Nicoll + */ +class RSocketPropertiesTests { + + @Test + void defaultServerSpecValuesAreConsistent() { + WebsocketServerSpec spec = WebsocketServerSpec.builder().build(); + Spec properties = new RSocketProperties().getServer().getSpec(); + assertThat(properties.getProtocols()).isEqualTo(spec.protocols()); + assertThat(properties.getMaxFramePayloadLength().toBytes()).isEqualTo(spec.maxFramePayloadLength()); + assertThat(properties.isHandlePing()).isEqualTo(spec.handlePing()); + assertThat(properties.isCompress()).isEqualTo(spec.compress()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfigurationTests.java index 0ddf63903d7b..62569319c380 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/rsocket/RSocketServerAutoConfigurationTests.java @@ -34,7 +34,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.codec.CharSequenceEncoder; import org.springframework.core.codec.StringDecoder; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.messaging.rsocket.RSocketStrategies; import org.springframework.messaging.rsocket.annotation.support.RSocketMessageHandler; import org.springframework.util.unit.DataSize; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/client/servlet/OAuth2WebSecurityConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/client/servlet/OAuth2WebSecurityConfigurationTests.java index 190859e9f7a3..b9cf9f8b6b3f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/client/servlet/OAuth2WebSecurityConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/client/servlet/OAuth2WebSecurityConfigurationTests.java @@ -48,6 +48,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.ObjectUtils; +import org.springframework.web.filter.CompositeFilter; import static org.assertj.core.api.Assertions.assertThat; @@ -68,7 +69,7 @@ void securityConfigurerConfiguresOAuth2Login() { .run((context) -> { ClientRegistrationRepository expected = context.getBean(ClientRegistrationRepository.class); ClientRegistrationRepository actual = (ClientRegistrationRepository) ReflectionTestUtils.getField( - getFilters(context, OAuth2LoginAuthenticationFilter.class).get(0), + getSecurityFilters(context, OAuth2LoginAuthenticationFilter.class).get(0), "clientRegistrationRepository"); assertThat(isEqual(expected.findByRegistrationId("first"), actual.findByRegistrationId("first"))) .isTrue(); @@ -85,7 +86,7 @@ void securityConfigurerConfiguresAuthorizationCode() { .run((context) -> { ClientRegistrationRepository expected = context.getBean(ClientRegistrationRepository.class); ClientRegistrationRepository actual = (ClientRegistrationRepository) ReflectionTestUtils.getField( - getFilters(context, OAuth2AuthorizationCodeGrantFilter.class).get(0), + getSecurityFilters(context, OAuth2AuthorizationCodeGrantFilter.class).get(0), "clientRegistrationRepository"); assertThat(isEqual(expected.findByRegistrationId("first"), actual.findByRegistrationId("first"))) .isTrue(); @@ -98,8 +99,8 @@ void securityConfigurerConfiguresAuthorizationCode() { void securityConfigurerBacksOffWhenClientRegistrationBeanAbsent() { this.contextRunner.withUserConfiguration(TestConfig.class, OAuth2WebSecurityConfiguration.class) .run((context) -> { - assertThat(getFilters(context, OAuth2LoginAuthenticationFilter.class)).isEmpty(); - assertThat(getFilters(context, OAuth2AuthorizationCodeGrantFilter.class)).isEmpty(); + assertThat(getSecurityFilters(context, OAuth2LoginAuthenticationFilter.class)).isEmpty(); + assertThat(getSecurityFilters(context, OAuth2AuthorizationCodeGrantFilter.class)).isEmpty(); }); } @@ -124,8 +125,8 @@ void securityFilterChainConfigBacksOffWhenOtherSecurityFilterChainBeanPresent() this.contextRunner.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class)) .withUserConfiguration(TestSecurityFilterChainConfiguration.class, OAuth2WebSecurityConfiguration.class) .run((context) -> { - assertThat(getFilters(context, OAuth2LoginAuthenticationFilter.class)).isEmpty(); - assertThat(getFilters(context, OAuth2AuthorizationCodeGrantFilter.class)).isEmpty(); + assertThat(getSecurityFilters(context, OAuth2LoginAuthenticationFilter.class)).isEmpty(); + assertThat(getSecurityFilters(context, OAuth2AuthorizationCodeGrantFilter.class)).isEmpty(); assertThat(context).getBean(OAuth2AuthorizedClientService.class).isNotNull(); }); } @@ -137,8 +138,8 @@ void securityFilterChainConfigConditionalOnSecurityFilterChainClass() { OAuth2WebSecurityConfiguration.class) .withClassLoader(new FilteredClassLoader(SecurityFilterChain.class)) .run((context) -> { - assertThat(getFilters(context, OAuth2LoginAuthenticationFilter.class)).isEmpty(); - assertThat(getFilters(context, OAuth2AuthorizationCodeGrantFilter.class)).isEmpty(); + assertThat(getSecurityFilters(context, OAuth2LoginAuthenticationFilter.class)).isEmpty(); + assertThat(getSecurityFilters(context, OAuth2AuthorizationCodeGrantFilter.class)).isEmpty(); }); } @@ -164,11 +165,29 @@ void authorizedClientRepositoryBeanIsConditionalOnMissingBean() { }); } - private List getFilters(AssertableWebApplicationContext context, Class filter) { - FilterChainProxy filterChain = (FilterChainProxy) context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN); - List filterChains = filterChain.getFilterChains(); - List filters = filterChains.get(0).getFilters(); - return filters.stream().filter(filter::isInstance).toList(); + private List getSecurityFilters(AssertableWebApplicationContext context, Class filter) { + return getSecurityFilterChain(context).getFilters().stream().filter(filter::isInstance).toList(); + } + + private SecurityFilterChain getSecurityFilterChain(AssertableWebApplicationContext context) { + Filter springSecurityFilterChain = context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN, Filter.class); + FilterChainProxy filterChainProxy = getFilterChainProxy(springSecurityFilterChain); + SecurityFilterChain securityFilterChain = filterChainProxy.getFilterChains().get(0); + return securityFilterChain; + } + + private FilterChainProxy getFilterChainProxy(Filter filter) { + if (filter instanceof FilterChainProxy filterChainProxy) { + return filterChainProxy; + } + if (filter instanceof CompositeFilter) { + List filters = (List) ReflectionTestUtils.getField(filter, "filters"); + return (FilterChainProxy) filters.stream() + .filter(FilterChainProxy.class::isInstance) + .findFirst() + .orElseThrow(); + } + throw new IllegalStateException("No FilterChainProxy found"); } private boolean isEqual(ClientRegistration reg1, ClientRegistration reg2) { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/JwtConverterCustomizationsArgumentsProvider.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/JwtConverterCustomizationsArgumentsProvider.java new file mode 100644 index 000000000000..7ef5d20fa91f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/JwtConverterCustomizationsArgumentsProvider.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.security.oauth2.resource; + +import java.time.Instant; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; + +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * {@link ArgumentsProvider Arguments provider} supplying different Spring Boot properties + * to customize JWT converter behavior, JWT token for conversion, expected principal name + * and expected authorities. + * + * @author Yan Kardziyaka + */ +public final class JwtConverterCustomizationsArgumentsProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext extensionContext) { + String customPrefix = "CUSTOM_AUTHORITY_PREFIX_"; + String customDelimiter = "[~,#:]"; + String customAuthoritiesClaim = "custom_authorities"; + String customPrincipalClaim = "custom_principal"; + String jwkSetUriProperty = "spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com"; + String authorityPrefixProperty = "spring.security.oauth2.resourceserver.jwt.authority-prefix=" + customPrefix; + String authoritiesDelimiterProperty = "spring.security.oauth2.resourceserver.jwt.authorities-claim-delimiter=" + + customDelimiter; + String authoritiesClaimProperty = "spring.security.oauth2.resourceserver.jwt.authorities-claim-name=" + + customAuthoritiesClaim; + String principalClaimProperty = "spring.security.oauth2.resourceserver.jwt.principal-claim-name=" + + customPrincipalClaim; + String[] customPrefixProps = { jwkSetUriProperty, authorityPrefixProperty }; + String[] customDelimiterProps = { jwkSetUriProperty, authorityPrefixProperty, authoritiesDelimiterProperty }; + String[] customAuthoritiesClaimProps = { jwkSetUriProperty, authoritiesClaimProperty }; + String[] customPrincipalClaimProps = { jwkSetUriProperty, principalClaimProperty }; + String[] allJwtConverterProps = { jwkSetUriProperty, authorityPrefixProperty, authoritiesDelimiterProperty, + authoritiesClaimProperty, principalClaimProperty }; + String[] jwtScopes = { "custom_scope0", "custom_scope1" }; + String subjectValue = UUID.randomUUID().toString(); + String customPrincipalValue = UUID.randomUUID().toString(); + Jwt.Builder jwtBuilder = Jwt.withTokenValue("token") + .header("alg", "none") + .expiresAt(Instant.MAX) + .issuedAt(Instant.MIN) + .issuer("https://issuer.example.org") + .jti("jti") + .notBefore(Instant.MIN) + .subject(subjectValue) + .claim(customPrincipalClaim, customPrincipalValue); + Jwt noAuthoritiesCustomizationsJwt = jwtBuilder.claim("scp", jwtScopes[0] + " " + jwtScopes[1]).build(); + Jwt customAuthoritiesDelimiterJwt = jwtBuilder.claim("scp", jwtScopes[0] + "~" + jwtScopes[1]).build(); + Jwt customAuthoritiesClaimJwt = jwtBuilder.claim("scp", null) + .claim(customAuthoritiesClaim, jwtScopes[0] + " " + jwtScopes[1]) + .build(); + Jwt customAuthoritiesClaimAndDelimiterJwt = jwtBuilder.claim("scp", null) + .claim(customAuthoritiesClaim, jwtScopes[0] + "~" + jwtScopes[1]) + .build(); + String[] customPrefixAuthorities = { customPrefix + jwtScopes[0], customPrefix + jwtScopes[1] }; + String[] defaultPrefixAuthorities = { "SCOPE_" + jwtScopes[0], "SCOPE_" + jwtScopes[1] }; + return Stream.of( + Arguments.of(Named.named("Custom prefix for GrantedAuthority", customPrefixProps), + noAuthoritiesCustomizationsJwt, subjectValue, customPrefixAuthorities), + Arguments.of(Named.named("Custom delimiter for JWT scopes", customDelimiterProps), + customAuthoritiesDelimiterJwt, subjectValue, customPrefixAuthorities), + Arguments.of(Named.named("Custom JWT authority claim name", customAuthoritiesClaimProps), + customAuthoritiesClaimJwt, subjectValue, defaultPrefixAuthorities), + Arguments.of(Named.named("Custom JWT principal claim name", customPrincipalClaimProps), + noAuthoritiesCustomizationsJwt, customPrincipalValue, defaultPrefixAuthorities), + Arguments.of(Named.named("All JWT converter customizations", allJwtConverterProps), + customAuthoritiesClaimAndDelimiterJwt, customPrincipalValue, customPrefixAuthorities)); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java index cc5a652d10c2..e650f900c6fe 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,14 +17,17 @@ package org.springframework.boot.autoconfigure.security.oauth2.resource.reactive; import java.io.IOException; -import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.stream.Stream; import com.fasterxml.jackson.core.JsonProcessingException; @@ -33,12 +36,16 @@ import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.api.ThrowingConsumer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ArgumentsSource; import org.mockito.InOrder; import reactor.core.publisher.Mono; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.security.oauth2.resource.JwtConverterCustomizationsArgumentsProvider; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; @@ -48,10 +55,12 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; import org.springframework.security.config.BeanIds; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; @@ -66,6 +75,7 @@ import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenReactiveAuthenticationManager; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector; import org.springframework.security.web.server.MatcherSecurityWebFilterChain; import org.springframework.security.web.server.SecurityWebFilterChain; @@ -87,6 +97,8 @@ * @author HaiTao Zhang * @author Anastasiia Losieva * @author Mushtaq Ahmed + * @author Roman Golovin + * @author Yan Kardziyaka */ class ReactiveOAuth2ResourceServerAutoConfigurationTests { @@ -438,7 +450,6 @@ void autoConfigurationWhenIntrospectionUriAvailableShouldBeConditionalOnClass() .run((context) -> assertThat(context).doesNotHaveBean(ReactiveOpaqueTokenIntrospector.class)); } - @SuppressWarnings("unchecked") @Test void autoConfigurationShouldConfigureResourceServerUsingJwkSetUriAndIssuerUri() throws Exception { this.server = new MockWebServer(); @@ -454,15 +465,11 @@ void autoConfigurationShouldConfigureResourceServerUsingJwkSetUriAndIssuerUri() .run((context) -> { assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); - DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils - .getField(reactiveJwtDecoder, "jwtValidator"); - Collection> tokenValidators = (Collection>) ReflectionTestUtils - .getField(jwtValidator, "tokenValidators"); - assertThat(tokenValidators).hasAtLeastOneElementOfType(JwtIssuerValidator.class); + validate(jwt().claim("iss", issuer), reactiveJwtDecoder, + (validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)); }); } - @SuppressWarnings("unchecked") @Test void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfPropertyNotConfigured() throws Exception { this.server = new MockWebServer(); @@ -476,13 +483,8 @@ void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfProper .run((context) -> { assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); - DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils - .getField(reactiveJwtDecoder, "jwtValidator"); - Collection> tokenValidators = (Collection>) ReflectionTestUtils - .getField(jwtValidator, "tokenValidators"); - assertThat(tokenValidators).hasExactlyElementsOfTypes(JwtTimestampValidator.class); - assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtClaimValidator.class); - assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtIssuerValidator.class); + validate(jwt(), reactiveJwtDecoder, (validators) -> assertThat(validators).singleElement() + .isInstanceOf(JwtTimestampValidator.class)); }); } @@ -502,39 +504,15 @@ void autoConfigurationShouldConfigureIssuerAndAudienceJwtValidatorIfPropertyProv .run((context) -> { assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); - validate(issuerUri, reactiveJwtDecoder); + validate( + jwt().claim("iss", URI.create(issuerUri).toURL()) + .claim("aud", List.of("https://test-audience.com")), + reactiveJwtDecoder, + (validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class) + .satisfiesOnlyOnce(audClaimValidator())); }); } - @SuppressWarnings("unchecked") - private void validate(String issuerUri, ReactiveJwtDecoder jwtDecoder) throws MalformedURLException { - DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils - .getField(jwtDecoder, "jwtValidator"); - Jwt.Builder builder = jwt().claim("aud", Collections.singletonList("https://test-audience.com")); - if (issuerUri != null) { - builder.claim("iss", new URL(issuerUri)); - } - Jwt jwt = builder.build(); - assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse(); - Collection> delegates = (Collection>) ReflectionTestUtils - .getField(jwtValidator, "tokenValidators"); - validateDelegates(issuerUri, delegates); - } - - @SuppressWarnings("unchecked") - private void validateDelegates(String issuerUri, Collection> delegates) { - assertThat(delegates).hasAtLeastOneElementOfType(JwtClaimValidator.class); - OAuth2TokenValidator delegatingValidator = delegates.stream() - .filter((v) -> v instanceof DelegatingOAuth2TokenValidator) - .findFirst() - .get(); - Collection> nestedDelegates = (Collection>) ReflectionTestUtils - .getField(delegatingValidator, "tokenValidators"); - if (issuerUri != null) { - assertThat(nestedDelegates).hasAtLeastOneElementOfType(JwtIssuerValidator.class); - } - } - @SuppressWarnings("unchecked") @Test void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssuerUri() throws Exception { @@ -552,7 +530,12 @@ void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssue Mono jwtDecoderSupplier = (Mono) ReflectionTestUtils .getField(supplierJwtDecoderBean, "jwtDecoderMono"); ReactiveJwtDecoder jwtDecoder = jwtDecoderSupplier.block(); - validate(issuerUri, jwtDecoder); + validate( + jwt().claim("iss", URI.create(issuerUri).toURL()) + .claim("aud", List.of("https://test-audience.com")), + jwtDecoder, + (validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class) + .satisfiesOnlyOnce(audClaimValidator())); }); } @@ -570,7 +553,33 @@ void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPubli .run((context) -> { assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class); - validate(null, jwtDecoder); + validate(jwt().claim("aud", List.of("https://test-audience.com")), jwtDecoder, + (validators) -> assertThat(validators).satisfiesOnlyOnce(audClaimValidator())); + }); + } + + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldConfigureCustomValidators() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String path = "test"; + String issuer = this.server.url(path).toString(); + String cleanIssuerPath = cleanIssuerPath(issuer); + setupMockResponse(cleanIssuerPath); + String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path; + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri) + .withUserConfiguration(CustomJwtClaimValidatorConfig.class) + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); + ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); + OAuth2TokenValidator customValidator = (OAuth2TokenValidator) context + .getBean("customJwtClaimValidator"); + validate(jwt().claim("iss", URI.create(issuerUri).toURL()).claim("custom_claim", "custom_claim_value"), + reactiveJwtDecoder, (validators) -> assertThat(validators).contains(customValidator) + .hasAtLeastOneElementOfType(JwtIssuerValidator.class)); }); } @@ -600,6 +609,94 @@ void audienceValidatorWhenAudienceInvalid() throws Exception { }); } + @SuppressWarnings("unchecked") + @Test + void customValidatorWhenInvalid() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String path = "test"; + String issuer = this.server.url(path).toString(); + String cleanIssuerPath = cleanIssuerPath(issuer); + setupMockResponse(cleanIssuerPath); + String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path; + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri) + .withUserConfiguration(CustomJwtClaimValidatorConfig.class) + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); + ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class); + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(jwtDecoder, "jwtValidator"); + Jwt jwt = jwt().claim("iss", new URL(issuerUri)).claim("custom_claim", "invalid_value").build(); + assertThat(jwtValidator.validate(jwt).hasErrors()).isTrue(); + }); + } + + @Test + void shouldNotConfigureJwtConverterIfNoPropertiesAreSet() { + this.contextRunner + .run((context) -> assertThat(context).doesNotHaveBean(ReactiveJwtAuthenticationConverter.class)); + } + + @Test + void shouldConfigureJwtConverterIfPrincipalClaimNameIsSet() { + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.principal-claim-name=dummy") + .run((context) -> assertThat(context).hasSingleBean(ReactiveJwtAuthenticationConverter.class)); + } + + @Test + void shouldConfigureJwtConverterIfAuthorityPrefixIsSet() { + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.authority-prefix=dummy") + .run((context) -> assertThat(context).hasSingleBean(ReactiveJwtAuthenticationConverter.class)); + } + + @Test + void shouldConfigureJwtConverterIfAuthorityClaimsNameIsSet() { + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-claim-name=dummy") + .run((context) -> assertThat(context).hasSingleBean(ReactiveJwtAuthenticationConverter.class)); + } + + @ParameterizedTest(name = "{0}") + @ArgumentsSource(JwtConverterCustomizationsArgumentsProvider.class) + void autoConfigurationShouldConfigureResourceServerWithJwtConverterCustomizations(String[] properties, Jwt jwt, + String expectedPrincipal, String[] expectedAuthorities) { + this.contextRunner.withPropertyValues(properties).run((context) -> { + ReactiveJwtAuthenticationConverter converter = context.getBean(ReactiveJwtAuthenticationConverter.class); + AbstractAuthenticationToken token = converter.convert(jwt).block(); + assertThat(token).isNotNull().extracting(AbstractAuthenticationToken::getName).isEqualTo(expectedPrincipal); + assertThat(token.getAuthorities()).extracting(GrantedAuthority::getAuthority) + .containsExactlyInAnyOrder(expectedAuthorities); + assertThat(context).hasSingleBean(NimbusReactiveJwtDecoder.class); + assertFilterConfiguredWithJwtAuthenticationManager(context); + }); + } + + @Test + void jwtAuthenticationConverterByJwtConfigIsConditionalOnMissingBean() { + String propertiesPrincipalClaim = "principal_from_properties"; + String propertiesPrincipalValue = "from_props"; + String userConfigPrincipalValue = "from_user_config"; + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.principal-claim-name=" + propertiesPrincipalClaim) + .withUserConfiguration(CustomJwtConverterConfig.class) + .run((context) -> { + ReactiveJwtAuthenticationConverter converter = context + .getBean(ReactiveJwtAuthenticationConverter.class); + Jwt jwt = jwt().claim(propertiesPrincipalClaim, propertiesPrincipalValue) + .claim(CustomJwtConverterConfig.PRINCIPAL_CLAIM, userConfigPrincipalValue) + .build(); + AbstractAuthenticationToken token = converter.convert(jwt).block(); + assertThat(token).isNotNull() + .extracting(AbstractAuthenticationToken::getName) + .isEqualTo(userConfigPrincipalValue) + .isNotEqualTo(propertiesPrincipalValue); + assertThat(context).hasSingleBean(NimbusReactiveJwtDecoder.class); + assertFilterConfiguredWithJwtAuthenticationManager(context); + }); + } + private void assertFilterConfiguredWithJwtAuthenticationManager(AssertableReactiveWebApplicationContext context) { MatcherSecurityWebFilterChain filterChain = (MatcherSecurityWebFilterChain) context .getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN); @@ -683,6 +780,37 @@ static Jwt.Builder jwt() { .subject("mock-test-subject"); } + @SuppressWarnings("unchecked") + private void validate(Jwt.Builder builder, ReactiveJwtDecoder jwtDecoder, + ThrowingConsumer>> validatorsConsumer) { + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(jwtDecoder, "jwtValidator"); + assertThat(jwtValidator.validate(builder.build()).hasErrors()).isFalse(); + validatorsConsumer.accept(extractValidators(jwtValidator)); + } + + @SuppressWarnings("unchecked") + private List> extractValidators(DelegatingOAuth2TokenValidator delegatingValidator) { + Collection> delegates = (Collection>) ReflectionTestUtils + .getField(delegatingValidator, "tokenValidators"); + List> extracted = new ArrayList<>(); + for (OAuth2TokenValidator delegate : delegates) { + if (delegate instanceof DelegatingOAuth2TokenValidator delegatingDelegate) { + extracted.addAll(extractValidators(delegatingDelegate)); + } + else { + extracted.add(delegate); + } + } + return extracted; + } + + private Consumer> audClaimValidator() { + return (validator) -> assertThat(validator).isInstanceOf(JwtClaimValidator.class) + .extracting("claim") + .isEqualTo("aud"); + } + @EnableWebFluxSecurity static class TestConfig { @@ -740,4 +868,28 @@ SecurityWebFilterChain testSpringSecurityFilterChain(ServerHttpSecurity http) { } + @Configuration(proxyBeanMethods = false) + static class CustomJwtClaimValidatorConfig { + + @Bean + JwtClaimValidator customJwtClaimValidator() { + return new JwtClaimValidator<>("custom_claim", "custom_claim_value"::equals); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomJwtConverterConfig { + + static String PRINCIPAL_CLAIM = "principal_from_user_configuration"; + + @Bean + ReactiveJwtAuthenticationConverter customReactiveJwtAuthenticationConverter() { + ReactiveJwtAuthenticationConverter converter = new ReactiveJwtAuthenticationConverter(); + converter.setPrincipalClaimName(PRINCIPAL_CLAIM); + return converter; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java index 878415ec9f9f..3c71539b81d6 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,16 @@ package org.springframework.boot.autoconfigure.security.oauth2.resource.servlet; -import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; import java.time.Instant; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.function.Supplier; import com.fasterxml.jackson.core.JsonProcessingException; @@ -33,11 +35,15 @@ import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.api.ThrowingConsumer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ArgumentsSource; import org.mockito.InOrder; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.security.oauth2.resource.JwtConverterCustomizationsArgumentsProvider; import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; @@ -48,9 +54,11 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.BeanIds; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jwt.Jwt; @@ -61,6 +69,7 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; @@ -80,6 +89,8 @@ * @author Artsiom Yudovin * @author HaiTao Zhang * @author Mushtaq Ahmed + * @author Roman Golovin + * @author Yan Kardziyaka */ class OAuth2ResourceServerAutoConfigurationTests { @@ -190,8 +201,8 @@ void autoConfigurationUsingPublicKeyValueWithMultipleJwsAlgorithmsShouldFail() { }); } - @Test @SuppressWarnings("unchecked") + @Test void autoConfigurationShouldConfigureResourceServerUsingOidcIssuerUri() throws Exception { this.server = new MockWebServer(); this.server.start(); @@ -215,8 +226,8 @@ void autoConfigurationShouldConfigureResourceServerUsingOidcIssuerUri() throws E assertThat(this.server.getRequestCount()).isEqualTo(2); } - @Test @SuppressWarnings("unchecked") + @Test void autoConfigurationShouldConfigureResourceServerUsingOidcRfc8414IssuerUri() throws Exception { this.server = new MockWebServer(); this.server.start(); @@ -240,8 +251,8 @@ void autoConfigurationShouldConfigureResourceServerUsingOidcRfc8414IssuerUri() t assertThat(this.server.getRequestCount()).isEqualTo(3); } - @Test @SuppressWarnings("unchecked") + @Test void autoConfigurationShouldConfigureResourceServerUsingOAuthIssuerUri() throws Exception { this.server = new MockWebServer(); this.server.start(); @@ -420,7 +431,7 @@ void autoConfigurationWhenJwkSetUriAndIntrospectionUriAvailable() { assertThat(context).hasSingleBean(OpaqueTokenIntrospector.class); assertThat(context).hasSingleBean(JwtDecoder.class); assertThat(getBearerTokenFilter(context)).extracting("authenticationManagerResolver.arg$1.providers") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .hasAtLeastOneElementOfType(JwtAuthenticationProvider.class); }); } @@ -472,9 +483,8 @@ void autoConfigurationShouldConfigureResourceServerUsingJwkSetUriAndIssuerUri() .run((context) -> { assertThat(context).hasSingleBean(JwtDecoder.class); JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); - assertThat(jwtDecoder).extracting("jwtValidator.tokenValidators") - .asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class)) - .hasAtLeastOneElementOfType(JwtIssuerValidator.class); + validate(jwt().claim("iss", issuer), jwtDecoder, + (validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class)); }); } @@ -491,11 +501,8 @@ void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfProper .run((context) -> { assertThat(context).hasSingleBean(JwtDecoder.class); JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); - assertThat(jwtDecoder).extracting("jwtValidator.tokenValidators") - .asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class)) - .hasExactlyElementsOfTypes(JwtTimestampValidator.class) - .doesNotHaveAnyElementsOfTypes(JwtClaimValidator.class) - .doesNotHaveAnyElementsOfTypes(JwtIssuerValidator.class); + validate(jwt(), jwtDecoder, (validators) -> assertThat(validators).singleElement() + .isInstanceOf(JwtTimestampValidator.class)); }); } @@ -515,7 +522,12 @@ void autoConfigurationShouldConfigureAudienceAndIssuerJwtValidatorIfPropertyProv .run((context) -> { assertThat(context).hasSingleBean(JwtDecoder.class); JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); - validate(issuerUri, jwtDecoder); + validate( + jwt().claim("iss", URI.create(issuerUri).toURL()) + .claim("aud", List.of("https://test-audience.com")), + jwtDecoder, + (validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class) + .satisfiesOnlyOnce(audClaimValidator())); }); } @@ -536,36 +548,39 @@ void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssue Supplier jwtDecoderSupplier = (Supplier) ReflectionTestUtils .getField(supplierJwtDecoderBean, "delegate"); JwtDecoder jwtDecoder = jwtDecoderSupplier.get(); - validate(issuerUri, jwtDecoder); + validate( + jwt().claim("iss", URI.create(issuerUri).toURL()) + .claim("aud", List.of("https://test-audience.com")), + jwtDecoder, + (validators) -> assertThat(validators).hasAtLeastOneElementOfType(JwtIssuerValidator.class) + .satisfiesOnlyOnce(audClaimValidator())); }); } @SuppressWarnings("unchecked") - private void validate(String issuerUri, JwtDecoder jwtDecoder) throws MalformedURLException { - DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils - .getField(jwtDecoder, "jwtValidator"); - Jwt.Builder builder = jwt().claim("aud", Collections.singletonList("https://test-audience.com")); - if (issuerUri != null) { - builder.claim("iss", new URL(issuerUri)); - } - Jwt jwt = builder.build(); - assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse(); - Collection> delegates = (Collection>) ReflectionTestUtils - .getField(jwtValidator, "tokenValidators"); - validateDelegates(issuerUri, delegates); - } - - private void validateDelegates(String issuerUri, Collection> delegates) { - assertThat(delegates).hasAtLeastOneElementOfType(JwtClaimValidator.class); - OAuth2TokenValidator delegatingValidator = delegates.stream() - .filter((v) -> v instanceof DelegatingOAuth2TokenValidator) - .findFirst() - .get(); - if (issuerUri != null) { - assertThat(delegatingValidator).extracting("tokenValidators") - .asInstanceOf(InstanceOfAssertFactories.collection(OAuth2TokenValidator.class)) - .hasAtLeastOneElementOfType(JwtIssuerValidator.class); - } + @Test + void autoConfigurationShouldConfigureCustomValidators() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String path = "test"; + String issuer = this.server.url(path).toString(); + String cleanIssuerPath = cleanIssuerPath(issuer); + setupMockResponse(cleanIssuerPath); + String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path; + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri) + .withUserConfiguration(CustomJwtClaimValidatorConfig.class) + .run((context) -> { + SupplierJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierJwtDecoder.class); + Supplier jwtDecoderSupplier = (Supplier) ReflectionTestUtils + .getField(supplierJwtDecoderBean, "delegate"); + JwtDecoder jwtDecoder = jwtDecoderSupplier.get(); + assertThat(context).hasBean("customJwtClaimValidator"); + OAuth2TokenValidator customValidator = (OAuth2TokenValidator) context + .getBean("customJwtClaimValidator"); + validate(jwt().claim("iss", URI.create(issuerUri).toURL()).claim("custom_claim", "custom_claim_value"), + jwtDecoder, (validators) -> assertThat(validators).contains(customValidator) + .hasAtLeastOneElementOfType(JwtIssuerValidator.class)); + }); } @Test @@ -582,7 +597,8 @@ void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPubli .run((context) -> { assertThat(context).hasSingleBean(JwtDecoder.class); JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); - validate(null, jwtDecoder); + validate(jwt().claim("aud", List.of("https://test-audience.com")), jwtDecoder, + (validators) -> assertThat(validators).satisfiesOnlyOnce(audClaimValidator())); }); } @@ -631,6 +647,68 @@ void opaqueTokenSecurityConfigurerBacksOffWhenSecurityFilterChainBeanIsPresent() .run((context) -> assertThat(context).hasSingleBean(SecurityFilterChain.class)); } + @ParameterizedTest(name = "{0}") + @ArgumentsSource(JwtConverterCustomizationsArgumentsProvider.class) + void autoConfigurationShouldConfigureResourceServerWithJwtConverterCustomizations(String[] properties, Jwt jwt, + String expectedPrincipal, String[] expectedAuthorities) { + this.contextRunner.withPropertyValues(properties).run((context) -> { + JwtAuthenticationConverter converter = context.getBean(JwtAuthenticationConverter.class); + AbstractAuthenticationToken token = converter.convert(jwt); + assertThat(token).isNotNull().extracting(AbstractAuthenticationToken::getName).isEqualTo(expectedPrincipal); + assertThat(token.getAuthorities()).extracting(GrantedAuthority::getAuthority) + .containsExactlyInAnyOrder(expectedAuthorities); + assertThat(context).hasSingleBean(JwtDecoder.class); + assertThat(getBearerTokenFilter(context)).isNotNull(); + }); + } + + @Test + void shouldNotConfigureJwtConverterIfNoPropertiesAreSet() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(JwtAuthenticationConverter.class)); + } + + @Test + void shouldConfigureJwtConverterIfPrincipalClaimNameIsSet() { + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.principal-claim-name=dummy") + .run((context) -> assertThat(context).hasSingleBean(JwtAuthenticationConverter.class)); + } + + @Test + void shouldConfigureJwtConverterIfAuthorityPrefixIsSet() { + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.authority-prefix=dummy") + .run((context) -> assertThat(context).hasSingleBean(JwtAuthenticationConverter.class)); + } + + @Test + void shouldConfigureJwtConverterIfAuthorityClaimsNameIsSet() { + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.authorities-claim-name=dummy") + .run((context) -> assertThat(context).hasSingleBean(JwtAuthenticationConverter.class)); + } + + @Test + void jwtAuthenticationConverterByJwtConfigIsConditionalOnMissingBean() { + String propertiesPrincipalClaim = "principal_from_properties"; + String propertiesPrincipalValue = "from_props"; + String userConfigPrincipalValue = "from_user_config"; + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.principal-claim-name=" + propertiesPrincipalClaim) + .withUserConfiguration(CustomJwtConverterConfig.class) + .run((context) -> { + JwtAuthenticationConverter converter = context.getBean(JwtAuthenticationConverter.class); + Jwt jwt = jwt().claim(propertiesPrincipalClaim, propertiesPrincipalValue) + .claim(CustomJwtConverterConfig.PRINCIPAL_CLAIM, userConfigPrincipalValue) + .build(); + AbstractAuthenticationToken token = converter.convert(jwt); + assertThat(token).isNotNull() + .extracting(AbstractAuthenticationToken::getName) + .isEqualTo(userConfigPrincipalValue) + .isNotEqualTo(propertiesPrincipalValue); + assertThat(context).hasSingleBean(JwtDecoder.class); + assertThat(getBearerTokenFilter(context)).isNotNull(); + }); + } + private Filter getBearerTokenFilter(AssertableWebApplicationContext context) { FilterChainProxy filterChain = (FilterChainProxy) context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN); List filterChains = filterChain.getFilterChains(); @@ -692,6 +770,37 @@ static Jwt.Builder jwt() { .subject("mock-test-subject"); } + @SuppressWarnings("unchecked") + private void validate(Jwt.Builder builder, JwtDecoder jwtDecoder, + ThrowingConsumer>> validatorsConsumer) { + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(jwtDecoder, "jwtValidator"); + assertThat(jwtValidator.validate(builder.build()).hasErrors()).isFalse(); + validatorsConsumer.accept(extractValidators(jwtValidator)); + } + + @SuppressWarnings("unchecked") + private List> extractValidators(DelegatingOAuth2TokenValidator delegatingValidator) { + Collection> delegates = (Collection>) ReflectionTestUtils + .getField(delegatingValidator, "tokenValidators"); + List> extracted = new ArrayList<>(); + for (OAuth2TokenValidator delegate : delegates) { + if (delegate instanceof DelegatingOAuth2TokenValidator delegatingDelegate) { + extracted.addAll(extractValidators(delegatingDelegate)); + } + else { + extracted.add(delegate); + } + } + return extracted; + } + + private Consumer> audClaimValidator() { + return (validator) -> assertThat(validator).isInstanceOf(JwtClaimValidator.class) + .extracting("claim") + .isEqualTo("aud"); + } + @Configuration(proxyBeanMethods = false) @EnableWebSecurity static class TestConfig { @@ -745,4 +854,28 @@ SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception } + @Configuration(proxyBeanMethods = false) + static class CustomJwtClaimValidatorConfig { + + @Bean + JwtClaimValidator customJwtClaimValidator() { + return new JwtClaimValidator<>("custom_claim", "custom_claim_value"::equals); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomJwtConverterConfig { + + static String PRINCIPAL_CLAIM = "principal_from_user_configuration"; + + @Bean + JwtAuthenticationConverter customJwtAuthenticationConverter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setPrincipalClaimName(PRINCIPAL_CLAIM); + return converter; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/server/servlet/OAuth2AuthorizationServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/server/servlet/OAuth2AuthorizationServerAutoConfigurationTests.java index 3f2a89e73769..c1c043eecdb2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/server/servlet/OAuth2AuthorizationServerAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/server/servlet/OAuth2AuthorizationServerAutoConfigurationTests.java @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -59,8 +60,11 @@ void autoConfigurationConditionalOnClassOauth2Authorization() { } @Test + @ClassPathExclusions({ "spring-security-oauth2-client-*.jar", "spring-security-oauth2-resource-server-*.jar", + "spring-security-saml2-service-provider-*.jar" }) void autoConfigurationDoesNotCauseUserDetailsServiceToBackOff() { - this.contextRunner.run((context) -> assertThat(context).hasBean("inMemoryUserDetailsManager")); + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(UserDetailsServiceAutoConfiguration.class) + .hasBean("inMemoryUserDetailsManager")); } @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfigurationTests.java index 4436ab7360e0..006f7b228f1a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveSecurityAutoConfigurationTests.java @@ -24,7 +24,11 @@ import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.web.reactive.config.WebFluxConfigurer; @@ -38,21 +42,46 @@ */ class ReactiveSecurityAutoConfigurationTests { - private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner(); + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class)); @Test void backsOffWhenWebFilterChainProxyBeanPresent() { - this.contextRunner.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class)) - .withUserConfiguration(WebFilterChainProxyConfiguration.class) + this.contextRunner.withUserConfiguration(WebFilterChainProxyConfiguration.class) .run((context) -> assertThat(context).hasSingleBean(WebFilterChainProxy.class)); } @Test - void enablesWebFluxSecurity() { + void autoConfiguresDenyAllReactiveAuthenticationManagerWhenNoAlternativeIsAvailable() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ReactiveSecurityAutoConfiguration.class) + .hasBean("denyAllAuthenticationManager")); + } + + @Test + void enablesWebFluxSecurityWhenUserDetailsServiceIsPresent() { + this.contextRunner.withUserConfiguration(UserDetailsServiceConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(WebFilterChainProxy.class); + assertThat(context).doesNotHaveBean("denyAllAuthenticationManager"); + }); + } + + @Test + void enablesWebFluxSecurityWhenReactiveAuthenticationManagerIsPresent() { this.contextRunner - .withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, - ReactiveUserDetailsServiceAutoConfiguration.class)) - .run((context) -> assertThat(context).getBean(WebFilterChainProxy.class).isNotNull()); + .withBean(ReactiveAuthenticationManager.class, () -> mock(ReactiveAuthenticationManager.class)) + .run((context) -> { + assertThat(context).hasSingleBean(WebFilterChainProxy.class); + assertThat(context).doesNotHaveBean("denyAllAuthenticationManager"); + }); + } + + @Test + void enablesWebFluxSecurityWhenSecurityWebFilterChainIsPresent() { + this.contextRunner.withBean(SecurityWebFilterChain.class, () -> mock(SecurityWebFilterChain.class)) + .run((context) -> { + assertThat(context).hasSingleBean(WebFilterChainProxy.class); + assertThat(context).doesNotHaveBean("denyAllAuthenticationManager"); + }); } @Test @@ -60,8 +89,7 @@ void autoConfigurationIsConditionalOnClass() { this.contextRunner .withClassLoader(new FilteredClassLoader(Flux.class, EnableWebFluxSecurity.class, WebFilterChainProxy.class, WebFluxConfigurer.class)) - .withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, - ReactiveUserDetailsServiceAutoConfiguration.class)) + .withUserConfiguration(UserDetailsServiceConfiguration.class) .run((context) -> assertThat(context).doesNotHaveBean(WebFilterChainProxy.class)); } @@ -75,4 +103,15 @@ WebFilterChainProxy webFilterChainProxy() { } + @Configuration(proxyBeanMethods = false) + static class UserDetailsServiceConfiguration { + + @Bean + MapReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsService( + User.withUsername("alice").password("secret").roles("admin").build()); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfigurationTests.java index e2389c322a89..1b673b6dc962 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/reactive/ReactiveUserDetailsServiceAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.autoconfigure.rsocket.RSocketStrategiesAutoConfiguration; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.context.annotation.Bean; @@ -39,6 +40,7 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector; @@ -58,15 +60,21 @@ class ReactiveUserDetailsServiceAutoConfigurationTests { @Test void configuresADefaultUser() { - this.contextRunner.withUserConfiguration(TestSecurityConfiguration.class).run((context) -> { - ReactiveUserDetailsService userDetailsService = context.getBean(ReactiveUserDetailsService.class); - assertThat(userDetailsService.findByUsername("user").block(Duration.ofSeconds(30))).isNotNull(); - }); + this.contextRunner + .withClassLoader( + new FilteredClassLoader(ClientRegistrationRepository.class, ReactiveOpaqueTokenIntrospector.class)) + .withUserConfiguration(TestSecurityConfiguration.class) + .run((context) -> { + ReactiveUserDetailsService userDetailsService = context.getBean(ReactiveUserDetailsService.class); + assertThat(userDetailsService.findByUsername("user").block(Duration.ofSeconds(30))).isNotNull(); + }); } @Test void userDetailsServiceWhenRSocketConfigured() { new ApplicationContextRunner() + .withClassLoader( + new FilteredClassLoader(ClientRegistrationRepository.class, ReactiveOpaqueTokenIntrospector.class)) .withConfiguration(AutoConfigurations.of(ReactiveUserDetailsServiceAutoConfiguration.class, RSocketMessagingAutoConfiguration.class, RSocketStrategiesAutoConfiguration.class)) .withUserConfiguration(TestRSocketSecurityConfiguration.class) @@ -109,20 +117,33 @@ void doesNotConfigureDefaultUserIfResourceServerWithJWTIsUsed() { } @Test - void doesNotConfigureDefaultUserIfResourceServerWithOpaqueIsUsed() { - this.contextRunner.withUserConfiguration(ReactiveOpaqueTokenIntrospectorConfiguration.class).run((context) -> { - assertThat(context).hasSingleBean(ReactiveOpaqueTokenIntrospector.class); - assertThat(context).doesNotHaveBean(ReactiveUserDetailsService.class); - }); + void doesNotConfigureDefaultUserIfResourceServerIsPresent() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ReactiveUserDetailsService.class)); + } + + @Test + void configuresDefaultUserWhenResourceServerIsPresentAndUsernameIsConfigured() { + this.contextRunner.withPropertyValues("spring.security.user.name=carol") + .run((context) -> assertThat(context).hasSingleBean(ReactiveUserDetailsService.class)); + } + + @Test + void configuresDefaultUserWhenResourceServerIsPresentAndPasswordIsConfigured() { + this.contextRunner.withPropertyValues("spring.security.user.password=p4ssw0rd") + .run((context) -> assertThat(context).hasSingleBean(ReactiveUserDetailsService.class)); } @Test void userDetailsServiceWhenPasswordEncoderAbsentAndDefaultPassword() { - this.contextRunner.withUserConfiguration(TestSecurityConfiguration.class).run(((context) -> { - MapReactiveUserDetailsService userDetailsService = context.getBean(MapReactiveUserDetailsService.class); - String password = userDetailsService.findByUsername("user").block(Duration.ofSeconds(30)).getPassword(); - assertThat(password).startsWith("{noop}"); - })); + this.contextRunner + .withClassLoader( + new FilteredClassLoader(ClientRegistrationRepository.class, ReactiveOpaqueTokenIntrospector.class)) + .withUserConfiguration(TestSecurityConfiguration.class) + .run(((context) -> { + MapReactiveUserDetailsService userDetailsService = context.getBean(MapReactiveUserDetailsService.class); + String password = userDetailsService.findByUsername("user").block(Duration.ofSeconds(30)).getPassword(); + assertThat(password).startsWith("{noop}"); + })); } @Test @@ -142,7 +163,10 @@ void userDetailsServiceWhenPasswordEncoderBeanPresent() { } private void testPasswordEncoding(Class configClass, String providedPassword, String expectedPassword) { - this.contextRunner.withUserConfiguration(configClass) + this.contextRunner + .withClassLoader( + new FilteredClassLoader(ClientRegistrationRepository.class, ReactiveOpaqueTokenIntrospector.class)) + .withUserConfiguration(configClass) .withPropertyValues("spring.security.user.password=" + providedPassword) .run(((context) -> { MapReactiveUserDetailsService userDetailsService = context.getBean(MapReactiveUserDetailsService.class); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/rsocket/RSocketSecurityAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/rsocket/RSocketSecurityAutoConfigurationTests.java index 95d807269000..a479bb5d1da7 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/rsocket/RSocketSecurityAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/rsocket/RSocketSecurityAutoConfigurationTests.java @@ -22,12 +22,15 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.rsocket.RSocketMessagingAutoConfiguration; import org.springframework.boot.autoconfigure.rsocket.RSocketStrategiesAutoConfiguration; -import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; import org.springframework.boot.rsocket.server.RSocketServerCustomizer; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.messaging.rsocket.annotation.support.RSocketMessageHandler; import org.springframework.security.config.annotation.rsocket.RSocketSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; import org.springframework.security.messaging.handler.invocation.reactive.AuthenticationPrincipalArgumentResolver; import org.springframework.security.rsocket.core.SecuritySocketAcceptorInterceptor; @@ -42,9 +45,9 @@ class RSocketSecurityAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(ReactiveUserDetailsServiceAutoConfiguration.class, - RSocketSecurityAutoConfiguration.class, RSocketMessagingAutoConfiguration.class, - RSocketStrategiesAutoConfiguration.class)); + .withConfiguration(AutoConfigurations.of(RSocketSecurityAutoConfiguration.class, + RSocketMessagingAutoConfiguration.class, RSocketStrategiesAutoConfiguration.class)) + .withUserConfiguration(UserDetailsServiceConfiguration.class); @Test void autoConfigurationEnablesRSocketSecurity() { @@ -81,4 +84,15 @@ void autoConfigurationAddsCustomizerForAuthenticationPrincipalArgumentResolver() }); } + @Configuration(proxyBeanMethods = false) + static class UserDetailsServiceConfiguration { + + @Bean + MapReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsService( + User.withUsername("alice").password("secret").roles("admin").build()); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java index b3f51121b243..5c8b4ae27da4 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java @@ -46,6 +46,8 @@ import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestFilter; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.filter.CompositeFilter; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -208,7 +210,7 @@ void relyingPartyRegistrationRepositoryShouldBeConditionalOnMissingBean() { @Test void samlLoginShouldBeConfigured() { this.contextRunner.withPropertyValues(getPropertyValues()) - .run((context) -> assertThat(hasFilter(context, Saml2WebSsoAuthenticationFilter.class)).isTrue()); + .run((context) -> assertThat(hasSecurityFilter(context, Saml2WebSsoAuthenticationFilter.class)).isTrue()); } @Test @@ -216,7 +218,7 @@ void samlLoginShouldBackOffWhenASecurityFilterChainBeanIsPresent() { this.contextRunner.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class)) .withUserConfiguration(TestSecurityFilterChainConfig.class) .withPropertyValues(getPropertyValues()) - .run((context) -> assertThat(hasFilter(context, Saml2WebSsoAuthenticationFilter.class)).isFalse()); + .run((context) -> assertThat(hasSecurityFilter(context, Saml2WebSsoAuthenticationFilter.class)).isFalse()); } @Test @@ -229,7 +231,7 @@ void samlLoginShouldShouldBeConditionalOnSecurityWebFilterClass() { @Test void samlLogoutShouldBeConfigured() { this.contextRunner.withPropertyValues(getPropertyValues()) - .run((context) -> assertThat(hasFilter(context, Saml2LogoutRequestFilter.class)).isTrue()); + .run((context) -> assertThat(hasSecurityFilter(context, Saml2LogoutRequestFilter.class)).isTrue()); } private String[] getPropertyValuesWithoutSigningCredentials(boolean signRequests) { @@ -323,11 +325,29 @@ private String[] getPropertyValues() { PREFIX + ".foo.acs.binding=redirect" }; } - private boolean hasFilter(AssertableWebApplicationContext context, Class filter) { - FilterChainProxy filterChain = (FilterChainProxy) context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN); - List filterChains = filterChain.getFilterChains(); - List filters = filterChains.get(0).getFilters(); - return filters.stream().anyMatch(filter::isInstance); + private boolean hasSecurityFilter(AssertableWebApplicationContext context, Class filter) { + return getSecurityFilterChain(context).getFilters().stream().anyMatch(filter::isInstance); + } + + private SecurityFilterChain getSecurityFilterChain(AssertableWebApplicationContext context) { + Filter springSecurityFilterChain = context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN, Filter.class); + FilterChainProxy filterChainProxy = getFilterChainProxy(springSecurityFilterChain); + SecurityFilterChain securityFilterChain = filterChainProxy.getFilterChains().get(0); + return securityFilterChain; + } + + private FilterChainProxy getFilterChainProxy(Filter filter) { + if (filter instanceof FilterChainProxy filterChainProxy) { + return filterChainProxy; + } + if (filter instanceof CompositeFilter) { + List filters = (List) ReflectionTestUtils.getField(filter, "filters"); + return (FilterChainProxy) filters.stream() + .filter(FilterChainProxy.class::isInstance) + .findFirst() + .orElseThrow(); + } + throw new IllegalStateException("No FilterChainProxy found"); } private void setupMockResponse(MockWebServer server, Resource resourceBody) throws Exception { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfigurationEarlyInitializationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfigurationEarlyInitializationTests.java index c186f9c009c2..40bcf8bddc53 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfigurationEarlyInitializationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfigurationEarlyInitializationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -37,6 +37,8 @@ import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; +import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; import org.springframework.context.annotation.Bean; @@ -63,6 +65,9 @@ class SecurityFilterAutoConfigurationEarlyInitializationTests { Pattern.MULTILINE); @Test + @DirtiesUrlFactories + @ClassPathExclusions({ "spring-security-oauth2-client-*.jar", "spring-security-oauth2-resource-server-*.jar", + "spring-security-saml2-service-provider-*.jar" }) void testSecurityFilterDoesNotCauseEarlyInitialization(CapturedOutput output) { try (AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext()) { TestPropertyValues.of("server.port:0").applyTo(context); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfigurationTests.java index b0375a3afc85..9cffa54313ec 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,13 +17,17 @@ package org.springframework.boot.autoconfigure.security.servlet; import java.util.Collections; +import java.util.function.Function; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; @@ -64,7 +68,7 @@ class UserDetailsServiceAutoConfigurationTests { @Test void testDefaultUsernamePassword(CapturedOutput output) { - this.contextRunner.run((context) -> { + this.contextRunner.with(noOtherFormsOfAuthenticationOnTheClasspath()).run((context) -> { UserDetailsService manager = context.getBean(UserDetailsService.class); assertThat(output).contains("Using generated security password:"); assertThat(manager.loadUserByUsername("user")).isNotNull(); @@ -126,11 +130,13 @@ void defaultUserNotCreatedIfResourceServerWithJWTIsUsed() { @Test void userDetailsServiceWhenPasswordEncoderAbsentAndDefaultPassword() { - this.contextRunner.withUserConfiguration(TestSecurityConfiguration.class).run(((context) -> { - InMemoryUserDetailsManager userDetailsService = context.getBean(InMemoryUserDetailsManager.class); - String password = userDetailsService.loadUserByUsername("user").getPassword(); - assertThat(password).startsWith("{noop}"); - })); + this.contextRunner.with(noOtherFormsOfAuthenticationOnTheClasspath()) + .withUserConfiguration(TestSecurityConfiguration.class) + .run(((context) -> { + InMemoryUserDetailsManager userDetailsService = context.getBean(InMemoryUserDetailsManager.class); + String password = userDetailsService.loadUserByUsername("user").getPassword(); + assertThat(password).startsWith("{noop}"); + })); } @Test @@ -150,20 +156,56 @@ void userDetailsServiceWhenPasswordEncoderBeanPresent() { } @Test - void userDetailsServiceWhenClientRegistrationRepositoryBeanPresent() { - this.contextRunner.withUserConfiguration(TestConfigWithClientRegistrationRepository.class) + void userDetailsServiceWhenClientRegistrationRepositoryPresent() { + this.contextRunner + .withClassLoader( + new FilteredClassLoader(OpaqueTokenIntrospector.class, RelyingPartyRegistrationRepository.class)) + .run(((context) -> assertThat(context).doesNotHaveBean(InMemoryUserDetailsManager.class))); + } + + @Test + void userDetailsServiceWhenOpaqueTokenIntrospectorPresent() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, + RelyingPartyRegistrationRepository.class)) .run(((context) -> assertThat(context).doesNotHaveBean(InMemoryUserDetailsManager.class))); } @Test - void userDetailsServiceWhenRelyingPartyRegistrationRepositoryBeanPresent() { + void userDetailsServiceWhenRelyingPartyRegistrationRepositoryPresent() { this.contextRunner - .withBean(RelyingPartyRegistrationRepository.class, () -> mock(RelyingPartyRegistrationRepository.class)) + .withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, OpaqueTokenIntrospector.class)) .run(((context) -> assertThat(context).doesNotHaveBean(InMemoryUserDetailsManager.class))); } + @Test + void userDetailsServiceWhenRelyingPartyRegistrationRepositoryPresentAndUsernameConfigured() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, OpaqueTokenIntrospector.class)) + .withPropertyValues("spring.security.user.name=alice") + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .run(((context) -> assertThat(context).hasSingleBean(InMemoryUserDetailsManager.class))); + } + + @Test + void userDetailsServiceWhenRelyingPartyRegistrationRepositoryPresentAndPasswordConfigured() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, OpaqueTokenIntrospector.class)) + .withPropertyValues("spring.security.user.password=secret") + .run(((context) -> assertThat(context).hasSingleBean(InMemoryUserDetailsManager.class))); + } + + private Function noOtherFormsOfAuthenticationOnTheClasspath() { + return (contextRunner) -> contextRunner + .withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, OpaqueTokenIntrospector.class, + RelyingPartyRegistrationRepository.class)); + } + private void testPasswordEncoding(Class configClass, String providedPassword, String expectedPassword) { - this.contextRunner.withUserConfiguration(configClass) + this.contextRunner.with(noOtherFormsOfAuthenticationOnTheClasspath()) + .withClassLoader(new FilteredClassLoader(ClientRegistrationRepository.class, OpaqueTokenIntrospector.class, + RelyingPartyRegistrationRepository.class)) + .withUserConfiguration(configClass) .withPropertyValues("spring.security.user.password=" + providedPassword) .run(((context) -> { InMemoryUserDetailsManager userDetailsService = context.getBean(InMemoryUserDetailsManager.class); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java new file mode 100644 index 000000000000..72d5f6ae3190 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/BundleContentPropertyTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link BundleContentProperty}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class BundleContentPropertyTests { + + private static final String PEM_TEXT = """ + -----BEGIN CERTIFICATE----- + -----END CERTIFICATE----- + """; + + @TempDir + Path temp; + + @Test + void isPemContentWhenValueIsPemTextReturnsTrue() { + BundleContentProperty property = new BundleContentProperty("name", PEM_TEXT); + assertThat(property.isPemContent()).isTrue(); + } + + @Test + void isPemContentWhenValueIsNotPemTextReturnsFalse() { + BundleContentProperty property = new BundleContentProperty("name", "file.pem"); + assertThat(property.isPemContent()).isFalse(); + } + + @Test + void hasValueWhenHasValueReturnsTrue() { + BundleContentProperty property = new BundleContentProperty("name", "file.pem"); + assertThat(property.hasValue()).isTrue(); + } + + @Test + void hasValueWhenHasNullValueReturnsFalse() { + BundleContentProperty property = new BundleContentProperty("name", null); + assertThat(property.hasValue()).isFalse(); + } + + @Test + void hasValueWhenHasEmptyValueReturnsFalse() { + BundleContentProperty property = new BundleContentProperty("name", ""); + assertThat(property.hasValue()).isFalse(); + } + + @Test + void toWatchPathWhenNotPathThrowsException() { + BundleContentProperty property = new BundleContentProperty("name", PEM_TEXT); + assertThatIllegalStateException().isThrownBy(property::toWatchPath) + .withMessage("Unable to convert value of property 'name' to a path"); + } + + @Test + void toWatchPathWhenPathReturnsPath() { + Path file = this.temp.toAbsolutePath().resolve("file.txt"); + BundleContentProperty property = new BundleContentProperty("name", file.toString()); + assertThat(property.toWatchPath()).isEqualTo(file); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java new file mode 100644 index 000000000000..c1516bdf6358 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +import java.security.cert.Certificate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CertificateMatcher}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class CertificateMatcherTests { + + @CertificateMatchingTest + void matchesWhenMatchReturnsTrue(CertificateMatchingTestSource source) { + CertificateMatcher matcher = new CertificateMatcher(source.privateKey()); + assertThat(matcher.matches(source.matchingCertificate())).isTrue(); + } + + @CertificateMatchingTest + void matchesWhenNoMatchReturnsFalse(CertificateMatchingTestSource source) { + CertificateMatcher matcher = new CertificateMatcher(source.privateKey()); + for (Certificate nonMatchingCertificate : source.nonMatchingCertificates()) { + assertThat(matcher.matches(nonMatchingCertificate)).isFalse(); + } + } + + @CertificateMatchingTest + void matchesAnyWhenNoneMatchReturnsFalse(CertificateMatchingTestSource source) { + CertificateMatcher matcher = new CertificateMatcher(source.privateKey()); + assertThat(matcher.matchesAny(source.nonMatchingCertificates())).isFalse(); + } + + @CertificateMatchingTest + void matchesAnyWhenOneMatchesReturnsTrue(CertificateMatchingTestSource source) { + CertificateMatcher matcher = new CertificateMatcher(source.privateKey()); + List certificates = new ArrayList<>(source.nonMatchingCertificates()); + certificates.add(source.matchingCertificate()); + assertThat(matcher.matchesAny(certificates)).isTrue(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java new file mode 100644 index 000000000000..fcf5e39d6534 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Annotation for a {@code ParameterizedTest @ParameterizedTest} with a + * {@link CertificateMatchingTestSource} parameter. + * + * @author Phillip Webb + */ +@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ParameterizedTest(name = "{0}") +@MethodSource("org.springframework.boot.autoconfigure.ssl.CertificateMatchingTestSource#create") +public @interface CertificateMatchingTest { + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java new file mode 100644 index 000000000000..e04f5651fa0a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.NamedParameterSpec; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Source used with {@link CertificateMatchingTest @CertificateMatchingTest} annotated + * tests that provides access to useful test material. + * + * @param algorithm the algorithm + * @param privateKey the private key to use for matching + * @param matchingCertificate a certificate that matches the private key + * @param nonMatchingCertificates a list of certificate that do not match the private key + * @param nonMatchingPrivateKeys a list of private keys that do not match the certificate + * @author Moritz Halbritter + * @author Phillip Webb + */ +record CertificateMatchingTestSource(CertificateMatchingTestSource.Algorithm algorithm, PrivateKey privateKey, + X509Certificate matchingCertificate, List nonMatchingCertificates, + List nonMatchingPrivateKeys) { + + private static final List ALGORITHMS; + static { + List algorithms = new ArrayList<>(); + Stream.of("RSA", "DSA", "ed25519", "ed448").map(Algorithm::of).forEach(algorithms::add); + Stream.of("secp256r1", "secp521r1").map(Algorithm::ec).forEach(algorithms::add); + ALGORITHMS = List.copyOf(algorithms); + } + + CertificateMatchingTestSource(Algorithm algorithm, KeyPair matchingKeyPair, List nonMatchingKeyPairs) { + this(algorithm, matchingKeyPair.getPrivate(), asCertificate(matchingKeyPair), + nonMatchingKeyPairs.stream().map(CertificateMatchingTestSource::asCertificate).toList(), + nonMatchingKeyPairs.stream().map(KeyPair::getPrivate).toList()); + } + + private static X509Certificate asCertificate(KeyPair keyPair) { + X509Certificate certificate = mock(X509Certificate.class); + given(certificate.getPublicKey()).willReturn(keyPair.getPublic()); + return certificate; + } + + @Override + public String toString() { + return this.algorithm.toString(); + } + + static List create() + throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + Map keyPairs = new LinkedHashMap<>(); + for (Algorithm algorithm : ALGORITHMS) { + keyPairs.put(algorithm, algorithm.generateKeyPair()); + } + List parameters = new ArrayList<>(); + keyPairs.forEach((algorith, matchingKeyPair) -> { + List nonMatchingKeyPairs = new ArrayList<>(keyPairs.values()); + nonMatchingKeyPairs.remove(matchingKeyPair); + parameters.add(new CertificateMatchingTestSource(algorith, matchingKeyPair, nonMatchingKeyPairs)); + }); + return List.copyOf(parameters); + } + + /** + * An individual algorithm. + * + * @param name the algorithm name + * @param spec the algorithm spec or {@code null} + */ + record Algorithm(String name, AlgorithmParameterSpec spec) { + + KeyPair generateKeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + KeyPairGenerator generator = KeyPairGenerator.getInstance(this.name); + if (this.spec != null) { + generator.initialize(this.spec); + } + return generator.generateKeyPair(); + } + + @Override + public String toString() { + String spec = (this.spec instanceof NamedParameterSpec namedSpec) ? namedSpec.getName() : ""; + return this.name + ((!spec.isEmpty()) ? ":" + spec : ""); + } + + static Algorithm of(String name) { + return new Algorithm(name, null); + } + + static Algorithm ec(String curve) { + return new Algorithm("EC", new ECGenParameterSpec(curve)); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java new file mode 100644 index 000000000000..5b3e18e3f79e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java @@ -0,0 +1,200 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.fail; + +/** + * Tests for {@link FileWatcher}. + * + * @author Moritz Halbritter + */ +class FileWatcherTests { + + private FileWatcher fileWatcher; + + @BeforeEach + void setUp() { + this.fileWatcher = new FileWatcher(Duration.ofMillis(10)); + } + + @AfterEach + void tearDown() throws IOException { + this.fileWatcher.close(); + } + + @Test + void shouldTriggerOnFileCreation(@TempDir Path tempDir) throws Exception { + Path newFile = tempDir.resolve("new-file.txt"); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(tempDir), callback); + Files.createFile(newFile); + callback.expectChanges(); + } + + @Test + void shouldTriggerOnFileDeletion(@TempDir Path tempDir) throws Exception { + Path deletedFile = tempDir.resolve("deleted-file.txt"); + Files.createFile(deletedFile); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(tempDir), callback); + Files.delete(deletedFile); + callback.expectChanges(); + } + + @Test + void shouldTriggerOnFileModification(@TempDir Path tempDir) throws Exception { + Path deletedFile = tempDir.resolve("modified-file.txt"); + Files.createFile(deletedFile); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(tempDir), callback); + Files.writeString(deletedFile, "Some content"); + callback.expectChanges(); + } + + @Test + void shouldWatchFile(@TempDir Path tempDir) throws Exception { + Path watchedFile = tempDir.resolve("watched.txt"); + Files.createFile(watchedFile); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(watchedFile), callback); + Files.writeString(watchedFile, "Some content"); + callback.expectChanges(); + } + + @Test + void shouldIgnoreNotWatchedFiles(@TempDir Path tempDir) throws Exception { + Path watchedFile = tempDir.resolve("watched.txt"); + Path notWatchedFile = tempDir.resolve("not-watched.txt"); + Files.createFile(watchedFile); + Files.createFile(notWatchedFile); + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(watchedFile), callback); + Files.writeString(notWatchedFile, "Some content"); + callback.expectNoChanges(); + } + + @Test + void shouldFailIfDirectoryOrFileDoesNotExist(@TempDir Path tempDir) { + Path directory = tempDir.resolve("dir1"); + assertThatExceptionOfType(UncheckedIOException.class) + .isThrownBy(() -> this.fileWatcher.watch(Set.of(directory), new WaitingCallback())) + .withMessage("Failed to register paths for watching: [%s]".formatted(directory)); + } + + @Test + void shouldNotFailIfDirectoryIsRegisteredMultipleTimes(@TempDir Path tempDir) { + WaitingCallback callback = new WaitingCallback(); + assertThatCode(() -> { + this.fileWatcher.watch(Set.of(tempDir), callback); + this.fileWatcher.watch(Set.of(tempDir), callback); + }).doesNotThrowAnyException(); + } + + @Test + void shouldNotFailIfStoppedMultipleTimes(@TempDir Path tempDir) { + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(tempDir), callback); + assertThatCode(() -> { + this.fileWatcher.close(); + this.fileWatcher.close(); + }).doesNotThrowAnyException(); + } + + @Test + void testRelativeFiles() throws Exception { + Path watchedFile = Path.of(UUID.randomUUID() + ".txt"); + Files.createFile(watchedFile); + try { + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(watchedFile), callback); + Files.delete(watchedFile); + callback.expectChanges(); + } + finally { + Files.deleteIfExists(watchedFile); + } + } + + @Test + void testRelativeDirectories() throws Exception { + Path watchedDirectory = Path.of(UUID.randomUUID() + "/"); + Path file = watchedDirectory.resolve("file.txt"); + Files.createDirectory(watchedDirectory); + try { + WaitingCallback callback = new WaitingCallback(); + this.fileWatcher.watch(Set.of(watchedDirectory), callback); + Files.createFile(file); + callback.expectChanges(); + } + finally { + Files.deleteIfExists(file); + Files.deleteIfExists(watchedDirectory); + } + } + + private static final class WaitingCallback implements Runnable { + + private final CountDownLatch latch = new CountDownLatch(1); + + volatile boolean changed = false; + + @Override + public void run() { + this.changed = true; + this.latch.countDown(); + } + + void expectChanges() throws InterruptedException { + waitForChanges(true); + assertThat(this.changed).as("changed").isTrue(); + } + + void expectNoChanges() throws InterruptedException { + waitForChanges(false); + assertThat(this.changed).as("changed").isFalse(); + } + + void waitForChanges(boolean fail) throws InterruptedException { + if (!this.latch.await(5, TimeUnit.SECONDS)) { + if (fail) { + fail("Timeout while waiting for changes"); + } + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundleTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundleTests.java index 1b745328310a..d6b770a3d927 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundleTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundleTests.java @@ -20,12 +20,15 @@ import java.security.KeyStore; import java.security.cert.Certificate; import java.util.Set; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; import org.springframework.boot.ssl.SslBundle; +import org.springframework.util.function.ThrowingConsumer; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link PropertiesSslBundle}. @@ -35,6 +38,8 @@ */ class PropertiesSslBundleTests { + private static final char[] EMPTY_KEY_PASSWORD = new char[] {}; + @Test void pemPropertiesAreMappedToSslBundle() throws Exception { PemSslBundleProperties properties = new PemSslBundleProperties(); @@ -61,10 +66,10 @@ void pemPropertiesAreMappedToSslBundle() throws Exception { Certificate certificate = sslBundle.getStores().getKeyStore().getCertificate("alias"); assertThat(certificate).isNotNull(); assertThat(certificate.getType()).isEqualTo("X.509"); - Key key = sslBundle.getStores().getKeyStore().getKey("alias", null); + Key key = sslBundle.getStores().getKeyStore().getKey("alias", "secret".toCharArray()); assertThat(key).isNotNull(); assertThat(key.getAlgorithm()).isEqualTo("RSA"); - certificate = sslBundle.getStores().getTrustStore().getCertificate("alias"); + certificate = sslBundle.getStores().getTrustStore().getCertificate("ssl"); assertThat(certificate).isNotNull(); assertThat(certificate.getType()).isEqualTo("X.509"); } @@ -99,4 +104,47 @@ void jksPropertiesAreMappedToSslBundle() { assertThat(trustStore.getProvider().getName()).isEqualTo("SUN"); } + @Test + void getWithPemSslBundlePropertiesWhenVerifyKeyStoreAgainstSingleCertificateWithMatchCreatesBundle() { + PemSslBundleProperties properties = new PemSslBundleProperties(); + properties.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/key1.crt"); + properties.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/key1.pem"); + properties.getKeystore().setVerifyKeys(true); + properties.getKey().setAlias("test-alias"); + SslBundle bundle = PropertiesSslBundle.get(properties); + assertThat(bundle.getStores().getKeyStore()).satisfies(storeContainingCertAndKey("test-alias")); + } + + @Test + void getWithPemSslBundlePropertiesWhenVerifyKeyStoreAgainstCertificateChainWithMatchCreatesBundle() { + PemSslBundleProperties properties = new PemSslBundleProperties(); + properties.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/key2-chain.crt"); + properties.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/key2.pem"); + properties.getKeystore().setVerifyKeys(true); + properties.getKey().setAlias("test-alias"); + SslBundle bundle = PropertiesSslBundle.get(properties); + assertThat(bundle.getStores().getKeyStore()).satisfies(storeContainingCertAndKey("test-alias")); + } + + @Test + void getWithPemSslBundlePropertiesWhenVerifyKeyStoreWithNoMatchThrowsException() { + PemSslBundleProperties properties = new PemSslBundleProperties(); + properties.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/key2.crt"); + properties.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/key1.pem"); + properties.getKeystore().setVerifyKeys(true); + properties.getKey().setAlias("test-alias"); + assertThatIllegalStateException().isThrownBy(() -> PropertiesSslBundle.get(properties)) + .withMessageContaining("Private key in keystore matches none of the certificates"); + } + + private Consumer storeContainingCertAndKey(String keyAlias) { + return ThrowingConsumer.of((keyStore) -> { + assertThat(keyStore).isNotNull(); + assertThat(keyStore.getType()).isEqualTo(KeyStore.getDefaultType()); + assertThat(keyStore.containsAlias(keyAlias)).isTrue(); + assertThat(keyStore.getCertificate(keyAlias)).isNotNull(); + assertThat(keyStore.getKey(keyAlias, EMPTY_KEY_PASSWORD)).isNotNull(); + }); + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java new file mode 100644 index 000000000000..759bb474609b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrarTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2012-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.boot.autoconfigure.ssl; + +import java.nio.file.Path; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.ssl.SslBundleRegistry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.assertArg; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +/** + * Tests for {@link SslPropertiesBundleRegistrar}. + * + * @author Moritz Halbritter + */ +class SslPropertiesBundleRegistrarTests { + + private SslPropertiesBundleRegistrar registrar; + + private FileWatcher fileWatcher; + + private SslProperties properties; + + private SslBundleRegistry registry; + + @BeforeEach + void setUp() { + this.properties = new SslProperties(); + this.fileWatcher = Mockito.mock(FileWatcher.class); + this.registrar = new SslPropertiesBundleRegistrar(this.properties, this.fileWatcher); + this.registry = Mockito.mock(SslBundleRegistry.class); + } + + @Test + void shouldWatchJksBundles() { + JksSslBundleProperties jks = new JksSslBundleProperties(); + jks.setReloadOnUpdate(true); + jks.getKeystore().setLocation("classpath:test.jks"); + jks.getKeystore().setPassword("secret"); + jks.getTruststore().setLocation("classpath:test.jks"); + jks.getTruststore().setPassword("secret"); + this.properties.getBundle().getJks().put("bundle1", jks); + this.registrar.registerBundles(this.registry); + then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any()); + then(this.fileWatcher).should().watch(assertArg((set) -> pathEndingWith(set, "test.jks")), any()); + } + + @Test + void shouldWatchPemBundles() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/rsa-cert.pem"); + pem.getKeystore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/rsa-key.pem"); + pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem"); + pem.getTruststore().setPrivateKey("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-key.pem"); + this.properties.getBundle().getPem().put("bundle1", pem); + this.registrar.registerBundles(this.registry); + then(this.registry).should(times(1)).registerBundle(eq("bundle1"), any()); + then(this.fileWatcher).should() + .watch(assertArg((set) -> pathEndingWith(set, "rsa-cert.pem", "rsa-key.pem")), any()); + } + + @Test + void shouldFailIfPemKeystoreCertificateIsEmbedded() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getKeystore().setCertificate(""" + -----BEGIN CERTIFICATE----- + MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ + BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l + MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O + YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4 + MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD + VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv + bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA + Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv + EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03 + k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD + 7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM= + -----END CERTIFICATE----- + """.strip()); + this.properties.getBundle().getPem().put("bundle1", pem); + assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry)) + .withMessageContaining("Unable to register SSL bundle 'bundle1'") + .havingCause() + .withMessage("Unable to watch for reload on update"); + } + + @Test + void shouldFailIfPemKeystorePrivateKeyIsEmbedded() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getKeystore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem"); + pem.getKeystore().setPrivateKey(""" + -----BEGIN PRIVATE KEY----- + MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh + -----END PRIVATE KEY----- + """.strip()); + this.properties.getBundle().getPem().put("bundle1", pem); + assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry)) + .withMessageContaining("Unable to register SSL bundle 'bundle1'") + .havingCause() + .withMessage("Unable to watch for reload on update"); + } + + @Test + void shouldFailIfPemTruststoreCertificateIsEmbedded() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getTruststore().setCertificate(""" + -----BEGIN CERTIFICATE----- + MIICCzCCAb2gAwIBAgIUZbDi7G5czH+Yi0k2EMWxdf00XagwBQYDK2VwMHsxCzAJ + BgNVBAYTAlhYMRIwEAYDVQQIDAlTdGF0ZU5hbWUxETAPBgNVBAcMCENpdHlOYW1l + MRQwEgYDVQQKDAtDb21wYW55TmFtZTEbMBkGA1UECwwSQ29tcGFueVNlY3Rpb25O + YW1lMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjMwOTExMTIxNDMwWhcNMzMwOTA4 + MTIxNDMwWjB7MQswCQYDVQQGEwJYWDESMBAGA1UECAwJU3RhdGVOYW1lMREwDwYD + VQQHDAhDaXR5TmFtZTEUMBIGA1UECgwLQ29tcGFueU5hbWUxGzAZBgNVBAsMEkNv + bXBhbnlTZWN0aW9uTmFtZTESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA + Q/DDA4BSgZ+Hx0DUxtIRjVjN+OcxXVURwAWc3Gt9GUyjUzBRMB0GA1UdDgQWBBSv + EdpoaBMBoxgO96GFbf03k07DSTAfBgNVHSMEGDAWgBSvEdpoaBMBoxgO96GFbf03 + k07DSTAPBgNVHRMBAf8EBTADAQH/MAUGAytlcANBAHMXDkGd57d4F4cRk/8UjhxD + 7OtRBZfdfznSvlhJIMNfH5q0zbC2eO3hWCB3Hrn/vIeswGP8Ov4AJ6eXeX44BQM= + -----END CERTIFICATE----- + """.strip()); + this.properties.getBundle().getPem().put("bundle1", pem); + assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry)) + .withMessageContaining("Unable to register SSL bundle 'bundle1'") + .havingCause() + .withMessage("Unable to watch for reload on update"); + } + + @Test + void shouldFailIfPemTruststorePrivateKeyIsEmbedded() { + PemSslBundleProperties pem = new PemSslBundleProperties(); + pem.setReloadOnUpdate(true); + pem.getTruststore().setCertificate("classpath:org/springframework/boot/autoconfigure/ssl/ed25519-cert.pem"); + pem.getTruststore().setPrivateKey(""" + -----BEGIN PRIVATE KEY----- + MC4CAQAwBQYDK2VwBCIEIC29RnMVTcyqXEAIO1b/6p7RdbM6TiqvnztVQ4IxYxUh + -----END PRIVATE KEY----- + """.strip()); + this.properties.getBundle().getPem().put("bundle1", pem); + assertThatIllegalStateException().isThrownBy(() -> this.registrar.registerBundles(this.registry)) + .withMessageContaining("Unable to register SSL bundle 'bundle1'") + .havingCause() + .withMessage("Unable to watch for reload on update"); + } + + private void pathEndingWith(Set paths, String... suffixes) { + for (String suffix : suffixes) { + assertThat(paths).anyMatch((path) -> path.getFileName().toString().endsWith(suffix)); + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java index c1d0507903ac..2ba81d5afeaa 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,23 +17,31 @@ package org.springframework.boot.autoconfigure.task; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder; import org.springframework.boot.task.TaskExecutorBuilder; import org.springframework.boot.task.TaskExecutorCustomizer; +import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.core.task.SyncTaskExecutor; import org.springframework.core.task.TaskDecorator; import org.springframework.core.task.TaskExecutor; @@ -51,13 +59,35 @@ * * @author Stephane Nicoll * @author Camille Vienot + * @author Moritz Halbritter + * @author Yanming Zhou */ @ExtendWith(OutputCaptureExtension.class) +@SuppressWarnings("removal") class TaskExecutionAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class)); + @Test + void shouldSupplyBeans() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(TaskExecutorBuilder.class); + assertThat(context).hasSingleBean(ThreadPoolTaskExecutorBuilder.class); + assertThat(context).hasSingleBean(ThreadPoolTaskExecutor.class); + assertThat(context).hasSingleBean(SimpleAsyncTaskExecutorBuilder.class); + }); + } + + @Test + void shouldNotSupplyThreadPoolTaskExecutorBuilderIfCustomTaskExecutorBuilderIsPresent() { + this.contextRunner.withBean(TaskExecutorBuilder.class, TaskExecutorBuilder::new).run((context) -> { + assertThat(context).hasSingleBean(TaskExecutorBuilder.class); + assertThat(context).doesNotHaveBean(ThreadPoolTaskExecutorBuilder.class); + assertThat(context).hasSingleBean(ThreadPoolTaskExecutor.class); + }); + } + @Test void taskExecutorBuilderShouldApplyCustomSettings() { this.contextRunner @@ -79,6 +109,42 @@ void taskExecutorBuilderShouldApplyCustomSettings() { })); } + @Test + void simpleAsyncTaskExecutorBuilderShouldReadProperties() { + this.contextRunner + .withPropertyValues("spring.task.execution.thread-name-prefix=mytest-", + "spring.task.execution.simple.concurrency-limit=1", + "spring.task.execution.shutdown.await-termination=true", + "spring.task.execution.shutdown.await-termination-period=30s") + .run(assertSimpleAsyncTaskExecutor((taskExecutor) -> { + assertThat(taskExecutor.getConcurrencyLimit()).isEqualTo(1); + assertThat(taskExecutor.getThreadNamePrefix()).isEqualTo("mytest-"); + assertThat(taskExecutor).hasFieldOrPropertyWithValue("taskTerminationTimeout", 30000L); + })); + } + + @Test + void threadPoolTaskExecutorBuilderShouldApplyCustomSettings() { + this.contextRunner.withPropertyValues("spring.task.execution.pool.queue-capacity=10", + "spring.task.execution.pool.core-size=2", "spring.task.execution.pool.max-size=4", + "spring.task.execution.pool.allow-core-thread-timeout=true", "spring.task.execution.pool.keep-alive=5s", + "spring.task.execution.pool.shutdown.accept-tasks-after-context-close=true", + "spring.task.execution.shutdown.await-termination=true", + "spring.task.execution.shutdown.await-termination-period=30s", + "spring.task.execution.thread-name-prefix=mytest-") + .run(assertThreadPoolTaskExecutor((taskExecutor) -> { + assertThat(taskExecutor).hasFieldOrPropertyWithValue("queueCapacity", 10); + assertThat(taskExecutor.getCorePoolSize()).isEqualTo(2); + assertThat(taskExecutor.getMaxPoolSize()).isEqualTo(4); + assertThat(taskExecutor).hasFieldOrPropertyWithValue("allowCoreThreadTimeOut", true); + assertThat(taskExecutor.getKeepAliveSeconds()).isEqualTo(5); + assertThat(taskExecutor).hasFieldOrPropertyWithValue("acceptTasksAfterContextClose", true); + assertThat(taskExecutor).hasFieldOrPropertyWithValue("waitForTasksToCompleteOnShutdown", true); + assertThat(taskExecutor).hasFieldOrPropertyWithValue("awaitTerminationMillis", 30000L); + assertThat(taskExecutor.getThreadNamePrefix()).isEqualTo("mytest-"); + })); + } + @Test void taskExecutorBuilderWhenHasCustomBuilderShouldUseCustomBuilder() { this.contextRunner.withUserConfiguration(CustomTaskExecutorBuilderConfig.class).run((context) -> { @@ -88,6 +154,15 @@ void taskExecutorBuilderWhenHasCustomBuilderShouldUseCustomBuilder() { }); } + @Test + void threadPoolTaskExecutorBuilderWhenHasCustomBuilderShouldUseCustomBuilder() { + this.contextRunner.withUserConfiguration(CustomThreadPoolTaskExecutorBuilderConfig.class).run((context) -> { + assertThat(context).hasSingleBean(ThreadPoolTaskExecutorBuilder.class); + assertThat(context.getBean(ThreadPoolTaskExecutorBuilder.class)) + .isSameAs(context.getBean(CustomThreadPoolTaskExecutorBuilderConfig.class).builder); + }); + } + @Test void taskExecutorBuilderShouldUseTaskDecorator() { this.contextRunner.withUserConfiguration(TaskDecoratorConfig.class).run((context) -> { @@ -98,7 +173,16 @@ void taskExecutorBuilderShouldUseTaskDecorator() { } @Test - void taskExecutorAutoConfiguredIsLazy() { + void threadPoolTaskExecutorBuilderShouldUseTaskDecorator() { + this.contextRunner.withUserConfiguration(TaskDecoratorConfig.class).run((context) -> { + assertThat(context).hasSingleBean(ThreadPoolTaskExecutorBuilder.class); + ThreadPoolTaskExecutor executor = context.getBean(ThreadPoolTaskExecutorBuilder.class).build(); + assertThat(executor).extracting("taskDecorator").isSameAs(context.getBean(TaskDecorator.class)); + }); + } + + @Test + void whenThreadPoolTaskExecutorIsAutoConfiguredThenItIsLazy() { this.contextRunner.run((context) -> { assertThat(context).hasSingleBean(Executor.class).hasBean("applicationTaskExecutor"); BeanDefinition beanDefinition = context.getSourceApplicationContext() @@ -109,6 +193,68 @@ void taskExecutorAutoConfiguredIsLazy() { }); } + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenVirtualThreadsAreEnabledThenSimpleAsyncTaskExecutorWithVirtualThreadsIsAutoConfigured() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + assertThat(context).hasSingleBean(Executor.class).hasBean("applicationTaskExecutor"); + assertThat(context).getBean("applicationTaskExecutor").isInstanceOf(SimpleAsyncTaskExecutor.class); + SimpleAsyncTaskExecutor taskExecutor = context.getBean("applicationTaskExecutor", + SimpleAsyncTaskExecutor.class); + assertThat(virtualThreadName(taskExecutor)).startsWith("task-"); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenTaskNamePrefixIsConfiguredThenSimpleAsyncTaskExecutorWithVirtualThreadsUsesIt() { + this.contextRunner + .withPropertyValues("spring.threads.virtual.enabled=true", + "spring.task.execution.thread-name-prefix=custom-") + .run((context) -> { + SimpleAsyncTaskExecutor taskExecutor = context.getBean("applicationTaskExecutor", + SimpleAsyncTaskExecutor.class); + assertThat(virtualThreadName(taskExecutor)).startsWith("custom-"); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenVirtualThreadsAreAvailableButNotEnabledThenThreadPoolTaskExecutorIsAutoConfigured() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(Executor.class).hasBean("applicationTaskExecutor"); + assertThat(context).getBean("applicationTaskExecutor").isInstanceOf(ThreadPoolTaskExecutor.class); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenTaskDecoratorIsDefinedThenSimpleAsyncTaskExecutorWithVirtualThreadsUsesIt() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true") + .withUserConfiguration(TaskDecoratorConfig.class) + .run((context) -> { + SimpleAsyncTaskExecutor executor = context.getBean(SimpleAsyncTaskExecutor.class); + assertThat(executor).extracting("taskDecorator").isSameAs(context.getBean(TaskDecorator.class)); + }); + } + + @Test + void simpleAsyncTaskExecutorBuilderUsesPlatformThreadsByDefault() { + this.contextRunner.run((context) -> { + SimpleAsyncTaskExecutorBuilder builder = context.getBean(SimpleAsyncTaskExecutorBuilder.class); + assertThat(builder).hasFieldOrPropertyWithValue("virtualThreads", null); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void simpleAsyncTaskExecutorBuilderUsesVirtualThreadsWhenEnabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + SimpleAsyncTaskExecutorBuilder builder = context.getBean(SimpleAsyncTaskExecutorBuilder.class); + assertThat(builder).hasFieldOrPropertyWithValue("virtualThreads", true); + }); + } + @Test void taskExecutorWhenHasCustomTaskExecutorShouldBackOff() { this.contextRunner.withUserConfiguration(CustomTaskExecutorConfig.class).run((context) -> { @@ -117,6 +263,17 @@ void taskExecutorWhenHasCustomTaskExecutorShouldBackOff() { }); } + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenVirtualThreadsAreEnabledAndCustomTaskExecutorIsDefinedThenSimpleAsyncTaskExecutorThatUsesVirtualThreadsBacksOff() { + this.contextRunner.withUserConfiguration(CustomTaskExecutorConfig.class) + .withPropertyValues("spring.threads.virtual.enabled=true") + .run((context) -> { + assertThat(context).hasSingleBean(Executor.class); + assertThat(context.getBean(Executor.class)).isSameAs(context.getBean("customTaskExecutor")); + }); + } + @Test void taskExecutorBuilderShouldApplyCustomizer() { this.contextRunner.withUserConfiguration(TaskExecutorCustomizerConfig.class).run((context) -> { @@ -126,6 +283,15 @@ void taskExecutorBuilderShouldApplyCustomizer() { }); } + @Test + void threadPoolTaskExecutorBuilderShouldApplyCustomizer() { + this.contextRunner.withUserConfiguration(TaskExecutorCustomizerConfig.class).run((context) -> { + TaskExecutorCustomizer customizer = context.getBean(TaskExecutorCustomizer.class); + ThreadPoolTaskExecutor executor = context.getBean(ThreadPoolTaskExecutorBuilder.class).build(); + then(customizer).should().customize(executor); + }); + } + @Test void enableAsyncUsesAutoConfiguredOneByDefault() { this.contextRunner.withPropertyValues("spring.task.execution.thread-name-prefix=task-test-") @@ -150,6 +316,25 @@ void enableAsyncUsesAutoConfiguredOneByDefaultEvenThoughSchedulingIsConfigured() }); } + @Test + void customTaskExecutorBuilderOverridesThreadPoolTaskExecutorBuilder() { + this.contextRunner.withUserConfiguration(CustomTaskExecutorBuilderConfig.class).run((context) -> { + ThreadPoolTaskExecutor bean = context.getBean(ThreadPoolTaskExecutor.class); + assertThat(bean.getThreadNamePrefix()).isEqualTo("CustomTaskExecutorBuilderConfig-"); + }); + } + + @Test + void threadPoolTaskExecutorBuilderAppliesTaskExecutorCustomizer() { + this.contextRunner + .withBean(TaskExecutorCustomizer.class, + () -> (taskExecutor) -> taskExecutor.setThreadNamePrefix("custom-prefix-")) + .run((context) -> { + ThreadPoolTaskExecutor bean = context.getBean(ThreadPoolTaskExecutor.class); + assertThat(bean.getThreadNamePrefix()).isEqualTo("custom-prefix-"); + }); + } + private ContextConsumer assertTaskExecutor( Consumer taskExecutor) { return (context) -> { @@ -159,10 +344,43 @@ private ContextConsumer assertTaskExecutor( }; } + private ContextConsumer assertThreadPoolTaskExecutor( + Consumer taskExecutor) { + return (context) -> { + assertThat(context).hasSingleBean(ThreadPoolTaskExecutorBuilder.class); + ThreadPoolTaskExecutorBuilder builder = context.getBean(ThreadPoolTaskExecutorBuilder.class); + taskExecutor.accept(builder.build()); + }; + } + + private ContextConsumer assertSimpleAsyncTaskExecutor( + Consumer taskExecutor) { + return (context) -> { + assertThat(context).hasSingleBean(SimpleAsyncTaskExecutorBuilder.class); + SimpleAsyncTaskExecutorBuilder builder = context.getBean(SimpleAsyncTaskExecutorBuilder.class); + taskExecutor.accept(builder.build()); + }; + } + + private String virtualThreadName(SimpleAsyncTaskExecutor taskExecutor) throws InterruptedException { + AtomicReference threadReference = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + taskExecutor.execute(() -> { + Thread currentThread = Thread.currentThread(); + threadReference.set(currentThread); + latch.countDown(); + }); + assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); + Thread thread = threadReference.get(); + assertThat(thread).extracting("virtual").as("%s is virtual", thread).isEqualTo(true); + return thread.getName(); + } + @Configuration(proxyBeanMethods = false) static class CustomTaskExecutorBuilderConfig { - private final TaskExecutorBuilder taskExecutorBuilder = new TaskExecutorBuilder(); + private final TaskExecutorBuilder taskExecutorBuilder = new TaskExecutorBuilder() + .threadNamePrefix("CustomTaskExecutorBuilderConfig-"); @Bean TaskExecutorBuilder customTaskExecutorBuilder() { @@ -171,6 +389,18 @@ TaskExecutorBuilder customTaskExecutorBuilder() { } + @Configuration(proxyBeanMethods = false) + static class CustomThreadPoolTaskExecutorBuilderConfig { + + private final ThreadPoolTaskExecutorBuilder builder = new ThreadPoolTaskExecutorBuilder(); + + @Bean + ThreadPoolTaskExecutorBuilder customThreadPoolTaskExecutorBuilder() { + return this.builder; + } + + } + @Configuration(proxyBeanMethods = false) static class TaskExecutorCustomizerConfig { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfigurationTests.java index 990e8cb6dbe8..57ed26c15ca9 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/task/TaskSchedulingAutoConfigurationTests.java @@ -26,12 +26,20 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import org.assertj.core.api.InstanceOfAssertFactories; import org.awaitility.Awaitility; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.springframework.boot.LazyInitializationBeanFactoryPostProcessor; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder; +import org.springframework.boot.task.SimpleAsyncTaskSchedulerCustomizer; +import org.springframework.boot.task.TaskSchedulerBuilder; import org.springframework.boot.task.TaskSchedulerCustomizer; +import org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder; +import org.springframework.boot.task.ThreadPoolTaskSchedulerCustomizer; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -49,7 +57,9 @@ * Tests for {@link TaskSchedulingAutoConfiguration}. * * @author Stephane Nicoll + * @author Moritz Halbritter */ +@SuppressWarnings("removal") class TaskSchedulingAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() @@ -67,6 +77,26 @@ void noSchedulingDoesNotExposeScheduledBeanLazyInitializationExcludeFilter() { .run((context) -> assertThat(context).doesNotHaveBean(ScheduledBeanLazyInitializationExcludeFilter.class)); } + @Test + void shouldSupplyBeans() { + this.contextRunner.withUserConfiguration(SchedulingConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(TaskSchedulerBuilder.class); + assertThat(context).hasSingleBean(ThreadPoolTaskSchedulerBuilder.class); + assertThat(context).hasSingleBean(ThreadPoolTaskScheduler.class); + }); + } + + @Test + void shouldNotSupplyThreadPoolTaskSchedulerBuilderIfCustomTaskSchedulerBuilderIsPresent() { + this.contextRunner.withUserConfiguration(SchedulingConfiguration.class) + .withBean(TaskSchedulerBuilder.class, TaskSchedulerBuilder::new) + .run((context) -> { + assertThat(context).hasSingleBean(TaskSchedulerBuilder.class); + assertThat(context).doesNotHaveBean(ThreadPoolTaskSchedulerBuilder.class); + assertThat(context).hasSingleBean(ThreadPoolTaskScheduler.class); + }); + } + @Test void enableSchedulingWithNoTaskExecutorAutoConfiguresOne() { this.contextRunner @@ -86,7 +116,62 @@ void enableSchedulingWithNoTaskExecutorAutoConfiguresOne() { } @Test - void enableSchedulingWithNoTaskExecutorAppliesCustomizers() { + void simpleAsyncTaskSchedulerBuilderShouldReadProperties() { + this.contextRunner + .withPropertyValues("spring.task.scheduling.simple.concurrency-limit=1", + "spring.task.scheduling.thread-name-prefix=scheduling-test-", + "spring.task.scheduling.shutdown.await-termination=true", + "spring.task.scheduling.shutdown.await-termination-period=30s") + .withUserConfiguration(SchedulingConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(SimpleAsyncTaskSchedulerBuilder.class); + SimpleAsyncTaskSchedulerBuilder builder = context.getBean(SimpleAsyncTaskSchedulerBuilder.class); + assertThat(builder).hasFieldOrPropertyWithValue("threadNamePrefix", "scheduling-test-"); + assertThat(builder).hasFieldOrPropertyWithValue("concurrencyLimit", 1); + assertThat(builder).hasFieldOrPropertyWithValue("taskTerminationTimeout", Duration.ofSeconds(30)); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void simpleAsyncTaskSchedulerBuilderShouldUseVirtualThreadsIfEnabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true") + .withUserConfiguration(SchedulingConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(SimpleAsyncTaskSchedulerBuilder.class); + SimpleAsyncTaskSchedulerBuilder builder = context.getBean(SimpleAsyncTaskSchedulerBuilder.class); + assertThat(builder).hasFieldOrPropertyWithValue("virtualThreads", true); + }); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void simpleAsyncTaskSchedulerBuilderShouldUsePlatformThreadsByDefault() { + this.contextRunner.withUserConfiguration(SchedulingConfiguration.class).run((context) -> { + assertThat(context).hasSingleBean(SimpleAsyncTaskSchedulerBuilder.class); + SimpleAsyncTaskSchedulerBuilder builder = context.getBean(SimpleAsyncTaskSchedulerBuilder.class); + assertThat(builder).hasFieldOrPropertyWithValue("virtualThreads", null); + }); + } + + @Test + @SuppressWarnings("unchecked") + void simpleAsyncTaskSchedulerBuilderShouldApplyCustomizers() { + SimpleAsyncTaskSchedulerCustomizer customizer = (scheduler) -> { + }; + this.contextRunner.withBean(SimpleAsyncTaskSchedulerCustomizer.class, () -> customizer) + .withUserConfiguration(SchedulingConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(SimpleAsyncTaskSchedulerBuilder.class); + SimpleAsyncTaskSchedulerBuilder builder = context.getBean(SimpleAsyncTaskSchedulerBuilder.class); + assertThat(builder).extracting("customizers") + .asInstanceOf(InstanceOfAssertFactories.collection(SimpleAsyncTaskSchedulerCustomizer.class)) + .containsExactly(customizer); + }); + } + + @Test + void enableSchedulingWithNoTaskExecutorAppliesTaskSchedulerCustomizers() { this.contextRunner.withPropertyValues("spring.task.scheduling.thread-name-prefix=scheduling-test-") .withUserConfiguration(SchedulingConfiguration.class, TaskSchedulerCustomizerConfiguration.class) .run((context) -> { @@ -97,6 +182,18 @@ void enableSchedulingWithNoTaskExecutorAppliesCustomizers() { }); } + @Test + void enableSchedulingWithNoTaskExecutorAppliesCustomizers() { + this.contextRunner.withPropertyValues("spring.task.scheduling.thread-name-prefix=scheduling-test-") + .withUserConfiguration(SchedulingConfiguration.class, ThreadPoolTaskSchedulerCustomizerConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(TaskExecutor.class); + TestBean bean = context.getBean(TestBean.class); + assertThat(bean.latch.await(30, TimeUnit.SECONDS)).isTrue(); + assertThat(bean.threadNames).allMatch((name) -> name.contains("customized-scheduler-")); + }); + } + @Test void enableSchedulingWithExistingTaskSchedulerBacksOff() { this.contextRunner.withUserConfiguration(SchedulingConfiguration.class, TaskSchedulerConfiguration.class) @@ -122,17 +219,6 @@ void enableSchedulingWithExistingScheduledExecutorServiceBacksOff() { }); } - @Test - void enableSchedulingWithConfigurerBacksOff() { - this.contextRunner.withUserConfiguration(SchedulingConfiguration.class, SchedulingConfigurerConfiguration.class) - .run((context) -> { - assertThat(context).doesNotHaveBean(TaskScheduler.class); - TestBean bean = context.getBean(TestBean.class); - assertThat(bean.latch.await(30, TimeUnit.SECONDS)).isTrue(); - assertThat(bean.threadNames).containsExactly("test-1"); - }); - } - @Test void enableSchedulingWithLazyInitializationInvokeScheduledMethods() { List threadNames = new ArrayList<>(); @@ -186,6 +272,16 @@ TaskSchedulerCustomizer testTaskSchedulerCustomizer() { } + @Configuration(proxyBeanMethods = false) + static class ThreadPoolTaskSchedulerCustomizerConfiguration { + + @Bean + ThreadPoolTaskSchedulerCustomizer testTaskSchedulerCustomizer() { + return ((taskScheduler) -> taskScheduler.setThreadNamePrefix("customized-scheduler-")); + } + + } + @Configuration(proxyBeanMethods = false) static class SchedulingConfigurerConfiguration implements SchedulingConfigurer { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizerTests.java new file mode 100644 index 000000000000..3722680f6d7c --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/ExecutionListenersTransactionManagerCustomizerTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.autoconfigure.transaction; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.transaction.ConfigurableTransactionManager; +import org.springframework.transaction.TransactionExecutionListener; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ExecutionListenersTransactionManagerCustomizer}. + * + * @author Andy Wilkinson + */ +class ExecutionListenersTransactionManagerCustomizerTests { + + @Test + void whenTransactionManagerIsCustomizedThenExecutionListenersAreAddedToIt() { + TransactionExecutionListener listener1 = mock(TransactionExecutionListener.class); + TransactionExecutionListener listener2 = mock(TransactionExecutionListener.class); + ConfigurableTransactionManager transactionManager = mock(ConfigurableTransactionManager.class); + new ExecutionListenersTransactionManagerCustomizer(List.of(listener1, listener2)).customize(transactionManager); + then(transactionManager).should().addListener(listener1); + then(transactionManager).should().addListener(listener2); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfigurationTests.java index 1af0a1151950..e3aa69357502 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionAutoConfigurationTests.java @@ -130,18 +130,6 @@ void whenAUserProvidesATransactionalOperatorTheAutoConfiguredOperatorBacksOff() }); } - @Test - void platformTransactionManagerCustomizers() { - this.contextRunner.withUserConfiguration(SeveralPlatformTransactionManagersConfiguration.class) - .run((context) -> { - TransactionManagerCustomizers customizers = context.getBean(TransactionManagerCustomizers.class); - assertThat(customizers).extracting("customizers") - .asList() - .singleElement() - .isInstanceOf(TransactionProperties.class); - }); - } - @Test void transactionNotManagedWithNoTransactionManager() { this.contextRunner.withUserConfiguration(BaseConfiguration.class) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfigurationTests.java new file mode 100644 index 000000000000..16c271f64af4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizationAutoConfigurationTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.transaction; + +import java.util.Collections; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link TransactionManagerCustomizationAutoConfiguration}. + * + * @author Andy Wilkinson + */ +class TransactionManagerCustomizationAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TransactionManagerCustomizationAutoConfiguration.class)); + + @Test + void autoConfiguresTransactionManagerCustomizers() { + this.contextRunner.run((context) -> { + TransactionManagerCustomizers customizers = context.getBean(TransactionManagerCustomizers.class); + assertThat(customizers).extracting("customizers") + .asInstanceOf(InstanceOfAssertFactories.LIST) + .hasSize(2) + .hasAtLeastOneElementOfType(TransactionProperties.class) + .hasAtLeastOneElementOfType(ExecutionListenersTransactionManagerCustomizer.class); + }); + } + + @Test + void autoConfiguredTransactionManagerCustomizersBacksOff() { + this.contextRunner.withUserConfiguration(CustomTransactionManagerCustomizersConfiguration.class) + .run((context) -> { + TransactionManagerCustomizers customizers = context.getBean(TransactionManagerCustomizers.class); + assertThat(customizers).extracting("customizers") + .asInstanceOf(InstanceOfAssertFactories.LIST) + .isEmpty(); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomTransactionManagerCustomizersConfiguration { + + @Bean + TransactionManagerCustomizers customTransactionManagerCustomizers() { + return TransactionManagerCustomizers.of(Collections.>emptyList()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizersTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizersTests.java index 9f5827813760..396b00987650 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizersTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/TransactionManagerCustomizersTests.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionManager; import org.springframework.transaction.jta.JtaTransactionManager; import static org.assertj.core.api.Assertions.assertThat; @@ -36,7 +37,7 @@ class TransactionManagerCustomizersTests { @Test void customizeWithNullCustomizersShouldDoNothing() { - new TransactionManagerCustomizers(null).customize(mock(PlatformTransactionManager.class)); + TransactionManagerCustomizers.of(null).customize(mock(TransactionManager.class)); } @Test @@ -44,15 +45,14 @@ void customizeShouldCheckGeneric() { List> list = new ArrayList<>(); list.add(new TestCustomizer<>()); list.add(new TestJtaCustomizer()); - TransactionManagerCustomizers customizers = new TransactionManagerCustomizers(list); - customizers.customize(mock(PlatformTransactionManager.class)); - customizers.customize(mock(JtaTransactionManager.class)); + TransactionManagerCustomizers customizers = TransactionManagerCustomizers.of(list); + customizers.customize((TransactionManager) mock(PlatformTransactionManager.class)); + customizers.customize((TransactionManager) mock(JtaTransactionManager.class)); assertThat(list.get(0).getCount()).isEqualTo(2); assertThat(list.get(1).getCount()).isOne(); } - static class TestCustomizer - implements PlatformTransactionManagerCustomizer { + static class TestCustomizer implements TransactionManagerCustomizer { private int count; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfigurationTests.java index e70fb24e4775..a2e0647780cc 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/transaction/jta/JtaAutoConfigurationTests.java @@ -43,6 +43,7 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -62,6 +63,7 @@ * @author Kazuki Shimizu * @author Nishant Raut */ +@ClassPathExclusions("jetty-jndi-*.jar") class JtaAutoConfigurationTests { private AnnotationConfigApplicationContext context; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidationAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidationAutoConfigurationTests.java index 4a46ae5ddc82..66dc324099c3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidationAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidationAutoConfigurationTests.java @@ -217,7 +217,7 @@ void userDefinedMethodValidationPostProcessorTakesPrecedence() { .isSameAs(userMethodValidationPostProcessor); assertThat(context.getBeansOfType(MethodValidationPostProcessor.class)).hasSize(1); Object validator = ReflectionTestUtils.getField(userMethodValidationPostProcessor, "validator"); - assertThat(validator).isNotNull().isInstanceOf(Supplier.class); + assertThat(validator).isInstanceOf(Supplier.class); assertThat(context.getBean(Validator.class)).isNotSameAs(((Supplier) validator).get()); }); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapterTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapterTests.java index bf57084b98ef..641c45dc220f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapterTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/validation/ValidatorAdapterTests.java @@ -18,7 +18,9 @@ import java.util.HashMap; +import jakarta.validation.Validator; import jakarta.validation.constraints.Min; +import org.hibernate.validator.HibernateValidator; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.FilteredClassLoader; @@ -27,10 +29,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; +import org.springframework.validation.Errors; import org.springframework.validation.MapBindingResult; +import org.springframework.validation.SmartValidator; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatRuntimeException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -91,6 +96,30 @@ void wrapperWhenValidationProviderNotPresentShouldNotThrowException() { .run((context) -> ValidatorAdapter.get(context, null)); } + @Test + void unwrapToJakartaValidatorShouldReturnJakartaValidator() { + this.contextRunner.withUserConfiguration(LocalValidatorFactoryBeanConfig.class).run((context) -> { + ValidatorAdapter wrapper = context.getBean(ValidatorAdapter.class); + assertThat(wrapper.unwrap(Validator.class)).isInstanceOf(Validator.class); + }); + } + + @Test + void whenJakartaValidatorIsWrappedMultipleTimesUnwrapToJakartaValidatorShouldReturnJakartaValidator() { + this.contextRunner.withUserConfiguration(DoubleWrappedConfig.class).run((context) -> { + ValidatorAdapter wrapper = context.getBean(ValidatorAdapter.class); + assertThat(wrapper.unwrap(Validator.class)).isInstanceOf(Validator.class); + }); + } + + @Test + void unwrapToUnsupportedTypeShouldThrow() { + this.contextRunner.withUserConfiguration(LocalValidatorFactoryBeanConfig.class).run((context) -> { + ValidatorAdapter wrapper = context.getBean(ValidatorAdapter.class); + assertThatRuntimeException().isThrownBy(() -> wrapper.unwrap(HibernateValidator.class)); + }); + } + @Configuration(proxyBeanMethods = false) static class LocalValidatorFactoryBeanConfig { @@ -106,6 +135,55 @@ ValidatorAdapter wrapper(LocalValidatorFactoryBean validator) { } + @Configuration(proxyBeanMethods = false) + static class DoubleWrappedConfig { + + @Bean + LocalValidatorFactoryBean validator() { + return new LocalValidatorFactoryBean(); + } + + @Bean + ValidatorAdapter wrapper(LocalValidatorFactoryBean validator) { + return new ValidatorAdapter(new Wrapper(validator), true); + } + + static class Wrapper implements SmartValidator { + + private final SmartValidator delegate; + + Wrapper(SmartValidator delegate) { + this.delegate = delegate; + } + + @Override + public boolean supports(Class clazz) { + return this.delegate.supports(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + this.delegate.validate(target, errors); + } + + @Override + public void validate(Object target, Errors errors, Object... validationHints) { + this.delegate.validate(target, errors, validationHints); + } + + @Override + @SuppressWarnings("unchecked") + public T unwrap(Class type) { + if (type.isInstance(this.delegate)) { + return (T) this.delegate; + } + return this.delegate.unwrap(type); + } + + } + + } + @Configuration(proxyBeanMethods = false) static class NonManagedBeanConfig { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java index eaf0b45c2b0a..ff8b377d2d95 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/ServerPropertiesTests.java @@ -16,21 +16,14 @@ package org.springframework.boot.autoconfigure.web; -import java.io.IOException; import java.net.InetAddress; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import io.undertow.UndertowOptions; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import org.apache.catalina.connector.Connector; import org.apache.catalina.core.StandardContext; import org.apache.catalina.core.StandardEngine; @@ -38,8 +31,7 @@ import org.apache.catalina.valves.RemoteIpValve; import org.apache.coyote.AbstractProtocol; import org.apache.tomcat.util.net.AbstractEndpoint; -import org.eclipse.jetty.server.HttpChannel; -import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.junit.jupiter.api.Test; @@ -51,21 +43,11 @@ import org.springframework.boot.context.properties.source.ConfigurationPropertySource; import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.embedded.jetty.JettyWebServer; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; -import org.springframework.boot.web.servlet.ServletContextInitializer; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.client.ClientHttpResponse; import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.util.unit.DataSize; -import org.springframework.web.client.ResponseErrorHandler; -import org.springframework.web.client.RestTemplate; import static org.assertj.core.api.Assertions.assertThat; @@ -114,6 +96,7 @@ void testServerHeader() { } @Test + @SuppressWarnings("removal") void testTomcatBinding() { Map map = new HashMap<>(); map.put("server.tomcat.accesslog.conditionIf", "foo"); @@ -205,24 +188,6 @@ void testCustomizeUriEncoding() { assertThat(this.properties.getTomcat().getUriEncoding()).isEqualTo(StandardCharsets.US_ASCII); } - @Test - @SuppressWarnings("removal") - @Deprecated(since = "3.0.0", forRemoval = true) - void testCustomizeHeaderSize() { - bind("server.max-http-header-size", "1MB"); - assertThat(this.properties.getMaxHttpHeaderSize()).isEqualTo(DataSize.ofMegabytes(1)); - assertThat(this.properties.getMaxHttpRequestHeaderSize()).isEqualTo(DataSize.ofMegabytes(1)); - } - - @Test - @SuppressWarnings("removal") - @Deprecated(since = "3.0.0", forRemoval = true) - void testCustomizeHeaderSizeUseBytesByDefault() { - bind("server.max-http-header-size", "1024"); - assertThat(this.properties.getMaxHttpHeaderSize()).isEqualTo(DataSize.ofKilobytes(1)); - assertThat(this.properties.getMaxHttpRequestHeaderSize()).isEqualTo(DataSize.ofKilobytes(1)); - } - @Test void testCustomizeMaxHttpRequestHeaderSize() { bind("server.max-http-request-header-size", "1MB"); @@ -441,6 +406,7 @@ void tomcatInternalProxiesMatchesDefault() { } @Test + @SuppressWarnings("removal") void tomcatRejectIllegalHeaderMatchesProtocolDefault() throws Exception { assertThat(getDefaultProtocol()).hasFieldOrPropertyWithValue("rejectIllegalHeader", this.properties.getTomcat().isRejectIllegalHeader()); @@ -460,7 +426,6 @@ void tomcatMaxKeepAliveRequestsDefault() throws Exception { } @Test - @Servlet5ClassPathOverrides void jettyThreadPoolPropertyDefaultsShouldMatchServerDefault() { JettyServletWebServerFactory jettyFactory = new JettyServletWebServerFactory(0); JettyWebServer jetty = (JettyWebServer) jettyFactory.getWebServer(); @@ -475,61 +440,12 @@ void jettyThreadPoolPropertyDefaultsShouldMatchServerDefault() { } @Test - @Servlet5ClassPathOverrides void jettyMaxHttpFormPostSizeMatchesDefault() { JettyServletWebServerFactory jettyFactory = new JettyServletWebServerFactory(0); - JettyWebServer jetty = (JettyWebServer) jettyFactory - .getWebServer((ServletContextInitializer) (servletContext) -> servletContext - .addServlet("formPost", new HttpServlet() { - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) - throws ServletException, IOException { - req.getParameterMap(); - } - - }) - .addMapping("/form")); - jetty.start(); - org.eclipse.jetty.server.Connector connector = jetty.getServer().getConnectors()[0]; - final AtomicReference failure = new AtomicReference<>(); - connector.addBean(new HttpChannel.Listener() { - - @Override - public void onDispatchFailure(Request request, Throwable ex) { - failure.set(ex); - } - - }); - try { - RestTemplate template = new RestTemplate(); - template.setErrorHandler(new ResponseErrorHandler() { - - @Override - public boolean hasError(ClientHttpResponse response) throws IOException { - return false; - } - - @Override - public void handleError(ClientHttpResponse response) throws IOException { - - } - - }); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("data", "a".repeat(250000)); - HttpEntity> entity = new HttpEntity<>(body, headers); - template.postForEntity(URI.create("http://localhost:" + jetty.getPort() + "/form"), entity, Void.class); - assertThat(failure.get()).isNotNull(); - String message = failure.get().getCause().getMessage(); - int defaultMaxPostSize = Integer.parseInt(message.substring(message.lastIndexOf(' ')).trim()); - assertThat(this.properties.getJetty().getMaxHttpFormPostSize().toBytes()).isEqualTo(defaultMaxPostSize); - } - finally { - jetty.stop(); - } + JettyWebServer jetty = (JettyWebServer) jettyFactory.getWebServer(); + Server server = jetty.getServer(); + assertThat(this.properties.getJetty().getMaxHttpFormPostSize().toBytes()) + .isEqualTo(((ServletContextHandler) server.getHandler()).getMaxFormContentSize()); } @Test @@ -538,14 +454,6 @@ void undertowMaxHttpPostSizeMatchesDefault() { .isEqualTo(UndertowOptions.DEFAULT_MAX_ENTITY_SIZE); } - @Test - @Deprecated(since = "3.0.0", forRemoval = true) - @SuppressWarnings("removal") - void nettyMaxChunkSizeMatchesHttpDecoderSpecDefault() { - assertThat(this.properties.getNetty().getMaxChunkSize().toBytes()) - .isEqualTo(HttpDecoderSpec.DEFAULT_MAX_CHUNK_SIZE); - } - @Test void nettyMaxInitialLineLengthMatchesHttpDecoderSpecDefault() { assertThat(this.properties.getNetty().getMaxInitialLineLength().toBytes()) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizerTests.java new file mode 100644 index 000000000000..f4138baa1819 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/HttpMessageConvertersRestClientCustomizerTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.client; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link HttpMessageConvertersRestClientCustomizer} + * + * @author Phillip Webb + */ +class HttpMessageConvertersRestClientCustomizerTests { + + @Test + void createWhenNullMessageConvertersArrayThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new HttpMessageConvertersRestClientCustomizer((HttpMessageConverter[]) null)) + .withMessage("MessageConverters must not be null"); + } + + @Test + void createWhenNullMessageConvertersDoesNotCustomize() { + HttpMessageConverter c0 = mock(); + assertThat(apply(new HttpMessageConvertersRestClientCustomizer((HttpMessageConverters) null), c0)) + .containsExactly(c0); + } + + @Test + void customizeConfiguresMessageConverters() { + HttpMessageConverter c0 = mock(); + HttpMessageConverter c1 = mock(); + HttpMessageConverter c2 = mock(); + assertThat(apply(new HttpMessageConvertersRestClientCustomizer(c1, c2), c0)).containsExactly(c1, c2); + } + + @SuppressWarnings("unchecked") + private List> apply(HttpMessageConvertersRestClientCustomizer customizer, + HttpMessageConverter... converters) { + List> messageConverters = new ArrayList<>(Arrays.asList(converters)); + RestClient.Builder restClientBuilder = mock(); + ArgumentCaptor>>> captor = ArgumentCaptor.forClass(Consumer.class); + given(restClientBuilder.messageConverters(captor.capture())).willReturn(restClientBuilder); + customizer.customize(restClientBuilder); + captor.getValue().accept(messageConverters); + return messageConverters; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfigurationTests.java new file mode 100644 index 000000000000..576d6e45808b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientAutoConfigurationTests.java @@ -0,0 +1,207 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.client; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.boot.web.codec.CodecCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.Builder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link RestClientAutoConfiguration} + * + * @author Arjen Poutsma + * @author Moritz Halbritter + */ +class RestClientAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class)); + + @Test + void shouldSupplyBeans() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(HttpMessageConvertersRestClientCustomizer.class); + assertThat(context).hasSingleBean(RestClientBuilderConfigurer.class); + assertThat(context).hasSingleBean(RestClient.Builder.class); + }); + } + + @Test + void shouldSupplyRestClientSslIfSslBundlesIsThere() { + this.contextRunner.withBean(SslBundles.class, () -> mock(SslBundles.class)) + .run((context) -> assertThat(context).hasSingleBean(RestClientSsl.class)); + } + + @Test + void shouldCreateBuilder() { + this.contextRunner.run((context) -> { + RestClient.Builder builder = context.getBean(RestClient.Builder.class); + RestClient restClient = builder.build(); + assertThat(restClient).isNotNull(); + }); + } + + @Test + void configurerShouldCallCustomizers() { + this.contextRunner.withUserConfiguration(RestClientCustomizerConfig.class).run((context) -> { + RestClientBuilderConfigurer configurer = context.getBean(RestClientBuilderConfigurer.class); + RestClientCustomizer customizer = context.getBean("restClientCustomizer", RestClientCustomizer.class); + Builder builder = RestClient.builder(); + configurer.configure(builder); + then(customizer).should().customize(builder); + }); + } + + @Test + void restClientShouldApplyCustomizers() { + this.contextRunner.withUserConfiguration(RestClientCustomizerConfig.class).run((context) -> { + RestClient.Builder builder = context.getBean(RestClient.Builder.class); + RestClientCustomizer customizer = context.getBean("restClientCustomizer", RestClientCustomizer.class); + builder.build(); + then(customizer).should().customize(any(RestClient.Builder.class)); + }); + } + + @Test + void shouldGetPrototypeScopedBean() { + this.contextRunner.withUserConfiguration(RestClientCustomizerConfig.class).run((context) -> { + RestClient.Builder firstBuilder = context.getBean(RestClient.Builder.class); + RestClient.Builder secondBuilder = context.getBean(RestClient.Builder.class); + assertThat(firstBuilder).isNotEqualTo(secondBuilder); + }); + } + + @Test + void shouldNotCreateClientBuilderIfAlreadyPresent() { + this.contextRunner.withUserConfiguration(CustomRestClientBuilderConfig.class).run((context) -> { + RestClient.Builder builder = context.getBean(RestClient.Builder.class); + assertThat(builder).isInstanceOf(MyRestClientBuilder.class); + }); + } + + @Test + @SuppressWarnings("unchecked") + void restClientWhenMessageConvertersDefinedShouldHaveMessageConverters() { + this.contextRunner.withConfiguration(AutoConfigurations.of(HttpMessageConvertersAutoConfiguration.class)) + .withUserConfiguration(RestClientConfig.class) + .run((context) -> { + RestClient restClient = context.getBean(RestClient.class); + List> expectedConverters = context.getBean(HttpMessageConverters.class) + .getConverters(); + List> actualConverters = (List>) ReflectionTestUtils + .getField(restClient, "messageConverters"); + assertThat(actualConverters).containsExactlyElementsOf(expectedConverters); + }); + } + + @Test + @SuppressWarnings("unchecked") + void restClientWhenNoMessageConvertersDefinedShouldHaveDefaultMessageConverters() { + this.contextRunner.withUserConfiguration(RestClientConfig.class).run((context) -> { + RestClient restClient = context.getBean(RestClient.class); + RestClient defaultRestClient = RestClient.builder().build(); + List> actualConverters = (List>) ReflectionTestUtils + .getField(restClient, "messageConverters"); + List> expectedConverters = (List>) ReflectionTestUtils + .getField(defaultRestClient, "messageConverters"); + assertThat(actualConverters).hasSameSizeAs(expectedConverters); + }); + } + + @Test + @SuppressWarnings({ "unchecked", "rawtypes" }) + void restClientWhenHasCustomMessageConvertersShouldHaveMessageConverters() { + this.contextRunner.withConfiguration(AutoConfigurations.of(HttpMessageConvertersAutoConfiguration.class)) + .withUserConfiguration(CustomHttpMessageConverter.class, RestClientConfig.class) + .run((context) -> { + RestClient restClient = context.getBean(RestClient.class); + List> actualConverters = (List>) ReflectionTestUtils + .getField(restClient, "messageConverters"); + assertThat(actualConverters).extracting(HttpMessageConverter::getClass) + .contains((Class) CustomHttpMessageConverter.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CodecConfiguration { + + @Bean + CodecCustomizer myCodecCustomizer() { + return mock(CodecCustomizer.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class RestClientCustomizerConfig { + + @Bean + RestClientCustomizer restClientCustomizer() { + return mock(RestClientCustomizer.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomRestClientBuilderConfig { + + @Bean + MyRestClientBuilder myRestClientBuilder() { + return mock(MyRestClientBuilder.class); + } + + } + + interface MyRestClientBuilder extends RestClient.Builder { + + } + + @Configuration(proxyBeanMethods = false) + static class RestClientConfig { + + @Bean + RestClient restClient(RestClient.Builder restClientBuilder) { + return restClientBuilder.build(); + } + + } + + static class CustomHttpMessageConverter extends StringHttpMessageConverter { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurerTests.java new file mode 100644 index 000000000000..c4c8395c2177 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestClientBuilderConfigurerTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.client; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link RestClientBuilderConfigurer}. + * + * @author Moritz Halbritter + */ +class RestClientBuilderConfigurerTests { + + @Test + void shouldApplyCustomizers() { + RestClientBuilderConfigurer configurer = new RestClientBuilderConfigurer(); + RestClientCustomizer customizer = mock(RestClientCustomizer.class); + configurer.setRestClientCustomizers(List.of(customizer)); + RestClient.Builder builder = RestClient.builder(); + configurer.configure(builder); + then(customizer).should().customize(builder); + } + + @Test + void shouldSupportNullAsCustomizers() { + RestClientBuilderConfigurer configurer = new RestClientBuilderConfigurer(); + configurer.setRestClientCustomizers(null); + assertThatCode(() -> configurer.configure(RestClient.builder())).doesNotThrowAnyException(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfigurationTests.java index a80f93a73720..582752925976 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/client/RestTemplateAutoConfigurationTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.BeanDefinitionOverrideException; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.http.HttpMessageConverters; import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; @@ -65,6 +66,14 @@ void restTemplateBuilderConfigurerShouldBeLazilyDefined() { .isTrue()); } + @Test + void shouldFailOnCustomRestTemplateBuilderConfigurer() { + this.contextRunner.withUserConfiguration(RestTemplateBuilderConfigurerConfig.class) + .run((context) -> assertThat(context).getFailure() + .isInstanceOf(BeanDefinitionOverrideException.class) + .hasMessageContaining("with name 'restTemplateBuilderConfigurer'")); + } + @Test void restTemplateBuilderShouldBeLazilyDefined() { this.contextRunner @@ -263,6 +272,16 @@ RestTemplateRequestCustomizer restTemplateRequestCustomizer() { } + @Configuration(proxyBeanMethods = false) + static class RestTemplateBuilderConfigurerConfig { + + @Bean + RestTemplateBuilderConfigurer restTemplateBuilderConfigurer() { + return new RestTemplateBuilderConfigurer(); + } + + } + static class CustomHttpMessageConverter extends StringHttpMessageConverter { } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizerTests.java new file mode 100644 index 000000000000..6f7fb09dd7f3 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyVirtualThreadsWebServerFactoryCustomizerTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.embedded; + +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.web.embedded.jetty.ConfigurableJettyWebServerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.assertArg; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JettyVirtualThreadsWebServerFactoryCustomizer}. + * + * @author Moritz Halbritter + */ +class JettyVirtualThreadsWebServerFactoryCustomizerTests { + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void shouldConfigureVirtualThreads() { + ServerProperties properties = new ServerProperties(); + JettyVirtualThreadsWebServerFactoryCustomizer customizer = new JettyVirtualThreadsWebServerFactoryCustomizer( + properties); + ConfigurableJettyWebServerFactory factory = mock(ConfigurableJettyWebServerFactory.class); + customizer.customize(factory); + then(factory).should().setThreadPool(assertArg((threadPool) -> { + assertThat(threadPool).isInstanceOf(QueuedThreadPool.class); + QueuedThreadPool queuedThreadPool = (QueuedThreadPool) threadPool; + assertThat(queuedThreadPool.getVirtualThreadsExecutor()).isNotNull(); + })); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizerTests.java index c024fc15c6cf..eef94bb88a20 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/JettyWebServerFactoryCustomizerTests.java @@ -47,7 +47,6 @@ import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.ConfigurationPropertySources; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.ConfigurableJettyWebServerFactory; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.embedded.jetty.JettyWebServer; @@ -67,7 +66,6 @@ * @author HaiTao Zhang */ @DirtiesUrlFactories -@Servlet5ClassPathOverrides class JettyWebServerFactoryCustomizerTests { private MockEnvironment environment; @@ -263,30 +261,6 @@ void setUseForwardHeaders() { then(factory).should().setUseForwardHeaders(true); } - @Test - void customizeMaxHttpHeaderSize() { - bind("server.max-http-header-size=2048"); - JettyWebServer server = customizeAndGetServer(); - List requestHeaderSizes = getRequestHeaderSizes(server); - assertThat(requestHeaderSizes).containsOnly(2048); - } - - @Test - void customMaxHttpHeaderSizeIgnoredIfNegative() { - bind("server.max-http-header-size=-1"); - JettyWebServer server = customizeAndGetServer(); - List requestHeaderSizes = getRequestHeaderSizes(server); - assertThat(requestHeaderSizes).containsOnly(8192); - } - - @Test - void customMaxHttpHeaderSizeIgnoredIfZero() { - bind("server.max-http-header-size=0"); - JettyWebServer server = customizeAndGetServer(); - List requestHeaderSizes = getRequestHeaderSizes(server); - assertThat(requestHeaderSizes).containsOnly(8192); - } - @Test void customizeMaxRequestHttpHeaderSize() { bind("server.max-http-request-header-size=2048"); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizerTests.java index 1b99ff688f73..70046794354d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/NettyWebServerFactoryCustomizerTests.java @@ -144,7 +144,6 @@ void configureHttpRequestDecoder() { nettyProperties.setValidateHeaders(false); nettyProperties.setInitialBufferSize(DataSize.ofBytes(512)); nettyProperties.setH2cMaxContentLength(DataSize.ofKilobytes(1)); - setMaxChunkSize(nettyProperties); nettyProperties.setMaxInitialLineLength(DataSize.ofKilobytes(32)); NettyReactiveWebServerFactory factory = mock(NettyReactiveWebServerFactory.class); this.customizer.customize(factory); @@ -156,20 +155,9 @@ void configureHttpRequestDecoder() { assertThat(decoder.maxHeaderSize()).isEqualTo(this.serverProperties.getMaxHttpRequestHeaderSize().toBytes()); assertThat(decoder.initialBufferSize()).isEqualTo(nettyProperties.getInitialBufferSize().toBytes()); assertThat(decoder.h2cMaxContentLength()).isEqualTo(nettyProperties.getH2cMaxContentLength().toBytes()); - assertMaxChunkSize(nettyProperties, decoder); assertThat(decoder.maxInitialLineLength()).isEqualTo(nettyProperties.getMaxInitialLineLength().toBytes()); } - @SuppressWarnings("removal") - private void setMaxChunkSize(ServerProperties.Netty nettyProperties) { - nettyProperties.setMaxChunkSize(DataSize.ofKilobytes(16)); - } - - @SuppressWarnings({ "deprecation", "removal" }) - private void assertMaxChunkSize(ServerProperties.Netty nettyProperties, HttpRequestDecoderSpec decoder) { - assertThat(decoder.maxChunkSize()).isEqualTo(nettyProperties.getMaxChunkSize().toBytes()); - } - private void verifyConnectionTimeout(NettyReactiveWebServerFactory factory, Integer expected) { if (expected == null) { then(factory).should(never()).addServerCustomizers(any(NettyServerCustomizer.class)); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizerTests.java new file mode 100644 index 000000000000..5fcf72d1f937 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatVirtualThreadsWebServerFactoryCustomizerTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-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.boot.autoconfigure.web.embedded; + +import java.util.function.Consumer; + +import org.apache.tomcat.util.threads.VirtualThreadExecutor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.embedded.tomcat.TomcatWebServer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link TomcatVirtualThreadsWebServerFactoryCustomizer}. + * + * @author Moritz Halbritter + */ +class TomcatVirtualThreadsWebServerFactoryCustomizerTests { + + private final TomcatVirtualThreadsWebServerFactoryCustomizer customizer = new TomcatVirtualThreadsWebServerFactoryCustomizer(); + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void shouldSetVirtualThreadExecutor() { + withWebServer((webServer) -> assertThat(webServer.getTomcat().getConnector().getProtocolHandler().getExecutor()) + .isInstanceOf(VirtualThreadExecutor.class)); + } + + private TomcatWebServer getWebServer() { + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + this.customizer.customize(factory); + return (TomcatWebServer) factory.getWebServer(); + } + + private void withWebServer(Consumer callback) { + TomcatWebServer webServer = getWebServer(); + webServer.start(); + try { + callback.accept(webServer); + } + finally { + webServer.stop(); + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java index c24b4f179cde..205d98db53d8 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,10 +17,12 @@ package org.springframework.boot.autoconfigure.web.embedded; import java.util.Locale; +import java.util.concurrent.Executor; import java.util.function.Consumer; import org.apache.catalina.Context; import org.apache.catalina.Valve; +import org.apache.catalina.core.StandardThreadExecutor; import org.apache.catalina.startup.Tomcat; import org.apache.catalina.valves.AccessLogValve; import org.apache.catalina.valves.ErrorReportValve; @@ -58,6 +60,7 @@ * @author Rafiullah Hamedy * @author Victor Mandujano * @author Parviz Rozikov + * @author Moritz Halbritter */ class TomcatWebServerFactoryCustomizerTests { @@ -176,47 +179,6 @@ void customMaxHttpFormPostSize() { (server) -> assertThat(server.getTomcat().getConnector().getMaxPostSize()).isEqualTo(10000)); } - @Test - void customMaxHttpHeaderSize() { - bind("server.max-http-header-size=1KB"); - customizeAndRunServer((server) -> assertThat( - ((AbstractHttp11Protocol) server.getTomcat().getConnector().getProtocolHandler()) - .getMaxHttpRequestHeaderSize()) - .isEqualTo(DataSize.ofKilobytes(1).toBytes())); - } - - @Test - void customMaxHttpHeaderSizeWithHttp2() { - bind("server.max-http-header-size=1KB", "server.http2.enabled=true"); - customizeAndRunServer((server) -> { - AbstractHttp11Protocol protocolHandler = (AbstractHttp11Protocol) server.getTomcat() - .getConnector() - .getProtocolHandler(); - long expectedSize = DataSize.ofKilobytes(1).toBytes(); - assertThat(protocolHandler.getMaxHttpRequestHeaderSize()).isEqualTo(expectedSize); - assertThat(((Http2Protocol) protocolHandler.getUpgradeProtocol("h2c")).getMaxHeaderSize()) - .isEqualTo(expectedSize); - }); - } - - @Test - void customMaxHttpHeaderSizeIgnoredIfNegative() { - bind("server.max-http-header-size=-1"); - customizeAndRunServer((server) -> assertThat( - ((AbstractHttp11Protocol) server.getTomcat().getConnector().getProtocolHandler()) - .getMaxHttpRequestHeaderSize()) - .isEqualTo(DataSize.ofKilobytes(8).toBytes())); - } - - @Test - void customMaxHttpHeaderSizeIgnoredIfZero() { - bind("server.max-http-header-size=0"); - customizeAndRunServer((server) -> assertThat( - ((AbstractHttp11Protocol) server.getTomcat().getConnector().getProtocolHandler()) - .getMaxHttpRequestHeaderSize()) - .isEqualTo(DataSize.ofKilobytes(8).toBytes())); - } - @Test void defaultMaxHttpRequestHeaderSize() { customizeAndRunServer((server) -> assertThat( @@ -436,16 +398,6 @@ void disableRemoteIpValve() { assertThat(factory.getEngineValves()).isEmpty(); } - @Test - @Deprecated(since = "2.7.12", forRemoval = true) - void testCustomizeRejectIllegalHeader() { - bind("server.tomcat.reject-illegal-header=false"); - customizeAndRunServer((server) -> assertThat( - ((AbstractHttp11Protocol) server.getTomcat().getConnector().getProtocolHandler()) - .getRejectIllegalHeader()) - .isFalse()); - } - @Test void errorReportValveIsConfiguredToNotReportStackTraces() { TomcatWebServer server = customizeAndGetServer(); @@ -615,6 +567,20 @@ void ajpConnectorCanBeCustomized() { server.stop(); } + @Test + void configureExecutor() { + bind("server.tomcat.threads.max=10", "server.tomcat.threads.min-spare=2", + "server.tomcat.threads.max-queue-capacity=20"); + customizeAndRunServer((server) -> { + Executor executor = server.getTomcat().getConnector().getProtocolHandler().getExecutor(); + assertThat(executor).isInstanceOf(StandardThreadExecutor.class); + StandardThreadExecutor standardThreadExecutor = (StandardThreadExecutor) executor; + assertThat(standardThreadExecutor.getMaxThreads()).isEqualTo(10); + assertThat(standardThreadExecutor.getMinSpareThreads()).isEqualTo(2); + assertThat(standardThreadExecutor.getMaxQueueSize()).isEqualTo(20); + }); + } + private void bind(String... inlinedProperties) { TestPropertySourceUtils.addInlinedPropertiesToEnvironment(this.environment, inlinedProperties); new Binder(ConfigurationPropertySources.get(this.environment)).bind("server", diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerConfigurationTests.java new file mode 100644 index 000000000000..927462ecfb75 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerConfigurationTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.web.embedded; + +import io.undertow.servlet.api.DeploymentInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration.UndertowWebServerFactoryCustomizerConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.embedded.undertow.UndertowDeploymentInfoCustomizer; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebApplicationContext; +import org.springframework.core.task.VirtualThreadTaskExecutor; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link UndertowWebServerFactoryCustomizerConfiguration}. + * + * @author Moritz Halbritter + */ +class UndertowWebServerFactoryCustomizerConfigurationTests { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner( + AnnotationConfigServletWebApplicationContext::new) + .withConfiguration(AutoConfigurations.of(EmbeddedWebServerFactoryCustomizerAutoConfiguration.class)); + + @EnabledForJreRange(min = JRE.JAVA_21) + @Test + void shouldUseVirtualThreadsIfEnabled() { + this.contextRunner.withPropertyValues("spring.threads.virtual.enabled=true").run((context) -> { + assertThat(context).hasSingleBean(UndertowDeploymentInfoCustomizer.class); + assertThat(context).hasBean("virtualThreadsUndertowDeploymentInfoCustomizer"); + UndertowDeploymentInfoCustomizer customizer = context.getBean(UndertowDeploymentInfoCustomizer.class); + DeploymentInfo deploymentInfo = new DeploymentInfo(); + customizer.customize(deploymentInfo); + assertThat(deploymentInfo.getExecutor()).isInstanceOf(VirtualThreadTaskExecutor.class); + }); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerTests.java index 934f0689223a..e5a2c95e8271 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/embedded/UndertowWebServerFactoryCustomizerTests.java @@ -85,24 +85,6 @@ void customizeUndertowAccessLog() { then(factory).should().setAccessLogRotate(false); } - @Test - void customMaxHttpHeaderSize() { - bind("server.max-http-header-size=2048"); - assertThat(boundServerOption(UndertowOptions.MAX_HEADER_SIZE)).isEqualTo(2048); - } - - @Test - void customMaxHttpHeaderSizeIgnoredIfNegative() { - bind("server.max-http-header-size=-1"); - assertThat(boundServerOption(UndertowOptions.MAX_HEADER_SIZE)).isNull(); - } - - @Test - void customMaxHttpHeaderSizeIgnoredIfZero() { - bind("server.max-http-header-size=0"); - assertThat(boundServerOption(UndertowOptions.MAX_HEADER_SIZE)).isNull(); - } - @Test void customMaxHttpRequestHeaderSize() { bind("server.max-http-request-header-size=2048"); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java index d5925346e13f..ae3d0099ce22 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java @@ -84,17 +84,21 @@ void shouldConfigureMultipartPropertiesForDefaultReader() { void shouldConfigureMultipartPropertiesForPartEventReader() { this.contextRunner .withPropertyValues("spring.webflux.multipart.max-in-memory-size=1GB", - "spring.webflux.multipart.max-headers-size=16KB", "spring.webflux.multipart.headers-charset:UTF_16") + "spring.webflux.multipart.max-headers-size=16KB", + "spring.webflux.multipart.max-disk-usage-per-part=3GB", "spring.webflux.multipart.max-parts=7", + "spring.webflux.multipart.headers-charset:UTF_16") .run((context) -> { CodecCustomizer customizer = context.getBean(CodecCustomizer.class); DefaultServerCodecConfigurer configurer = new DefaultServerCodecConfigurer(); customizer.customize(configurer); PartEventHttpMessageReader partReader = getPartEventReader(configurer); + assertThat(partReader).hasFieldOrPropertyWithValue("maxParts", 7); assertThat(partReader).hasFieldOrPropertyWithValue("maxHeadersSize", Math.toIntExact(DataSize.ofKilobytes(16).toBytes())); assertThat(partReader).hasFieldOrPropertyWithValue("headersCharset", StandardCharsets.UTF_16); assertThat(partReader).hasFieldOrPropertyWithValue("maxInMemorySize", Math.toIntExact(DataSize.ofGigabytes(1).toBytes())); + assertThat(partReader).hasFieldOrPropertyWithValue("maxPartSize", DataSize.ofGigabytes(3).toBytes()); }); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryAutoConfigurationTests.java index 7b1b38cf2678..044c1bfff9f1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveWebServerFactoryAutoConfigurationTests.java @@ -31,7 +31,6 @@ import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyReactiveWebServerFactory; import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer; import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; @@ -231,7 +230,6 @@ void tomcatProtocolHandlerCustomizerRegisteredAsBeanAndViaFactoryIsOnlyCalledOnc } @Test - @Servlet5ClassPathOverrides void jettyServerCustomizerBeanIsAddedToFactory() { new ReactiveWebApplicationContextRunner(AnnotationConfigReactiveWebApplicationContext::new) .withConfiguration(AutoConfigurations.of(ReactiveWebServerFactoryAutoConfiguration.class)) @@ -244,7 +242,6 @@ void jettyServerCustomizerBeanIsAddedToFactory() { } @Test - @Servlet5ClassPathOverrides void jettyServerCustomizerRegisteredAsBeanAndViaFactoryIsOnlyCalledOnce() { new ReactiveWebApplicationContextRunner(AnnotationConfigReactiveWebServerApplicationContext::new) .withConfiguration(AutoConfigurations.of(ReactiveWebServerFactoryAutoConfiguration.class)) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java index 15759a908e2d..b6a15e1ae4d6 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -36,6 +37,7 @@ import org.aspectj.lang.annotation.AfterReturning; import org.aspectj.lang.annotation.Aspect; import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -43,10 +45,13 @@ import org.springframework.aop.support.AopUtils; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; import org.springframework.boot.autoconfigure.validation.ValidatorAdapter; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration.WebFluxConfig; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfigurationTests.OrderedControllerAdviceBeansConfiguration.HighestOrderedControllerAdvice; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfigurationTests.OrderedControllerAdviceBeansConfiguration.LowestOrderedControllerAdvice; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; @@ -62,6 +67,7 @@ import org.springframework.core.annotation.Order; import org.springframework.core.convert.ConversionService; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.task.AsyncTaskExecutor; import org.springframework.format.Parser; import org.springframework.format.Printer; import org.springframework.format.support.FormattingConversionService; @@ -77,8 +83,10 @@ import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.filter.reactive.HiddenHttpMethodFilter; +import org.springframework.web.method.ControllerAdviceBean; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.config.BlockingExecutionConfigurer; import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration; import org.springframework.web.reactive.config.WebFluxConfigurationSupport; import org.springframework.web.reactive.config.WebFluxConfigurer; @@ -101,8 +109,11 @@ import org.springframework.web.server.i18n.FixedLocaleContextResolver; import org.springframework.web.server.i18n.LocaleContextResolver; import org.springframework.web.server.session.CookieWebSessionIdResolver; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.InMemoryWebSessionStore; import org.springframework.web.server.session.WebSessionIdResolver; import org.springframework.web.server.session.WebSessionManager; +import org.springframework.web.server.session.WebSessionStore; import org.springframework.web.util.pattern.PathPattern; import static org.assertj.core.api.Assertions.assertThat; @@ -190,7 +201,9 @@ void shouldMapResourcesToCustomPath() { SimpleUrlHandlerMapping hm = context.getBean("resourceHandlerMapping", SimpleUrlHandlerMapping.class); assertThat(hm.getUrlMap().get("/static/**")).isInstanceOf(ResourceWebHandler.class); ResourceWebHandler staticHandler = (ResourceWebHandler) hm.getUrlMap().get("/static/**"); - assertThat(staticHandler).extracting("locationValues").asList().hasSize(4); + assertThat(staticHandler).extracting("locationValues") + .asInstanceOf(InstanceOfAssertFactories.LIST) + .hasSize(4); }); } @@ -591,7 +604,7 @@ void userConfigurersCanBeOrderedBeforeOrAfterTheAutoConfiguredConfigurer() { .withBean(LowPrecedenceConfigurer.class, LowPrecedenceConfigurer::new) .run((context) -> assertThat(context.getBean(DelegatingWebFluxConfiguration.class)) .extracting("configurers.delegates") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .extracting((configurer) -> (Class) configurer.getClass()) .containsExactly(HighPrecedenceConfigurer.class, WebFluxConfig.class, LowPrecedenceConfigurer.class)); } @@ -612,6 +625,18 @@ void customSessionTimeoutConfigurationShouldBeApplied() { }))); } + @Test + void customSessionMaxSessionsConfigurationShouldBeApplied() { + this.contextRunner.withPropertyValues("server.reactive.session.max-sessions:123") + .run(assertMaxSessionsWithWebSession(123)); + } + + @Test + void defaultSessionMaxSessionsConfigurationShouldBeInSync() { + int defaultMaxSessions = new InMemoryWebSessionStore().getMaxSessions(); + this.contextRunner.run(assertMaxSessionsWithWebSession(defaultMaxSessions)); + } + @Test void customSessionCookieConfigurationShouldBeApplied() { this.contextRunner.withPropertyValues("server.reactive.session.cookie.name:JSESSIONID", @@ -667,6 +692,58 @@ void problemDetailsBacksOffWhenExceptionHandler() { .hasSingleBean(CustomExceptionHandler.class)); } + @Test + void problemDetailsExceptionHandlerIsOrderedAt0() { + this.contextRunner.withPropertyValues("spring.webflux.problemdetails.enabled:true") + .withUserConfiguration(OrderedControllerAdviceBeansConfiguration.class) + .run((context) -> assertThat( + ControllerAdviceBean.findAnnotatedBeans(context).stream().map(ControllerAdviceBean::getBeanType)) + .asInstanceOf(InstanceOfAssertFactories.list(Class.class)) + .containsExactly(HighestOrderedControllerAdvice.class, ProblemDetailsExceptionHandler.class, + LowestOrderedControllerAdvice.class)); + } + + @Test + void asyncTaskExecutorWithApplicationTaskExecutor() { + this.contextRunner.withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class)) + .run((context) -> { + assertThat(context).hasSingleBean(AsyncTaskExecutor.class); + assertThat(context.getBean(RequestMappingHandlerAdapter.class)).extracting("scheduler.executor") + .isSameAs(context.getBean("applicationTaskExecutor")); + }); + } + + @Test + void asyncTaskExecutorWithNonMatchApplicationTaskExecutorBean() { + this.contextRunner.withUserConfiguration(CustomApplicationTaskExecutorConfig.class) + .withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class)) + .run((context) -> { + assertThat(context).doesNotHaveBean(AsyncTaskExecutor.class); + assertThat(context.getBean(RequestMappingHandlerAdapter.class)).extracting("scheduler.executor") + .isNotSameAs(context.getBean("applicationTaskExecutor")); + }); + } + + @Test + void asyncTaskExecutorWithWebFluxConfigurerCanOverrideExecutor() { + this.contextRunner.withUserConfiguration(CustomAsyncTaskExecutorConfigurer.class) + .withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class)) + .run((context) -> assertThat(context.getBean(RequestMappingHandlerAdapter.class)) + .extracting("scheduler.executor") + .isSameAs(context.getBean(CustomAsyncTaskExecutorConfigurer.class).taskExecutor)); + } + + @Test + void asyncTaskExecutorWithCustomNonApplicationTaskExecutor() { + this.contextRunner.withUserConfiguration(CustomAsyncTaskExecutorConfig.class) + .withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class)) + .run((context) -> { + assertThat(context).hasSingleBean(AsyncTaskExecutor.class); + assertThat(context.getBean(RequestMappingHandlerAdapter.class)).extracting("scheduler.executor") + .isNotSameAs(context.getBean("customTaskExecutor")); + }); + } + private ContextConsumer assertExchangeWithSession( Consumer exchange) { return (context) -> { @@ -691,6 +768,16 @@ private ContextConsumer assertSessionTimeoutWithW }; } + private ContextConsumer assertMaxSessionsWithWebSession(int maxSessions) { + return (context) -> { + WebSessionManager sessionManager = context.getBean(WebSessionManager.class); + assertThat(sessionManager).isInstanceOf(DefaultWebSessionManager.class); + WebSessionStore sessionStore = ((DefaultWebSessionManager) sessionManager).getSessionStore(); + assertThat(sessionStore).isInstanceOf(InMemoryWebSessionStore.class); + assertThat(((InMemoryWebSessionStore) sessionStore).getMaxSessions()).isEqualTo(maxSessions); + }; + } + private Map getHandlerMap(ApplicationContext context) { HandlerMapping mapping = context.getBean("resourceHandlerMapping", HandlerMapping.class); if (mapping instanceof SimpleUrlHandlerMapping simpleMapping) { @@ -971,6 +1058,24 @@ static class CustomExceptionHandler extends ResponseEntityExceptionHandler { } + @Configuration(proxyBeanMethods = false) + @Import({ LowestOrderedControllerAdvice.class, HighestOrderedControllerAdvice.class }) + static class OrderedControllerAdviceBeansConfiguration { + + @ControllerAdvice + @Order + static class LowestOrderedControllerAdvice { + + } + + @ControllerAdvice + @Order(Ordered.HIGHEST_PRECEDENCE) + static class HighestOrderedControllerAdvice { + + } + + } + @Aspect static class ExceptionHandlerInterceptor { @@ -981,4 +1086,36 @@ void exceptionHandlerIntercept(JoinPoint joinPoint, Object returnValue) { } + @Configuration(proxyBeanMethods = false) + static class CustomApplicationTaskExecutorConfig { + + @Bean + Executor applicationTaskExecutor() { + return mock(Executor.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomAsyncTaskExecutorConfig { + + @Bean + AsyncTaskExecutor customTaskExecutor() { + return mock(AsyncTaskExecutor.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomAsyncTaskExecutorConfigurer implements WebFluxConfigurer { + + private final AsyncTaskExecutor taskExecutor = mock(AsyncTaskExecutor.class); + + @Override + public void configureBlockingExecution(BlockingExecutionConfigurer configurer) { + configurer.setExecutor(this.taskExecutor); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java index c61f09fba999..8662c71bedb4 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java @@ -33,12 +33,10 @@ import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.result.view.View; import org.springframework.web.reactive.result.view.ViewResolver; import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.adapter.HttpWebHandlerAdapter; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -54,15 +52,6 @@ */ class DefaultErrorWebExceptionHandlerTests { - @Test - void disconnectedClientExceptionsMatchesFramework() { - Object errorHandlers = ReflectionTestUtils.getField(AbstractErrorWebExceptionHandler.class, - "DISCONNECTED_CLIENT_EXCEPTIONS"); - Object webHandlers = ReflectionTestUtils.getField(HttpWebHandlerAdapter.class, - "DISCONNECTED_CLIENT_EXCEPTIONS"); - assertThat(errorHandlers).isNotNull().isEqualTo(webHandlers); - } - @Test void nonStandardErrorStatusCodeShouldNotFail() { ErrorAttributes errorAttributes = mock(ErrorAttributes.class); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java index 2a05496baf18..9c0d2ee1f2b3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java @@ -17,7 +17,6 @@ package org.springframework.boot.autoconfigure.web.reactive.function.client; import org.apache.hc.client5.http.impl.async.HttpAsyncClients; -import org.eclipse.jetty.reactive.client.ReactiveRequest; import org.junit.jupiter.api.Test; import reactor.netty.http.client.HttpClient; @@ -28,9 +27,9 @@ import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.http.client.reactive.ReactorResourceFactory; import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.Assertions.assertThat; @@ -62,36 +61,20 @@ void whenReactorIsAvailableThenReactorBeansAreDefined() { } @Test - void whenReactorIsUnavailableThenJettyBeansAreDefined() { + void whenReactorIsUnavailableThenHttpClientBeansAreDefined() { this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class)).run((context) -> { BeanDefinition customizerDefinition = context.getBeanFactory() .getBeanDefinition("webClientHttpConnectorCustomizer"); assertThat(customizerDefinition.isLazyInit()).isTrue(); BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("webClientHttpConnector"); assertThat(connectorDefinition.isLazyInit()).isTrue(); - assertThat(context).hasBean("jettyClientResourceFactory"); - assertThat(context).hasBean("jettyClientHttpConnectorFactory"); + assertThat(context).hasBean("httpComponentsClientHttpConnectorFactory"); }); } @Test - void whenReactorAndJettyAreUnavailableThenHttpClientBeansAreDefined() { - this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class, ReactiveRequest.class)) - .run((context) -> { - BeanDefinition customizerDefinition = context.getBeanFactory() - .getBeanDefinition("webClientHttpConnectorCustomizer"); - assertThat(customizerDefinition.isLazyInit()).isTrue(); - BeanDefinition connectorDefinition = context.getBeanFactory() - .getBeanDefinition("webClientHttpConnector"); - assertThat(connectorDefinition.isLazyInit()).isTrue(); - assertThat(context).hasBean("httpComponentsClientHttpConnectorFactory"); - }); - } - - @Test - void whenReactorJettyAndHttpClientBeansAreUnavailableThenJdkClientBeansAreDefined() { - this.contextRunner - .withClassLoader(new FilteredClassLoader(HttpClient.class, ReactiveRequest.class, HttpAsyncClients.class)) + void whenReactorAndHttpClientBeansAreUnavailableThenJdkClientBeansAreDefined() { + this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class, HttpAsyncClients.class)) .run((context) -> { BeanDefinition customizerDefinition = context.getBeanFactory() .getBeanDefinition("webClientHttpConnectorCustomizer"); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java index 79991a079b64..5d7fd0fab66e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java @@ -16,11 +16,6 @@ package org.springframework.boot.autoconfigure.web.reactive.function.client; -import java.util.concurrent.Executor; - -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.io.ByteBufferPool; -import org.eclipse.jetty.util.thread.Scheduler; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -32,13 +27,9 @@ import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; -import org.springframework.http.client.reactive.JettyClientHttpConnector; -import org.springframework.http.client.reactive.JettyResourceFactory; -import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; /** @@ -50,40 +41,6 @@ */ class ClientHttpConnectorFactoryConfigurationTests { - @Test - void jettyClientHttpConnectorAppliesJettyResourceFactory() { - Executor executor = mock(Executor.class); - ByteBufferPool byteBufferPool = mock(ByteBufferPool.class); - Scheduler scheduler = mock(Scheduler.class); - JettyResourceFactory jettyResourceFactory = new JettyResourceFactory(); - jettyResourceFactory.setExecutor(executor); - jettyResourceFactory.setByteBufferPool(byteBufferPool); - jettyResourceFactory.setScheduler(scheduler); - JettyClientHttpConnectorFactory connectorFactory = getJettyClientHttpConnectorFactory(jettyResourceFactory); - JettyClientHttpConnector connector = connectorFactory.createClientHttpConnector(); - HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); - assertThat(httpClient.getExecutor()).isSameAs(executor); - assertThat(httpClient.getByteBufferPool()).isSameAs(byteBufferPool); - assertThat(httpClient.getScheduler()).isSameAs(scheduler); - } - - @Test - void JettyResourceFactoryHasSslContextFactory() { - // gh-16810 - JettyResourceFactory jettyResourceFactory = new JettyResourceFactory(); - JettyClientHttpConnectorFactory connectorFactory = getJettyClientHttpConnectorFactory(jettyResourceFactory); - JettyClientHttpConnector connector = connectorFactory.createClientHttpConnector(); - HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); - assertThat(httpClient.getSslContextFactory()).isNotNull(); - } - - private JettyClientHttpConnectorFactory getJettyClientHttpConnectorFactory( - JettyResourceFactory jettyResourceFactory) { - ClientHttpConnectorFactoryConfiguration.JettyClient jettyClient = new ClientHttpConnectorFactoryConfiguration.JettyClient(); - // We shouldn't usually call this method directly since it's on a non-proxy config - return ReflectionTestUtils.invokeMethod(jettyClient, "jettyClientHttpConnectorFactory", jettyResourceFactory); - } - @Test void shouldApplyHttpClientMapper() { JksSslStoreDetails storeDetails = JksSslStoreDetails.forLocation("classpath:test.jks"); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java index 632d8f707636..951941d02446 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java @@ -19,7 +19,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; /** * Tests for {@link ReactorClientHttpConnectorFactory}. diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfigurationTests.java index 0dbf0eaf0ad6..100d36cdd917 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfigurationTests.java @@ -141,7 +141,6 @@ void renamesMultipartResolver() { void dispatcherServletDefaultConfig() { this.contextRunner.run((context) -> { DispatcherServlet dispatcherServlet = context.getBean(DispatcherServlet.class); - assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(false); assertThat(dispatcherServlet).extracting("dispatchOptionsRequest").isEqualTo(true); assertThat(dispatcherServlet).extracting("dispatchTraceRequest").isEqualTo(false); assertThat(dispatcherServlet).extracting("enableLoggingRequestDetails").isEqualTo(false); @@ -151,15 +150,24 @@ void dispatcherServletDefaultConfig() { }); } + @Test + @Deprecated(since = "3.2.0", forRemoval = true) + void dispatcherServletThrowExceptionIfNoHandlerFoundDefaultConfig() { + this.contextRunner.run((context) -> { + DispatcherServlet dispatcherServlet = context.getBean(DispatcherServlet.class); + assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(true); + }); + } + @Test void dispatcherServletCustomConfig() { this.contextRunner - .withPropertyValues("spring.mvc.throw-exception-if-no-handler-found:true", + .withPropertyValues("spring.mvc.throw-exception-if-no-handler-found:false", "spring.mvc.dispatch-options-request:false", "spring.mvc.dispatch-trace-request:true", "spring.mvc.publish-request-handled-events:false", "spring.mvc.servlet.load-on-startup=5") .run((context) -> { DispatcherServlet dispatcherServlet = context.getBean(DispatcherServlet.class); - assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(true); + assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(false); assertThat(dispatcherServlet).extracting("dispatchOptionsRequest").isEqualTo(false); assertThat(dispatcherServlet).extracting("dispatchTraceRequest").isEqualTo(true); assertThat(dispatcherServlet).extracting("publishEvents").isEqualTo(false); @@ -168,6 +176,15 @@ void dispatcherServletCustomConfig() { }); } + @Test + @Deprecated(since = "3.2.0", forRemoval = true) + void dispatcherServletThrowExceptionIfNoHandlerFoundCustomConfig() { + this.contextRunner.withPropertyValues("spring.mvc.throw-exception-if-no-handler-found:false").run((context) -> { + DispatcherServlet dispatcherServlet = context.getBean(DispatcherServlet.class); + assertThat(dispatcherServlet).extracting("throwExceptionIfNoHandlerFound").isEqualTo(false); + }); + } + @Configuration(proxyBeanMethods = false) static class MultipartConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfigurationTests.java index b1e1dd0926a0..edbc441d871f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/MultipartAutoConfigurationTests.java @@ -31,7 +31,6 @@ import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.boot.testsupport.classpath.ForkedClassPath; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory; @@ -65,6 +64,7 @@ * @author Josh Long * @author Ivan Sopov * @author Toshiaki Maki + * @author Yanming Zhou */ @DirtiesUrlFactories class MultipartAutoConfigurationTests { @@ -175,6 +175,17 @@ void configureResolveLazily() { assertThat(multipartResolver).hasFieldOrPropertyWithValue("resolveLazily", true); } + @Test + void configureStrictServletCompliance() { + this.context = new AnnotationConfigServletWebServerApplicationContext(); + TestPropertyValues.of("spring.servlet.multipart.strict-servlet-compliance=true").applyTo(this.context); + this.context.register(WebServerWithNothing.class, BaseConfiguration.class); + this.context.refresh(); + StandardServletMultipartResolver multipartResolver = this.context + .getBean(StandardServletMultipartResolver.class); + assertThat(multipartResolver).hasFieldOrPropertyWithValue("strictServletCompliance", true); + } + @Test void configureMultipartProperties() { this.context = new AnnotationConfigServletWebServerApplicationContext(); @@ -221,7 +232,6 @@ static class WebServerWithNothing { } - @Servlet5ClassPathOverrides @Configuration(proxyBeanMethods = false) static class WebServerWithNoMultipartJetty { @@ -282,7 +292,6 @@ WebController controller() { } - @Servlet5ClassPathOverrides @Configuration(proxyBeanMethods = false) static class WebServerWithEverythingJetty { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryAutoConfigurationTests.java index 6555da75f8ff..a3211bd3417b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryAutoConfigurationTests.java @@ -38,7 +38,6 @@ import org.springframework.boot.test.context.runner.ContextConsumer; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer; @@ -156,7 +155,6 @@ void initParametersAreConfiguredOnTheServletContext() { } @Test - @Servlet5ClassPathOverrides void jettyServerCustomizerBeanIsAddedToFactory() { WebApplicationContextRunner runner = new WebApplicationContextRunner( AnnotationConfigServletWebServerApplicationContext::new) @@ -171,7 +169,6 @@ void jettyServerCustomizerBeanIsAddedToFactory() { } @Test - @Servlet5ClassPathOverrides void jettyServerCustomizerRegisteredAsBeanAndViaFactoryIsOnlyCalledOnce() { WebApplicationContextRunner runner = new WebApplicationContextRunner( AnnotationConfigServletWebServerApplicationContext::new) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java index 7cff13798441..c428ff17819f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerFactoryCustomizerTests.java @@ -28,11 +28,11 @@ import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.ConfigurationPropertySource; import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.boot.web.server.Cookie; import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory; import org.springframework.boot.web.servlet.server.Jsp; -import org.springframework.boot.web.servlet.server.Session.Cookie; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -97,7 +97,6 @@ void testCustomizeJsp() { } @Test - @SuppressWarnings("removal") void customizeSessionProperties() { Map map = new HashMap<>(); map.put("server.servlet.session.timeout", "123"); @@ -105,7 +104,6 @@ void customizeSessionProperties() { map.put("server.servlet.session.cookie.name", "testname"); map.put("server.servlet.session.cookie.domain", "testdomain"); map.put("server.servlet.session.cookie.path", "/testpath"); - map.put("server.servlet.session.cookie.comment", "testcomment"); map.put("server.servlet.session.cookie.http-only", "true"); map.put("server.servlet.session.cookie.secure", "true"); map.put("server.servlet.session.cookie.max-age", "60"); @@ -118,7 +116,6 @@ void customizeSessionProperties() { assertThat(cookie.getName()).isEqualTo("testname"); assertThat(cookie.getDomain()).isEqualTo("testdomain"); assertThat(cookie.getPath()).isEqualTo("/testpath"); - assertThat(cookie.getComment()).isEqualTo("testcomment"); assertThat(cookie.getHttpOnly()).isTrue(); assertThat(cookie.getMaxAge()).hasSeconds(60); })); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerServletContextListenerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerServletContextListenerTests.java index d2ef77630aa8..ec52ceab9cbb 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerServletContextListenerTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/ServletWebServerServletContextListenerTests.java @@ -26,7 +26,6 @@ import org.springframework.boot.testsupport.classpath.ForkedClassPath; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory; @@ -90,7 +89,6 @@ ServletWebServerFactory webServerFactory() { } - @Servlet5ClassPathOverrides @Configuration(proxyBeanMethods = false) static class JettyConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java index 4b8737d0424c..7ccc5e12bff4 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java @@ -39,6 +39,7 @@ import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterReturning; import org.aspectj.lang.annotation.Aspect; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.springframework.aop.support.AopUtils; @@ -51,6 +52,8 @@ import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; import org.springframework.boot.autoconfigure.validation.ValidatorAdapter; import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfigurationTests.OrderedControllerAdviceBeansConfiguration.HighestOrderedControllerAdvice; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfigurationTests.OrderedControllerAdviceBeansConfiguration.LowestOrderedControllerAdvice; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; import org.springframework.boot.test.context.runner.ContextConsumer; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; @@ -65,6 +68,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.core.convert.ConversionService; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; @@ -89,6 +94,7 @@ import org.springframework.web.filter.FormContentFilter; import org.springframework.web.filter.HiddenHttpMethodFilter; import org.springframework.web.filter.RequestContextFilter; +import org.springframework.web.method.ControllerAdviceBean; import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.FlashMap; import org.springframework.web.servlet.FlashMapManager; @@ -229,7 +235,7 @@ void resourceHandlerMappingOverrideAll() { @Test void resourceHandlerMappingDisabled() { this.contextRunner.withPropertyValues("spring.web.resources.add-mappings:false") - .run((context) -> assertThat(getResourceMappingLocations(context)).hasSize(0)); + .run((context) -> assertThat(getResourceMappingLocations(context)).isEmpty()); } @Test @@ -380,29 +386,6 @@ void customLocaleResolverWithDifferentNameDoesNotReplaceAutoConfiguredLocaleReso }); } - @Test - @Deprecated(since = "3.0.0", forRemoval = true) - @SuppressWarnings("deprecation") - void customThemeResolverWithMatchingNameReplacesDefaultThemeResolver() { - this.contextRunner.withBean("themeResolver", CustomThemeResolver.class, CustomThemeResolver::new) - .run((context) -> { - assertThat(context).hasSingleBean(org.springframework.web.servlet.ThemeResolver.class); - assertThat(context.getBean("themeResolver")).isInstanceOf(CustomThemeResolver.class); - }); - } - - @Test - @Deprecated(since = "3.0.0", forRemoval = true) - @SuppressWarnings("deprecation") - void customThemeResolverWithDifferentNameDoesNotReplaceDefaultThemeResolver() { - this.contextRunner.withBean("customThemeResolver", CustomThemeResolver.class, CustomThemeResolver::new) - .run((context) -> { - assertThat(context.getBean("customThemeResolver")).isInstanceOf(CustomThemeResolver.class); - assertThat(context.getBean("themeResolver")) - .isInstanceOf(org.springframework.web.servlet.theme.FixedThemeResolver.class); - }); - } - @Test void customFlashMapManagerWithMatchingNameReplacesDefaultFlashMapManager() { this.contextRunner.withBean("flashMapManager", CustomFlashMapManager.class, CustomFlashMapManager::new) @@ -493,21 +476,6 @@ void overrideMessageCodesFormat() { .isNotNull()); } - @Test - void ignoreDefaultModelOnRedirectIsTrue() { - this.contextRunner.run((context) -> assertThat(context.getBean(RequestMappingHandlerAdapter.class)) - .extracting("ignoreDefaultModelOnRedirect") - .isEqualTo(true)); - } - - @Test - void overrideIgnoreDefaultModelOnRedirect() { - this.contextRunner.withPropertyValues("spring.mvc.ignore-default-model-on-redirect:false") - .run((context) -> assertThat(context.getBean(RequestMappingHandlerAdapter.class)) - .extracting("ignoreDefaultModelOnRedirect") - .isEqualTo(false)); - } - @Test void customViewResolver() { this.contextRunner.withUserConfiguration(CustomViewResolver.class) @@ -1010,6 +978,17 @@ void problemDetailsBacksOffWhenExceptionHandler() { .hasSingleBean(CustomExceptionHandler.class)); } + @Test + void problemDetailsExceptionHandlerIsOrderedAt0() { + this.contextRunner.withPropertyValues("spring.mvc.problemdetails.enabled:true") + .withUserConfiguration(OrderedControllerAdviceBeansConfiguration.class) + .run((context) -> assertThat( + ControllerAdviceBean.findAnnotatedBeans(context).stream().map(ControllerAdviceBean::getBeanType)) + .asInstanceOf(InstanceOfAssertFactories.list(Class.class)) + .containsExactly(HighestOrderedControllerAdvice.class, ProblemDetailsExceptionHandler.class, + OrderedControllerAdviceBeansConfiguration.LowestOrderedControllerAdvice.class)); + } + private void assertResourceHttpRequestHandler(AssertableWebApplicationContext context, Consumer handlerConsumer) { Map handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class)); @@ -1464,20 +1443,6 @@ public void setLocale(HttpServletRequest request, HttpServletResponse response, } - @Deprecated(since = "3.0.0", forRemoval = true) - static class CustomThemeResolver implements org.springframework.web.servlet.ThemeResolver { - - @Override - public String resolveThemeName(HttpServletRequest request) { - return "custom"; - } - - @Override - public void setThemeName(HttpServletRequest request, HttpServletResponse response, String themeName) { - } - - } - static class CustomFlashMapManager extends AbstractFlashMapManager { @Override @@ -1548,6 +1513,24 @@ CustomExceptionHandler customExceptionHandler() { } + @Configuration(proxyBeanMethods = false) + @Import({ LowestOrderedControllerAdvice.class, HighestOrderedControllerAdvice.class }) + static class OrderedControllerAdviceBeansConfiguration { + + @ControllerAdvice + @Order + static class LowestOrderedControllerAdvice { + + } + + @ControllerAdvice + @Order(Ordered.HIGHEST_PRECEDENCE) + static class HighestOrderedControllerAdvice { + + } + + } + @ControllerAdvice static class CustomExceptionHandler extends ResponseEntityExceptionHandler { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfigurationTests.java index e4d211f54634..ced04e789bfc 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,8 +112,7 @@ private DispatcherServletWebRequest createWebRequest(Exception ex, boolean commi } private ErrorAttributeOptions withAllOptions() { - return ErrorAttributeOptions.of(Include.EXCEPTION, Include.STACK_TRACE, Include.MESSAGE, - Include.BINDING_ERRORS); + return ErrorAttributeOptions.of(Include.values()); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfigurationTests.java index e9505d09632c..e092a9262f2e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/reactive/WebSocketReactiveAutoConfigurationTests.java @@ -24,14 +24,13 @@ import org.apache.catalina.Container; import org.apache.catalina.Context; import org.apache.catalina.startup.Tomcat; -import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.boot.testsupport.classpath.ForkedClassPath; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyReactiveWebServerFactory; import org.springframework.boot.web.embedded.jetty.JettyWebServer; import org.springframework.boot.web.embedded.tomcat.TomcatReactiveWebServerFactory; @@ -123,7 +122,6 @@ ReactiveWebServerFactory webServerFactory() { } - @Servlet5ClassPathOverrides @Configuration(proxyBeanMethods = false) static class JettyConfiguration extends CommonConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java index c2112deeddd6..bd4d9213ba1d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java @@ -30,7 +30,7 @@ import jakarta.websocket.DeploymentException; import jakarta.websocket.server.ServerContainer; import jakarta.websocket.server.ServerEndpoint; -import org.eclipse.jetty.websocket.servlet.WebSocketUpgradeFilter; +import org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -43,7 +43,6 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.testsupport.classpath.ForkedClassPath; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServer; @@ -107,7 +106,6 @@ void webSocketUpgradeDoesNotPreventAFilterFromRejectingTheRequest(String server, } @Test - @Servlet5ClassPathOverrides void jettyWebSocketUpgradeFilterIsAddedToServletContextOfJettyServer() { try (AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext( JettyConfiguration.class, WebSocketServletAutoConfiguration.JettyWebSocketConfiguration.class)) { @@ -142,7 +140,6 @@ void jettyWebSocketUpgradeFilterIsNotExposedAsABean() { } @Test - @Servlet5ClassPathOverrides void jettyWebSocketUpgradeFilterServletContextInitializerBacksOffWhenBeanWithSameNameIsDefined() { try (AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext( JettyConfiguration.class, CustomWebSocketUpgradeFilterServletContextInitializerConfiguration.class, @@ -204,7 +201,6 @@ ServletWebServerFactory webServerFactory() { } - @Servlet5ClassPathOverrides @Configuration(proxyBeanMethods = false) static class JettyConfiguration extends CommonConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.crt b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.crt new file mode 100644 index 000000000000..e381ab69b3d8 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJjCCAhECFHuJXZO0JDPtCSc1/r0llpyc/j9TMA0GCSqGSIb3DQEBCwUAME8x +CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl +ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3Mjg0NVoY +DzIxMjMwOTExMDcyODQ1WjBRMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs +dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQ0wCwYDVQQDDARr +ZXkxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYU2afupPq/b6PIy +6MWDOMRdJk5uW51lrw6oudXpWlUQMXKdsaZT4sqbgjGLggfo7WWsPeCzQN3kIX3T +OqBog5EMkXnlQhAfP2Htj0uXPFj97leZ+FqJrzgPnZY8wSqDXfy9/ycR3PgWjRsS +GZJb05hTNVGTU2vpNQDDo+XBKgybB0afGU8Nk/InWfs1xd/Jv0YcVADQiQEmg41w +g18B3LMIBZPWIJUQ1b7wMlhxWaCNXHfB1bUTIYCUAUOZyEaxPaOOiJo32xKmqOlU +TCLM8zgWCBCEgHtQwSD0GMLhUarLPNE5GP3yo5qHBYqOque7BBjP4e58r6wAyBoe +7kMYRQIDAQABMA0GCSqGSIb3DQEBCwUAA4H/AAMIYpTDxgQwpfk+U1IhkqJjb+Uh +hj6KlT5TEdpn/saGYLZQECZAO21MWrUDTsV2Pax2Ee8ezarCg8Cthu4YOtPauPaL +XpyrIagUOgrDcmXr6QxMKUqifiMurLRFaAS7mWXp0TAFNgzDg3WvF9zMJgkjUp/O +gNSG9U7kXuFfxpVtoalyC2C3g3UeieVXSek3a28h5c/0/DomHqLbyqZh5rYwAJ7C +q1bqA5TnZNVvV731SVueycj9+5PKHKG6eeRRh7roZ34l54O9adNEeDAF0Lqn4sbn +a/h4GPK/u6J6Y3nwrdajipZ2DmfiQwoimxprMGNQKuKA0lc025SGHNno +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.pem b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.pem new file mode 100644 index 000000000000..197eabb17264 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key1.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQChhTZp+6k+r9vo +8jLoxYM4xF0mTm5bnWWvDqi51elaVRAxcp2xplPiypuCMYuCB+jtZaw94LNA3eQh +fdM6oGiDkQyReeVCEB8/Ye2PS5c8WP3uV5n4WomvOA+dljzBKoNd/L3/JxHc+BaN +GxIZklvTmFM1UZNTa+k1AMOj5cEqDJsHRp8ZTw2T8idZ+zXF38m/RhxUANCJASaD +jXCDXwHcswgFk9YglRDVvvAyWHFZoI1cd8HVtRMhgJQBQ5nIRrE9o46ImjfbEqao +6VRMIszzOBYIEISAe1DBIPQYwuFRqss80TkY/fKjmocFio6q57sEGM/h7nyvrADI +Gh7uQxhFAgMBAAECggEABUfEGCHkjgqUv2BPnsF6QTxWKT7uJ6uVG+x4Qp8GInBe +d6deFWUxH9xsygxRmb4ldMFaqKk0Yv3+C8Q/yA5fbFGtHgJkpsy9IMbUS9d2ScBF +COovO+nFz4cfJ5E2SkBYDBYLphBCar1ni1RjupdIzjmQGtGgZd1EwflU7AJCVtwG +S7ltIs2nSOqUFGTfjb9j0NiATZvWTDRtavNMhyrZplKK6M6VoH1ZcnmcvEfF7j5L +oSmXrNKYs4iKn1qKypykfCQoEFK0/EEjj5EdnPaSeI9EERrZK1QnHafB2qK38LSr +8cGaWH24mPW6c/26bDQnHkN3SqKLCODXZMBGhPlLDwKBgQDdMqOzRR3SpTx7KPqp +h+P0diBZb1e6c+Ob0lXD/rfJEtkAqyFLqpi8hN9xodxw++JYbhC69kJE7VWtQLIt +Lc+DG72KTS/cbpnvERL1+AoM0TRbO9Ds9aFP4+Zmm/VDxi9rR5yTgl9iAHJ46VrE +BhnG8JQPBm4n5JU5/wJ9qCQCywKBgQC67uWchaewzDHCiefhTVgwTm1BmHiV/OR4 +50Je2x3GPW6VJGFnBjVzlScKrNyFeOYwscvVS8pTmFP8c5laTbQMC3pVqiWs28Ip +6sy6cXfepVyc0njLFGbiek8ab0rjVYU27D0O9tucrxDx4pKOurilds1Gbm4HjfyE +R7pWn/AfLwKBgQC+5wJzKLaJYsQlAwP6pmYtSHm41ihfqb8Jb2lHwyD4r4SLWCZf +OHejVAXH+0rWU/1QFoXn5brh4/cqlIhyB3RtkdZucxlYZDgEJLc5g32g/Dj0eFZi ++8bhvS3O5tCxUm0AaIiQolcRrJMfGT6VqTI8CMuvf/w3/8ZujFCpBCE4KwKBgBiw +lQMnZA6l6ayYKlhHru4ybZvMV6D31fViFhIRPs2AL6rjMzo4R7cMbCusyTOX1E96 +LEHv0LlZ1T3yxr52pOEyYuYNowxBulNu/7tgYUS28pSD+BBakXw4S1pieLGuCfpH +GYlwcXEwbjyEgHb5konINzSmQUIeLswJ7UKjvUNhAoGAXmXvyHqdL04SD99G3B/5 ++azzzAVR1fvGYOvq+/hWZMG5PS0kx2V3txCVyY8E1/lCysp9BuUHtW+vOS8YGhAT +wkZ/X9igZteQvvdVw+E5CXS05b4EBI+7ZViL9ulXFZ4YC70lKcUE52bmaPM+onQJ +Y1s9JWTe2EAkxsuxm+hkjo0= +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2-chain.crt b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2-chain.crt new file mode 100644 index 000000000000..3b55b95a96ae --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2-chain.crt @@ -0,0 +1,38 @@ +-----BEGIN CERTIFICATE----- +MIIDJjCCAhECFFjLlXVdTxDdLlCifzrA0dTHHJ2mMA0GCSqGSIb3DQEBCwUAME8x +CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl +ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3Mjg1MFoY +DzIxMjMwOTExMDcyODUwWjBRMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs +dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQ0wCwYDVQQDDARr +ZXkyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAspCMUdFGyKkgpMbW ++UwSg4fdKM4qLSH7voTdsdVM9aAvLvYjBQ4gpORxDZNfUz67R0Ua0/oJt9jD49Wp +qcq+tDOnp0dPtn2hFluV5PxM6d+MCSx/frPsfvyt9234okLL1zdLDNFYEbLhSPjA +ku3vHw/OwlJOxCRwTkPqcElIV4+IvIbzAgSffyokzm/wKVKEhoT6NcfeU+6wCkTu +al1X8loJ+27N6jN13oGZfH7EveBqgR8rPs55+54S/OcVG/uqL9ggOGRJiIZ3jUBk +m5cN27wKkaNg/CQwa1UjcU4qshVpknHw1dpgJ2Gbs/yUphwpEZl/FTsZFcK1KCHD +rOp3PQIDAQABMA0GCSqGSIb3DQEBCwUAA4H/AAFmEq86broBFxs0cpImaM884PBT +bvJBSsFhsOg6mi4Gt01G/lPSj/ExNtH3G5bytCYAPaRxNx/dCs7uON3p86ta4zL8 +2PxgyhX1oY/GG63ETwn5s3GKpRaGTNVDWvPIM9RX6+bvX/wOg8eYXVaQlG5XYadC +Ms9lWqHaM1C/iLGNmUTGcdbvhnmQDky2CwPNm+lXogSWbrsGpAmCkXJD1H+0Mx8I +wjDVtGLBwr/8oXI8WbhvISMnS9+dd7+GLm6mU+14Kswi5I7EmBmREvkswi2IVJ6M +GL7EY3qA6iqJWqsseYyLxiMr3nBT0SETphzoDanUQI1/jXQPrWIyjqvs +-----END CERTIFICATE----- +-----BEGIN TRUSTED CERTIFICATE----- +MIIDIDCCAgsCFH3lh1RXOEy2ESqUPyzb+9zxMYUnMA0GCSqGSIb3DQEBCwUAME8x +CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl +ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3MjU1M1oY +DzIxMjMwOTExMDcyNTUzWjBPMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs +dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQswCQYDVQQDDAJD +QTCCAR4wDQYJKoZIhvcNAQEBBQADggELADCCAQYCgf4NNpc+6B3qvwKcRYgoXmJ4 +3wyWktBK7BdShz/YnW1OlFZ+R845ZiDw0KdzElZWkYqn+BYJus6lPIS5dfLcrGSf +a1e8IK02RpBiY/WJvupetnSk8gKA7emF94NlV4gXr4ICJAhXvXUFyBLpdEUE/lcg +lgCbVJzs5jWUnffEF9mrClzzo0+iXw34zwmyYyBTFmlOEr+QUEdAb6Lr/klpTVit +as2Ddg1QT4EaSIdTEpkVRZp2dyYVdqSxpaBq21xg0viDHsYQrP96IfacmUB7kFFn +HsnptDHFvJj2WSQDX+PRS7tLl4mmfizZg80eGfLD22ShNspRSGnbJc0OzegPiwID +AQABMA0GCSqGSIb3DQEBCwUAA4H/AAnC+FQqdeJaG5I7R+pNjgKplL2UsxW983kA +CVVkv/Dt0+4rbPC67o9/8Tr+g4eo/wUntMNo2ghF3oBItGr7pJE16zPiLwIvha9c +8BDhCEZWyhz3vkamZUi19lOnkm3zTmmDE/nX4WYH6CL4UWjxvniZYwW8AdVSnFXY +ncriuvfliLa3dw1SJ7FtxdcBn4yfzrZWcY+psYNHpftLGYRmQF/VCDSB9EAIEggr +yBcP749u2y8s44WvKAnnwfLcALIrylY25zN0pao/l2X8HI6qHUeA/QbbEBpDoQvR +du/rgaHCVvFFxATefhBJ0CUA1Nn5nrGwyRTKnZWtR080qwUp +-----END TRUSTED CERTIFICATE----- diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.crt b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.crt new file mode 100644 index 000000000000..127882627896 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJjCCAhECFFjLlXVdTxDdLlCifzrA0dTHHJ2mMA0GCSqGSIb3DQEBCwUAME8x +CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl +ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3Mjg1MFoY +DzIxMjMwOTExMDcyODUwWjBRMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs +dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQ0wCwYDVQQDDARr +ZXkyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAspCMUdFGyKkgpMbW ++UwSg4fdKM4qLSH7voTdsdVM9aAvLvYjBQ4gpORxDZNfUz67R0Ua0/oJt9jD49Wp +qcq+tDOnp0dPtn2hFluV5PxM6d+MCSx/frPsfvyt9234okLL1zdLDNFYEbLhSPjA +ku3vHw/OwlJOxCRwTkPqcElIV4+IvIbzAgSffyokzm/wKVKEhoT6NcfeU+6wCkTu +al1X8loJ+27N6jN13oGZfH7EveBqgR8rPs55+54S/OcVG/uqL9ggOGRJiIZ3jUBk +m5cN27wKkaNg/CQwa1UjcU4qshVpknHw1dpgJ2Gbs/yUphwpEZl/FTsZFcK1KCHD +rOp3PQIDAQABMA0GCSqGSIb3DQEBCwUAA4H/AAFmEq86broBFxs0cpImaM884PBT +bvJBSsFhsOg6mi4Gt01G/lPSj/ExNtH3G5bytCYAPaRxNx/dCs7uON3p86ta4zL8 +2PxgyhX1oY/GG63ETwn5s3GKpRaGTNVDWvPIM9RX6+bvX/wOg8eYXVaQlG5XYadC +Ms9lWqHaM1C/iLGNmUTGcdbvhnmQDky2CwPNm+lXogSWbrsGpAmCkXJD1H+0Mx8I +wjDVtGLBwr/8oXI8WbhvISMnS9+dd7+GLm6mU+14Kswi5I7EmBmREvkswi2IVJ6M +GL7EY3qA6iqJWqsseYyLxiMr3nBT0SETphzoDanUQI1/jXQPrWIyjqvs +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.pem b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.pem new file mode 100644 index 000000000000..9e21a1c3f421 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/ssl/key2.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCykIxR0UbIqSCk +xtb5TBKDh90oziotIfu+hN2x1Uz1oC8u9iMFDiCk5HENk19TPrtHRRrT+gm32MPj +1ampyr60M6enR0+2faEWW5Xk/Ezp34wJLH9+s+x+/K33bfiiQsvXN0sM0VgRsuFI ++MCS7e8fD87CUk7EJHBOQ+pwSUhXj4i8hvMCBJ9/KiTOb/ApUoSGhPo1x95T7rAK +RO5qXVfyWgn7bs3qM3XegZl8fsS94GqBHys+znn7nhL85xUb+6ov2CA4ZEmIhneN +QGSblw3bvAqRo2D8JDBrVSNxTiqyFWmScfDV2mAnYZuz/JSmHCkRmX8VOxkVwrUo +IcOs6nc9AgMBAAECggEAPN9dDolG1aIeYD3uzCa8Sv2WjdIWe7NRlEXMI9MgvL1i +SGKdVpxV0ZCU37llLkY85tNujWP4SyXIxdMxVxIoR9syJKsBSCd0sl//bgP6nmHY +Zco3HnTswu+VyLtDHuGhhtkxKwn0uXffKBaw44XcVhz38bPIaUI4zN2HPscks8BG +j2MEl0N8P/TVrTkhgdjfoRi73VAisrEe+1wCg74BT7cmR8fEr7iNFrv955sdPGdw +UTmx8U26++wbeYQs1ZE1713SYnRQuCUFs5GGjzOhNFi27zuhI6TafoVm9PO4j+ZC +JUKTyUTBUsRMvm9z1IoHdjM8yInAv2g0J1bAeCTY+wKBgQDuMNMbNVoiXRKsSUry +22T3W6HVLfLNKiYMNxsAkJjOiyyJcC+yg9BErn/haIHSafD2WmuWbW5ASViyl6fn +D8qMluTwEaSrTgHXWI4ahWyapDShDQYp1s4dB75Aa/LVcFCay54YEtyCPzCPlj1K +jz5OBV14NEVVA2cf59fIc/LXCwKBgQC/6m3TefUp5jnN/QUOx2OtZo8Y1pVrsuMB +AuTtb21Khxn/86ZpVzySzg79/DkSNf9/sZhzj0IkviWNP5S8iAAaFC1q08CYhdCX +d7tVnHlzpZmmoHUhG6dlJZayr1duZrURp2rP18+wIsKiFRImAyjc6yswVRpZgAiG +gOkHCB231wKBgGlwXZMWy/6YOtLfYvkcm5ZQDtSCkY+2j78qiZ53Y91SiHWSntqk +NQaiRGOw0n8lfJBhOG0PphV5InV0YtQLDnurtE59UOqwDmqYfddJpujRtaZxUIAm +4XjCW7rCzm0jWdscNbCscMaLWGDHffxKaqc5AsZaRTK73eOmysOmaCI/AoGAf/yd +RZ1dzJWHE0Kb7uE2LlvpLo1clLh1/ySo+1eGMV+sDS+2WSYedWEKSoO8o9JzE/ui +Sd7OI6bTcEFotdqVBs9SAp45IP6Mv5bPziZOMLvNnnv/4RaKKkBJId0hl7TTKHTY +HMg176ce2eznb4ZH6BzFbrQyoGFsThcGUPQurX0CgYBYtkDTp21TI1nuak7xpMIY +BJQpqF5ahBf/+QYWtL0f3ca9MO2++zv5/XXitvt48cY1bCHNrVvSHgRzwSrOorZA +5u7a5zyvfXjY3LY3k0VHddaVjU0mHsjx/1ux0wO2v8wQjOVZpT7XweB3WlUEGV7C +5T/p+rmGg5Y5dTKUVCyvbQ== +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot-devtools/build.gradle b/spring-boot-project/spring-boot-devtools/build.gradle index 7e33304df021..b3047fd5d52d 100644 --- a/spring-boot-project/spring-boot-devtools/build.gradle +++ b/spring-boot-project/spring-boot-devtools/build.gradle @@ -65,9 +65,7 @@ dependencies { testImplementation("org.apache.tomcat.embed:tomcat-embed-jasper") testImplementation("org.assertj:assertj-core") testImplementation("org.awaitility:awaitility") - testImplementation("org.eclipse.jetty.websocket:websocket-jakarta-client") { - exclude group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-websocket-api" - } + testImplementation("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-client") testImplementation("org.hamcrest:hamcrest-library") testImplementation("org.hsqldb:hsqldb") testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/filewatch/FileSystemWatcher.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/filewatch/FileSystemWatcher.java index 99ef112ea3b5..35a950c6e3b1 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/filewatch/FileSystemWatcher.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/filewatch/FileSystemWatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -158,9 +158,7 @@ public void setTriggerFilter(FileFilter triggerFilter) { } private void checkNotStarted() { - synchronized (this.monitor) { - Assert.state(this.watchThread == null, "FileSystemWatcher already started"); - } + Assert.state(this.watchThread == null, "FileSystemWatcher already started"); } /** diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/remote/client/ClassPathChangeUploader.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/remote/client/ClassPathChangeUploader.java index 8cfe3144db9f..44bf7d24d58d 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/remote/client/ClassPathChangeUploader.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/remote/client/ClassPathChangeUploader.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -92,15 +92,14 @@ public void onApplicationEvent(ClassPathChangedEvent event) { try { ClassLoaderFiles classLoaderFiles = getClassLoaderFiles(event); byte[] bytes = serialize(classLoaderFiles); - performUpload(classLoaderFiles, bytes, event); + performUpload(bytes, event); } catch (IOException ex) { throw new IllegalStateException(ex); } } - private void performUpload(ClassLoaderFiles classLoaderFiles, byte[] bytes, ClassPathChangedEvent event) - throws IOException { + private void performUpload(byte[] bytes, ClassPathChangedEvent event) throws IOException { try { while (true) { try { diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java index 071395a21e4e..284056061943 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java @@ -22,7 +22,6 @@ import java.net.URL; import java.util.Arrays; import java.util.Collection; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; @@ -30,6 +29,7 @@ import java.util.Set; import java.util.concurrent.BlockingDeque; import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.ThreadFactory; @@ -68,9 +68,9 @@ * {@link #initialize(String[])} directly if your SpringApplication arguments are not * identical to your main method arguments. *

- * By default, applications running in an IDE (i.e. those not packaged as "fat jars") will - * automatically detect URLs that can change. It's also possible to manually configure - * URLs or class file updates for remote restart scenarios. + * By default, applications running in an IDE (i.e. those not packaged as "uber jars") + * will automatically detect URLs that can change. It's also possible to manually + * configure URLs or class file updates for remote restart scenarios. * * @author Phillip Webb * @author Andy Wilkinson @@ -92,7 +92,7 @@ public class Restarter { private final ClassLoaderFiles classLoaderFiles = new ClassLoaderFiles(); - private final Map attributes = new HashMap<>(); + private final Map attributes = new ConcurrentHashMap<>(); private final BlockingDeque leakSafeThreads = new LinkedBlockingDeque<>(); @@ -440,18 +440,11 @@ private LeakSafeThread getLeakSafeThread() { } public Object getOrAddAttribute(String name, final ObjectFactory objectFactory) { - synchronized (this.attributes) { - if (!this.attributes.containsKey(name)) { - this.attributes.put(name, objectFactory.getObject()); - } - return this.attributes.get(name); - } + return this.attributes.computeIfAbsent(name, (ignore) -> objectFactory.getObject()); } public Object removeAttribute(String name) { - synchronized (this.attributes) { - return this.attributes.remove(name); - } + return this.attributes.remove(name); } /** diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFile.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFile.java index d39c569e5ebd..46df7df0d325 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFile.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFile.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 the original author 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,8 +55,12 @@ public ClassLoaderFile(Kind kind, byte[] contents) { */ public ClassLoaderFile(Kind kind, long lastModified, byte[] contents) { Assert.notNull(kind, "Kind must not be null"); - Assert.isTrue((kind != Kind.DELETED) ? contents != null : contents == null, - () -> "Contents must " + ((kind != Kind.DELETED) ? "not " : "") + "be null"); + if (kind == Kind.DELETED) { + Assert.isTrue(contents == null, "Contents must be null"); + } + else { + Assert.isTrue(contents != null, "Contents must not be null"); + } this.kind = kind; this.lastModified = lastModified; this.contents = contents; diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java index e611e5f39827..06e7d8f3f97b 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -108,12 +108,7 @@ private void removeAll(String name) { * @return an existing or newly added {@link SourceDirectory} */ protected final SourceDirectory getOrCreateSourceDirectory(String name) { - SourceDirectory sourceDirectory = this.sourceDirectories.get(name); - if (sourceDirectory == null) { - sourceDirectory = new SourceDirectory(name); - this.sourceDirectories.put(name, sourceDirectory); - } - return sourceDirectory; + return this.sourceDirectories.computeIfAbsent(name, (key) -> new SourceDirectory(name)); } /** diff --git a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/ChangeableUrlsTests.java b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/ChangeableUrlsTests.java index 354fd1d7e301..35504c892648 100644 --- a/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/ChangeableUrlsTests.java +++ b/spring-boot-project/spring-boot-devtools/src/test/java/org/springframework/boot/devtools/restart/ChangeableUrlsTests.java @@ -48,7 +48,7 @@ class ChangeableUrlsTests { @Test void directoryUrl() throws Exception { URL url = makeUrl("myproject"); - assertThat(ChangeableUrls.fromUrls(url).size()).isOne(); + assertThat(ChangeableUrls.fromUrls(url)).hasSize(1); } @Test diff --git a/spring-boot-project/spring-boot-docker-compose/build.gradle b/spring-boot-project/spring-boot-docker-compose/build.gradle index b0429632ffac..ec1665712dec 100644 --- a/spring-boot-project/spring-boot-docker-compose/build.gradle +++ b/spring-boot-project/spring-boot-docker-compose/build.gradle @@ -18,6 +18,7 @@ dependencies { optional(project(":spring-boot-project:spring-boot-actuator-autoconfigure")) optional("io.r2dbc:r2dbc-spi") optional("org.mongodb:mongodb-driver-core") + optional("org.neo4j.driver:neo4j-java-driver") optional("org.springframework.data:spring-data-r2dbc") testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java index 626c3262bdaf..1990f9b27134 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicate.java @@ -16,28 +16,33 @@ package org.springframework.boot.docker.compose.service.connection; +import java.util.Arrays; +import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; import org.springframework.boot.docker.compose.core.ImageReference; import org.springframework.boot.docker.compose.core.RunningService; +import org.springframework.util.Assert; /** - * {@link Predicate} that matches against connection names. + * {@link Predicate} that matches against connection name. * * @author Phillip Webb */ class ConnectionNamePredicate implements Predicate { - private final String required; + private final Set required; - ConnectionNamePredicate(String required) { - this.required = asCanonicalName(required); + ConnectionNamePredicate(String... required) { + Assert.notEmpty(required, "Required must not be empty"); + this.required = Arrays.stream(required).map(this::asCanonicalName).collect(Collectors.toSet()); } @Override public boolean test(DockerComposeConnectionSource source) { String actual = getActual(source.getRunningService()); - return this.required.equals(actual); + return this.required.contains(actual); } private String getActual(RunningService service) { diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java index 6d29c8bb0247..302a3ba35817 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/DockerComposeConnectionDetailsFactory.java @@ -54,6 +54,16 @@ protected DockerComposeConnectionDetailsFactory(String connectionName, String... this(new ConnectionNamePredicate(connectionName), requiredClassNames); } + /** + * Create a new {@link DockerComposeConnectionDetailsFactory} instance. + * @param connectionNames the required connection name + * @param requiredClassNames the names of classes that must be present + * @since 3.2.0 + */ + protected DockerComposeConnectionDetailsFactory(String[] connectionNames, String... requiredClassNames) { + this(new ConnectionNamePredicate(connectionNames), requiredClassNames); + } + /** * Create a new {@link DockerComposeConnectionDetailsFactory} instance. * @param predicate a predicate used to check when a service is accepted diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..ac3809d8da21 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.activemq; + +import org.springframework.boot.autoconfigure.amqp.RabbitConnectionDetails; +import org.springframework.boot.autoconfigure.jms.activemq.ActiveMQConnectionDetails; +import org.springframework.boot.docker.compose.core.RunningService; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create + * {@link ActiveMQConnectionDetails} for an {@code activemq} service. + * + * @author Stephane Nicoll + */ +class ActiveMQDockerComposeConnectionDetailsFactory + extends DockerComposeConnectionDetailsFactory { + + private static final int ACTIVEMQ_PORT = 61616; + + protected ActiveMQDockerComposeConnectionDetailsFactory() { + super("symptoma/activemq"); + } + + @Override + protected ActiveMQConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) { + return new ActiveMQDockerComposeConnectionDetails(source.getRunningService()); + } + + /** + * {@link RabbitConnectionDetails} backed by a {@code rabbitmq} + * {@link RunningService}. + */ + static class ActiveMQDockerComposeConnectionDetails extends DockerComposeConnectionDetails + implements ActiveMQConnectionDetails { + + private final ActiveMQEnvironment environment; + + private final String brokerUrl; + + protected ActiveMQDockerComposeConnectionDetails(RunningService service) { + super(service); + this.environment = new ActiveMQEnvironment(service.env()); + this.brokerUrl = "tcp://" + service.host() + ":" + service.ports().get(ACTIVEMQ_PORT); + } + + @Override + public String getBrokerUrl() { + return this.brokerUrl; + } + + @Override + public String getUser() { + return this.environment.getUser(); + } + + @Override + public String getPassword() { + return this.environment.getPassword(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironment.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironment.java new file mode 100644 index 000000000000..742389e80a7e --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironment.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.activemq; + +import java.util.Map; + +/** + * ActiveMQ environment details. + * + * @author Stephane Nicoll + */ +class ActiveMQEnvironment { + + private final String user; + + private final String password; + + ActiveMQEnvironment(Map env) { + this.user = env.get("ACTIVEMQ_USERNAME"); + this.password = env.get("ACTIVEMQ_PASSWORD"); + } + + String getUser() { + return this.user; + } + + String getPassword() { + return this.password; + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/package-info.java new file mode 100644 index 000000000000..5cb2e75cf5b4 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/activemq/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Auto-configuration for docker compose ActiveMQ service connections. + */ +package org.springframework.boot.docker.compose.service.connection.activemq; diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ldap/OpenLdapDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ldap/OpenLdapDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..b5674f152ff4 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ldap/OpenLdapDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docker.compose.service.connection.ldap; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.boot.autoconfigure.ldap.LdapConnectionDetails; +import org.springframework.boot.docker.compose.core.RunningService; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create {@link LdapConnectionDetails} + * for an {@code ldap} service. + * + * @author Philipp Kessler + */ +class OpenLdapDockerComposeConnectionDetailsFactory + extends DockerComposeConnectionDetailsFactory { + + protected OpenLdapDockerComposeConnectionDetailsFactory() { + super("osixia/openldap"); + } + + @Override + protected LdapConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) { + return new OpenLdapDockerComposeConnectionDetails(source.getRunningService()); + } + + /** + * {@link LdapConnectionDetails} backed by an {@code openldap} {@link RunningService}. + */ + static class OpenLdapDockerComposeConnectionDetails extends DockerComposeConnectionDetails + implements LdapConnectionDetails { + + private final String[] urls; + + private final String base; + + private final String username; + + private final String password; + + OpenLdapDockerComposeConnectionDetails(RunningService service) { + super(service); + Map env = service.env(); + boolean usesTls = Boolean.parseBoolean(env.getOrDefault("LDAP_TLS", "true")); + String ldapPort = usesTls ? env.getOrDefault("LDAPS_PORT", "636") : env.getOrDefault("LDAP_PORT", "389"); + this.urls = new String[] { "%s://%s:%d".formatted(usesTls ? "ldaps" : "ldap", service.host(), + service.ports().get(Integer.parseInt(ldapPort))) }; + if (env.containsKey("LDAP_BASE_DN")) { + this.base = env.get("LDAP_BASE_DN"); + } + else { + this.base = Arrays.stream(env.getOrDefault("LDAP_DOMAIN", "example.org").split("\\.")) + .map("dc=%s"::formatted) + .collect(Collectors.joining(",")); + } + this.password = env.getOrDefault("LDAP_ADMIN_PASSWORD", "admin"); + this.username = "cn=admin,%s".formatted(this.base); + } + + @Override + public String[] getUrls() { + return this.urls; + } + + @Override + public String getBase() { + return this.base; + } + + @Override + public String getUsername() { + return this.username; + } + + @Override + public String getPassword() { + return this.password; + } + + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ldap/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ldap/package-info.java new file mode 100644 index 000000000000..489148d2777e --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/ldap/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for Docker Compose LDAP service connections. + */ +package org.springframework.boot.docker.compose.service.connection.ldap; diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..33bd622e23ab --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.neo4j; + +import java.net.URI; + +import org.neo4j.driver.AuthToken; + +import org.springframework.boot.autoconfigure.neo4j.Neo4jConnectionDetails; +import org.springframework.boot.docker.compose.core.RunningService; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create {@link Neo4jConnectionDetails} + * for a {@code Neo4j} service. + * + * @author Andy Wilkinson + */ +class Neo4jDockerComposeConnectionDetailsFactory extends DockerComposeConnectionDetailsFactory { + + Neo4jDockerComposeConnectionDetailsFactory() { + super("neo4j"); + } + + @Override + protected Neo4jConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) { + return new Neo4jDockerComposeConnectionDetails(source.getRunningService()); + } + + /** + * {@link Neo4jConnectionDetails} backed by a {@code Neo4j} {@link RunningService}. + */ + static class Neo4jDockerComposeConnectionDetails extends DockerComposeConnectionDetails + implements Neo4jConnectionDetails { + + private static final int BOLT_PORT = 7687; + + private final AuthToken authToken; + + private final URI uri; + + Neo4jDockerComposeConnectionDetails(RunningService service) { + super(service); + Neo4jEnvironment neo4jEnvironment = new Neo4jEnvironment(service.env()); + this.authToken = neo4jEnvironment.getAuthToken(); + this.uri = URI.create("neo4j://%s:%d".formatted(service.host(), service.ports().get(BOLT_PORT))); + } + + @Override + public URI getUri() { + return this.uri; + } + + @Override + public AuthToken getAuthToken() { + return this.authToken; + } + + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironment.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironment.java new file mode 100644 index 000000000000..59e5a90e9230 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironment.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.neo4j; + +import java.util.Map; + +import org.neo4j.driver.AuthToken; +import org.neo4j.driver.AuthTokens; + +/** + * Neo4j environment details. + * + * @author Andy Wilkinson + */ +class Neo4jEnvironment { + + private final AuthToken authToken; + + Neo4jEnvironment(Map env) { + this.authToken = parse(env.get("NEO4J_AUTH")); + } + + private AuthToken parse(String neo4jAuth) { + if (neo4jAuth == null) { + return null; + } + if ("none".equals(neo4jAuth)) { + return AuthTokens.none(); + } + if (neo4jAuth.startsWith("neo4j/")) { + return AuthTokens.basic("neo4j", neo4jAuth.substring(6)); + } + throw new IllegalStateException( + "Cannot extract auth token from NEO4J_AUTH environment variable with value '" + neo4jAuth + "'." + + " Value should be 'none' to disable authentication or start with 'neo4j/' to specify" + + " the neo4j user's password"); + } + + AuthToken getAuthToken() { + return this.authToken; + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/package-info.java new file mode 100644 index 000000000000..afea67c3cf5c --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/neo4j/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Auto-configuration for docker compose Neo4j service connections. + */ +package org.springframework.boot.docker.compose.service.connection.neo4j; diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleContainer.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleContainer.java new file mode 100644 index 000000000000..55776fd3a132 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleContainer.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.oracle; + +/** + * Enumeration of supported Oracle containers. + * + * @author Andy Wilkinson + */ +enum OracleContainer { + + FREE("gvenzl/oracle-free", "freepdb1"), + + XE("gvenzl/oracle-xe", "xepdb1"); + + private final String imageName; + + private final String defaultDatabase; + + OracleContainer(String imageName, String defaultDatabase) { + this.imageName = imageName; + this.defaultDatabase = defaultDatabase; + } + + String getImageName() { + return this.imageName; + } + + String getDefaultDatabase() { + return this.defaultDatabase; + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironment.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironment.java index 78011a7d6cfc..c38b595263c6 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironment.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironment.java @@ -34,10 +34,10 @@ class OracleEnvironment { private final String database; - OracleEnvironment(Map env) { + OracleEnvironment(Map env, String defaultDatabase) { this.username = env.getOrDefault("APP_USER", "system"); this.password = extractPassword(env); - this.database = env.getOrDefault("ORACLE_DATABASE", "xepdb1"); + this.database = env.getOrDefault("ORACLE_DATABASE", defaultDatabase); } private String extractPassword(Map env) { diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..85e017a47d74 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.oracle; + +import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create {@link JdbcConnectionDetails} + * for an {@link OracleContainer#FREE} service. + * + * @author Andy Wilkinson + */ +class OracleFreeJdbcDockerComposeConnectionDetailsFactory extends OracleJdbcDockerComposeConnectionDetailsFactory { + + protected OracleFreeJdbcDockerComposeConnectionDetailsFactory() { + super(OracleContainer.FREE); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..3e4ae171b928 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.oracle; + +import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create {@link R2dbcConnectionDetails} + * for an {@link OracleContainer#FREE} service. + * + * @author Andy Wilkinson + */ +class OracleFreeR2dbcDockerComposeConnectionDetailsFactory extends OracleR2dbcDockerComposeConnectionDetailsFactory { + + protected OracleFreeR2dbcDockerComposeConnectionDetailsFactory() { + super(OracleContainer.FREE); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactory.java index 9104a9ff267a..924195ab4d90 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactory.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactory.java @@ -23,27 +23,30 @@ import org.springframework.util.StringUtils; /** - * {@link DockerComposeConnectionDetailsFactory} to create {@link JdbcConnectionDetails} - * for an {@code oracle-xe} service. + * Base class for a {@link DockerComposeConnectionDetailsFactory} to create + * {@link JdbcConnectionDetails} for an {@code oracle-free} or {@code oracle-xe} service. * * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb */ -class OracleJdbcDockerComposeConnectionDetailsFactory +abstract class OracleJdbcDockerComposeConnectionDetailsFactory extends DockerComposeConnectionDetailsFactory { - protected OracleJdbcDockerComposeConnectionDetailsFactory() { - super("gvenzl/oracle-xe"); + private final String defaultDatabase; + + protected OracleJdbcDockerComposeConnectionDetailsFactory(OracleContainer container) { + super(container.getImageName()); + this.defaultDatabase = container.getDefaultDatabase(); } @Override protected JdbcConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) { - return new OracleJdbcDockerComposeConnectionDetails(source.getRunningService()); + return new OracleJdbcDockerComposeConnectionDetails(source.getRunningService(), this.defaultDatabase); } /** - * {@link JdbcConnectionDetails} backed by an {@code oracle-xe} + * {@link JdbcConnectionDetails} backed by an {@code oracle-xe} or {@code oracle-free} * {@link RunningService}. */ static class OracleJdbcDockerComposeConnectionDetails extends DockerComposeConnectionDetails @@ -55,9 +58,9 @@ static class OracleJdbcDockerComposeConnectionDetails extends DockerComposeConne private final String jdbcUrl; - OracleJdbcDockerComposeConnectionDetails(RunningService service) { + OracleJdbcDockerComposeConnectionDetails(RunningService service, String defaultDatabase) { super(service); - this.environment = new OracleEnvironment(service.env()); + this.environment = new OracleEnvironment(service.env(), defaultDatabase); this.jdbcUrl = "jdbc:oracle:thin:@" + service.host() + ":" + service.ports().get(1521) + "/" + this.environment.getDatabase() + getParameters(service); } diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactory.java index 5ebf98956e9b..9d54bee83f67 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactory.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactory.java @@ -25,23 +25,26 @@ import org.springframework.boot.docker.compose.service.connection.r2dbc.ConnectionFactoryOptionsBuilder; /** - * {@link DockerComposeConnectionDetailsFactory} to create {@link R2dbcConnectionDetails} - * for an {@code oracle-xe} service. + * Base class for a {@link DockerComposeConnectionDetailsFactory} to create + * {@link R2dbcConnectionDetails} for an {@code oracle-free} or {@code oracle-xe} service. * * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb */ -class OracleR2dbcDockerComposeConnectionDetailsFactory +abstract class OracleR2dbcDockerComposeConnectionDetailsFactory extends DockerComposeConnectionDetailsFactory { - OracleR2dbcDockerComposeConnectionDetailsFactory() { - super("gvenzl/oracle-xe", "io.r2dbc.spi.ConnectionFactoryOptions"); + private final String defaultDatabase; + + OracleR2dbcDockerComposeConnectionDetailsFactory(OracleContainer container) { + super(container.getImageName(), "io.r2dbc.spi.ConnectionFactoryOptions"); + this.defaultDatabase = container.getDefaultDatabase(); } @Override protected R2dbcConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) { - return new OracleDbR2dbcDockerComposeConnectionDetails(source.getRunningService()); + return new OracleDbR2dbcDockerComposeConnectionDetails(source.getRunningService(), this.defaultDatabase); } /** @@ -56,9 +59,9 @@ static class OracleDbR2dbcDockerComposeConnectionDetails extends DockerComposeCo private final ConnectionFactoryOptions connectionFactoryOptions; - OracleDbR2dbcDockerComposeConnectionDetails(RunningService service) { + OracleDbR2dbcDockerComposeConnectionDetails(RunningService service, String defaultDatabase) { super(service); - OracleEnvironment environment = new OracleEnvironment(service.env()); + OracleEnvironment environment = new OracleEnvironment(service.env(), defaultDatabase); this.connectionFactoryOptions = connectionFactoryOptionsBuilder.build(service, environment.getDatabase(), environment.getUsername(), environment.getPassword()); } diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..75da136d567e --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.oracle; + +import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create {@link JdbcConnectionDetails} + * for an {@link OracleContainer#XE} service. + * + * @author Andy Wilkinson + */ +class OracleXeJdbcDockerComposeConnectionDetailsFactory extends OracleJdbcDockerComposeConnectionDetailsFactory { + + protected OracleXeJdbcDockerComposeConnectionDetailsFactory() { + super(OracleContainer.XE); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..f5b02edde660 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.oracle; + +import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create {@link R2dbcConnectionDetails} + * for an {@link OracleContainer#XE} service. + * + * @author Andy Wilkinson + */ +class OracleXeR2dbcDockerComposeConnectionDetailsFactory extends OracleR2dbcDockerComposeConnectionDetailsFactory { + + protected OracleXeR2dbcDockerComposeConnectionDetailsFactory() { + super(OracleContainer.XE); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..49913297040c --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.otlp; + +import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsConnectionDetails; +import org.springframework.boot.docker.compose.core.RunningService; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create + * {@link OtlpMetricsConnectionDetails} for an OTLP service. + * + * @author Eddú Meléndez + */ +class OpenTelemetryMetricsDockerComposeConnectionDetailsFactory + extends DockerComposeConnectionDetailsFactory { + + private static final int OTLP_PORT = 4318; + + OpenTelemetryMetricsDockerComposeConnectionDetailsFactory() { + super("otel/opentelemetry-collector-contrib", + "org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration"); + } + + @Override + protected OtlpMetricsConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) { + return new OpenTelemetryMetricsDockerComposeConnectionDetails(source.getRunningService()); + } + + private static final class OpenTelemetryMetricsDockerComposeConnectionDetails extends DockerComposeConnectionDetails + implements OtlpMetricsConnectionDetails { + + private final String host; + + private final int port; + + private OpenTelemetryMetricsDockerComposeConnectionDetails(RunningService source) { + super(source); + this.host = source.host(); + this.port = source.ports().get(OTLP_PORT); + } + + @Override + public String getUrl() { + return "http://%s:%d/v1/metrics".formatted(this.host, this.port); + } + + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..20e5b06b3daa --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.otlp; + +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConnectionDetails; +import org.springframework.boot.docker.compose.core.RunningService; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create + * {@link OtlpTracingConnectionDetails} for an OTLP service. + * + * @author Eddú Meléndez + */ +class OpenTelemetryTracingDockerComposeConnectionDetailsFactory + extends DockerComposeConnectionDetailsFactory { + + private static final int OTLP_PORT = 4318; + + OpenTelemetryTracingDockerComposeConnectionDetailsFactory() { + super("otel/opentelemetry-collector-contrib", + "org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration"); + } + + @Override + protected OtlpTracingConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) { + return new OpenTelemetryTracingDockerComposeConnectionDetails(source.getRunningService()); + } + + private static final class OpenTelemetryTracingDockerComposeConnectionDetails extends DockerComposeConnectionDetails + implements OtlpTracingConnectionDetails { + + private final String host; + + private final int port; + + private OpenTelemetryTracingDockerComposeConnectionDetails(RunningService source) { + super(source); + this.host = source.host(); + this.port = source.ports().get(OTLP_PORT); + } + + @Override + public String getUrl() { + return "http://%s:%d/v1/traces".formatted(this.host, this.port); + } + + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/package-info.java new file mode 100644 index 000000000000..cbac91d2c639 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/otlp/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Support for docker compose OpenTelemetry service connections. + */ +package org.springframework.boot.docker.compose.service.connection.otlp; diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactory.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactory.java new file mode 100644 index 000000000000..0568a9811933 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactory.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.pulsar; + +import org.springframework.boot.autoconfigure.pulsar.PulsarConnectionDetails; +import org.springframework.boot.docker.compose.core.ConnectionPorts; +import org.springframework.boot.docker.compose.core.RunningService; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory; +import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource; + +/** + * {@link DockerComposeConnectionDetailsFactory} to create {@link PulsarConnectionDetails} + * for a {@code pulsar} service. + * + * @author Chris Bono + */ +class PulsarDockerComposeConnectionDetailsFactory + extends DockerComposeConnectionDetailsFactory { + + private static final int BROKER_PORT = 6650; + + private static final int ADMIN_PORT = 8080; + + PulsarDockerComposeConnectionDetailsFactory() { + super("apachepulsar/pulsar"); + } + + @Override + protected PulsarConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) { + return new PulsarDockerComposeConnectionDetails(source.getRunningService()); + } + + /** + * {@link PulsarConnectionDetails} backed by a {@code pulsar} {@link RunningService}. + */ + static class PulsarDockerComposeConnectionDetails extends DockerComposeConnectionDetails + implements PulsarConnectionDetails { + + private final String brokerUrl; + + private final String adminUrl; + + PulsarDockerComposeConnectionDetails(RunningService service) { + super(service); + ConnectionPorts ports = service.ports(); + this.brokerUrl = "pulsar://%s:%s".formatted(service.host(), ports.get(BROKER_PORT)); + this.adminUrl = "http://%s:%s".formatted(service.host(), ports.get(ADMIN_PORT)); + } + + @Override + public String getBrokerUrl() { + return this.brokerUrl; + } + + @Override + public String getAdminUrl() { + return this.adminUrl; + } + + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/package-info.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/package-info.java new file mode 100644 index 000000000000..7d8c4d1b1a56 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/service/connection/pulsar/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Auto-configuration for docker compose Pulsar service connections. + */ +package org.springframework.boot.docker.compose.service.connection.pulsar; diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-docker-compose/src/main/resources/META-INF/spring.factories index 7c6623fbe5c9..3ffd311e3631 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-docker-compose/src/main/resources/META-INF/spring.factories @@ -5,22 +5,29 @@ org.springframework.boot.docker.compose.service.connection.DockerComposeServiceC # Connection Details Factories org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\ +org.springframework.boot.docker.compose.service.connection.activemq.ActiveMQDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.cassandra.CassandraDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.elasticsearch.ElasticsearchDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.flyway.JdbcAdaptingFlywayConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.ldap.OpenLdapDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.liquibase.JdbcAdaptingLiquibaseConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.mariadb.MariaDbJdbcDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.mariadb.MariaDbR2dbcDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.mongo.MongoDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.mysql.MySqlJdbcDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.mysql.MySqlR2dbcDockerComposeConnectionDetailsFactory,\ -org.springframework.boot.docker.compose.service.connection.oracle.OracleJdbcDockerComposeConnectionDetailsFactory,\ -org.springframework.boot.docker.compose.service.connection.oracle.OracleR2dbcDockerComposeConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.neo4j.Neo4jDockerComposeConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.oracle.OracleFreeJdbcDockerComposeConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.oracle.OracleXeJdbcDockerComposeConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.oracle.OracleFreeR2dbcDockerComposeConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.oracle.OracleXeR2dbcDockerComposeConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.otlp.OpenTelemetryMetricsDockerComposeConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.otlp.OpenTelemetryTracingDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.postgres.PostgresJdbcDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.postgres.PostgresR2dbcDockerComposeConnectionDetailsFactory,\ +org.springframework.boot.docker.compose.service.connection.pulsar.PulsarDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.rabbit.RabbitDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.redis.RedisDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.sqlserver.SqlServerJdbcDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.sqlserver.SqlServerR2dbcDockerComposeConnectionDetailsFactory,\ org.springframework.boot.docker.compose.service.connection.zipkin.ZipkinDockerComposeConnectionDetailsFactory - diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java index 4d1692e247fd..02bab15eb46c 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java @@ -106,7 +106,7 @@ void ofReturnsDockerComposeFile() throws Exception { FileCopyUtils.copy(new byte[0], file); DockerComposeFile composeFile = DockerComposeFile.of(file); assertThat(composeFile).isNotNull(); - assertThat(composeFile.toString()).isEqualTo(file.getCanonicalPath()); + assertThat(composeFile).hasToString(file.getCanonicalPath()); } @Test diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java index 183ca38ec177..76b1128232db 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ConnectionNamePredicateTests.java @@ -74,7 +74,14 @@ void labeled() { .accepts(sourceOf("internalhost:8080/libs/libs/mzipkin", "openzipkin/zipkin")); } - private Predicate predicateOf(String required) { + @Test + void multiple() { + assertThat(predicateOf("elasticsearch1", "elasticsearch2")).accepts(sourceOf("elasticsearch1")) + .accepts(sourceOf("elasticsearch2")); + + } + + private Predicate predicateOf(String... required) { return new ConnectionNamePredicate(required); } diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..0fd0852991ea --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.activemq; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.jms.activemq.ActiveMQConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link ActiveMQDockerComposeConnectionDetailsFactory}. + * + * @author Stephane Nicoll + */ +class ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests { + + ActiveMQDockerComposeConnectionDetailsFactoryIntegrationTests() { + super("activemq-compose.yaml", DockerImageNames.activeMq()); + } + + @Test + void runCreatesConnectionDetails() { + ActiveMQConnectionDetails connectionDetails = run(ActiveMQConnectionDetails.class); + assertThat(connectionDetails.getBrokerUrl()).isNotNull().startsWith("tcp://"); + assertThat(connectionDetails.getUser()).isEqualTo("root"); + assertThat(connectionDetails.getPassword()).isEqualTo("secret"); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironmentTests.java new file mode 100644 index 000000000000..04ee5929788f --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/activemq/ActiveMQEnvironmentTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.activemq; + +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ActiveMQEnvironment}. + * + * @author Stephane Nicoll + */ +class ActiveMQEnvironmentTests { + + @Test + void getUserWhenHasNoActiveMqUser() { + ActiveMQEnvironment environment = new ActiveMQEnvironment(Collections.emptyMap()); + assertThat(environment.getUser()).isNull(); + } + + @Test + void getUserWhenHasActiveMqUser() { + ActiveMQEnvironment environment = new ActiveMQEnvironment(Map.of("ACTIVEMQ_USERNAME", "me")); + assertThat(environment.getUser()).isEqualTo("me"); + } + + @Test + void getPasswordWhenHasNoActiveMqPassword() { + ActiveMQEnvironment environment = new ActiveMQEnvironment(Collections.emptyMap()); + assertThat(environment.getPassword()).isNull(); + } + + @Test + void getPasswordWhenHasActiveMqPassword() { + ActiveMQEnvironment environment = new ActiveMQEnvironment(Map.of("ACTIVEMQ_PASSWORD", "secret")); + assertThat(environment.getPassword()).isEqualTo("secret"); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/cassandra/CassandraDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/cassandra/CassandraDockerComposeConnectionDetailsFactoryIntegrationTests.java index 5c7bd51379b3..8df562e23111 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/cassandra/CassandraDockerComposeConnectionDetailsFactoryIntegrationTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/cassandra/CassandraDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -42,7 +42,7 @@ class CassandraDockerComposeConnectionDetailsFactoryIntegrationTests extends Abs void runCreatesConnectionDetails() { CassandraConnectionDetails connectionDetails = run(CassandraConnectionDetails.class); List contactPoints = connectionDetails.getContactPoints(); - assertThat(contactPoints.size()).isEqualTo(1); + assertThat(contactPoints).hasSize(1); Node node = contactPoints.get(0); assertThat(node.host()).isNotNull(); assertThat(node.port()).isGreaterThan(0); diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ldap/OpenLdapDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ldap/OpenLdapDockerComposeConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..57756fe01240 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/ldap/OpenLdapDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docker.compose.service.connection.ldap; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.ldap.LdapConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link OpenLdapDockerComposeConnectionDetailsFactory}. + * + * @author Philipp Kessler + */ +class OpenLdapDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests { + + OpenLdapDockerComposeConnectionDetailsFactoryIntegrationTests() { + super("ldap-compose.yaml", DockerImageNames.openLdap()); + } + + @Test + void runCreatesConnectionDetails() { + LdapConnectionDetails connectionDetails = run(LdapConnectionDetails.class); + assertThat(connectionDetails.getUsername()).isEqualTo("cn=admin,dc=ldap,dc=example,dc=org"); + assertThat(connectionDetails.getPassword()).isEqualTo("somepassword"); + assertThat(connectionDetails.getBase()).isEqualTo("dc=ldap,dc=example,dc=org"); + assertThat(connectionDetails.getUrls()).hasSize(1); + assertThat(connectionDetails.getUrls()[0]).startsWith("ldaps://"); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..ca95c13efa11 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.neo4j; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; + +import org.springframework.boot.autoconfigure.neo4j.Neo4jConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Integration tests for {@link Neo4jDockerComposeConnectionDetailsFactory}. + * + * @author Andy Wilkinson + */ +class Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests { + + Neo4jDockerComposeConnectionDetailsFactoryIntegrationTests() { + super("neo4j-compose.yaml", DockerImageNames.neo4j()); + } + + @Test + void runCreatesConnectionDetailsThatCanAccessNeo4j() { + Neo4jConnectionDetails connectionDetails = run(Neo4jConnectionDetails.class); + assertThat(connectionDetails.getAuthToken()).isEqualTo(AuthTokens.basic("neo4j", "secret")); + try (Driver driver = GraphDatabase.driver(connectionDetails.getUri(), connectionDetails.getAuthToken())) { + assertThatNoException().isThrownBy(driver::verifyConnectivity); + } + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironmentTests.java new file mode 100644 index 000000000000..4cbb02d0b608 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/neo4j/Neo4jEnvironmentTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.neo4j; + +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.AuthTokens; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link Neo4jEnvironment}. + * + * @author Andy Wilkinson + */ +class Neo4jEnvironmentTests { + + @Test + void whenNeo4jAuthIsNullThenAuthTokenIsNull() { + Neo4jEnvironment environment = new Neo4jEnvironment(Collections.emptyMap()); + assertThat(environment.getAuthToken()).isNull(); + } + + @Test + void whenNeo4jAuthIsNoneThenAuthTokenIsNone() { + Neo4jEnvironment environment = new Neo4jEnvironment(Map.of("NEO4J_AUTH", "none")); + assertThat(environment.getAuthToken()).isEqualTo(AuthTokens.none()); + } + + @Test + void whenNeo4jAuthIsNeo4jSlashPasswordThenAuthTokenIsBasic() { + Neo4jEnvironment environment = new Neo4jEnvironment(Map.of("NEO4J_AUTH", "neo4j/custom-password")); + assertThat(environment.getAuthToken()).isEqualTo(AuthTokens.basic("neo4j", "custom-password")); + } + + @Test + void whenNeo4jAuthIsNeitherNoneNorNeo4jSlashPasswordEnvironmentCreationThrows() { + assertThatIllegalStateException() + .isThrownBy(() -> new Neo4jEnvironment(Map.of("NEO4J_AUTH", "graphdb/custom-password"))); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironmentTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironmentTests.java index 4b9f37fca7bc..b66b55196736 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironmentTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleEnvironmentTests.java @@ -35,77 +35,80 @@ class OracleEnvironmentTests { @Test void getUsernameWhenHasAppUser() { OracleEnvironment environment = new OracleEnvironment( - Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret")); + Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret"), "defaultDb"); assertThat(environment.getUsername()).isEqualTo("alice"); } @Test void getUsernameWhenHasNoAppUser() { - OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret")); + OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"), "defaultDb"); assertThat(environment.getUsername()).isEqualTo("system"); } @Test void getPasswordWhenHasAppPassword() { OracleEnvironment environment = new OracleEnvironment( - Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret")); + Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret"), "defaultDb"); assertThat(environment.getPassword()).isEqualTo("secret"); } @Test void getPasswordWhenHasOraclePassword() { - OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret")); + OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"), "defaultDb"); assertThat(environment.getPassword()).isEqualTo("secret"); } @Test void createWhenRandomPasswordAndAppPasswordDoesNotThrow() { assertThatNoException().isThrownBy(() -> new OracleEnvironment( - Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret", "ORACLE_RANDOM_PASSWORD", "true"))); + Map.of("APP_USER", "alice", "APP_USER_PASSWORD", "secret", "ORACLE_RANDOM_PASSWORD", "true"), + "defaultDb")); } @Test void createWhenRandomPasswordThrowsException() { assertThatIllegalStateException() - .isThrownBy(() -> new OracleEnvironment(Map.of("ORACLE_RANDOM_PASSWORD", "true"))) + .isThrownBy(() -> new OracleEnvironment(Map.of("ORACLE_RANDOM_PASSWORD", "true"), "defaultDb")) .withMessage("ORACLE_RANDOM_PASSWORD is not supported without APP_USER and APP_USER_PASSWORD"); } @Test void createWhenAppUserAndNoAppPasswordThrowsException() { - assertThatIllegalStateException().isThrownBy(() -> new OracleEnvironment(Map.of("APP_USER", "alice"))) + assertThatIllegalStateException() + .isThrownBy(() -> new OracleEnvironment(Map.of("APP_USER", "alice"), "defaultDb")) .withMessage("No Oracle app password found"); } @Test void createWhenAppUserAndEmptyAppPasswordThrowsException() { assertThatIllegalStateException() - .isThrownBy(() -> new OracleEnvironment(Map.of("APP_USER", "alice", "APP_USER_PASSWORD", ""))) + .isThrownBy(() -> new OracleEnvironment(Map.of("APP_USER", "alice", "APP_USER_PASSWORD", ""), "defaultDb")) .withMessage("No Oracle app password found"); } @Test void createWhenHasNoPasswordThrowsException() { - assertThatIllegalStateException().isThrownBy(() -> new OracleEnvironment(Collections.emptyMap())) + assertThatIllegalStateException().isThrownBy(() -> new OracleEnvironment(Collections.emptyMap(), "defaultDb")) .withMessage("No Oracle password found"); } @Test void createWhenHasEmptyPasswordThrowsException() { - assertThatIllegalStateException().isThrownBy(() -> new OracleEnvironment(Map.of("ORACLE_PASSWORD", ""))) + assertThatIllegalStateException() + .isThrownBy(() -> new OracleEnvironment(Map.of("ORACLE_PASSWORD", ""), "defaultDb")) .withMessage("No Oracle password found"); } @Test void getDatabaseWhenHasNoOracleDatabase() { - OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret")); - assertThat(environment.getDatabase()).isEqualTo("xepdb1"); + OracleEnvironment environment = new OracleEnvironment(Map.of("ORACLE_PASSWORD", "secret"), "defaultDb"); + assertThat(environment.getDatabase()).isEqualTo("defaultDb"); } @Test void getDatabaseWhenHasOracleDatabase() { OracleEnvironment environment = new OracleEnvironment( - Map.of("ORACLE_PASSWORD", "secret", "ORACLE_DATABASE", "db")); + Map.of("ORACLE_PASSWORD", "secret", "ORACLE_DATABASE", "db"), "defaultDb"); assertThat(environment.getDatabase()).isEqualTo("db"); } diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..127fe0f57467 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.oracle; + +import java.sql.Driver; +import java.time.Duration; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.OS; + +import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests; +import org.springframework.boot.jdbc.DatabaseDriver; +import org.springframework.boot.testsupport.junit.DisabledOnOs; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.SimpleDriverDataSource; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link OracleFreeJdbcDockerComposeConnectionDetailsFactory} + * + * @author Andy Wilkinson + */ +@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64", + disabledReason = "The Oracle image has no ARM support") +class OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests + extends AbstractDockerComposeIntegrationTests { + + OracleFreeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() { + super("oracle-compose.yaml", DockerImageNames.oracleFree()); + } + + @Test + @SuppressWarnings("unchecked") + void runCreatesConnectionDetailsThatCanBeUsedToAccessDatabase() throws Exception { + JdbcConnectionDetails connectionDetails = run(JdbcConnectionDetails.class); + assertThat(connectionDetails.getUsername()).isEqualTo("app_user"); + assertThat(connectionDetails.getPassword()).isEqualTo("app_user_secret"); + assertThat(connectionDetails.getJdbcUrl()).startsWith("jdbc:oracle:thin:@").endsWith("/freepdb1"); + SimpleDriverDataSource dataSource = new SimpleDriverDataSource(); + dataSource.setUrl(connectionDetails.getJdbcUrl()); + dataSource.setUsername(connectionDetails.getUsername()); + dataSource.setPassword(connectionDetails.getPassword()); + dataSource.setDriverClass((Class) ClassUtils.forName(connectionDetails.getDriverClassName(), + getClass().getClassLoader())); + Awaitility.await().atMost(Duration.ofMinutes(1)).ignoreExceptions().untilAsserted(() -> { + JdbcTemplate template = new JdbcTemplate(dataSource); + assertThat(template.queryForObject(DatabaseDriver.ORACLE.getValidationQuery(), String.class)) + .isEqualTo("Hello"); + }); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..dea004833c4c --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.oracle; + +import java.time.Duration; + +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.OS; + +import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests; +import org.springframework.boot.jdbc.DatabaseDriver; +import org.springframework.boot.testsupport.junit.DisabledOnOs; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.r2dbc.core.DatabaseClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link OracleFreeR2dbcDockerComposeConnectionDetailsFactory} + * + * @author Andy Wilkinson + */ +@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64", + disabledReason = "The Oracle image has no ARM support") +class OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests + extends AbstractDockerComposeIntegrationTests { + + OracleFreeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() { + super("oracle-compose.yaml", DockerImageNames.oracleFree()); + } + + @Test + void runCreatesConnectionDetailsThatCanBeUsedToAccessDatabase() { + R2dbcConnectionDetails connectionDetails = run(R2dbcConnectionDetails.class); + ConnectionFactoryOptions connectionFactoryOptions = connectionDetails.getConnectionFactoryOptions(); + assertThat(connectionFactoryOptions.toString()).contains("database=freepdb1", "driver=oracle", + "password=REDACTED", "user=app_user"); + assertThat(connectionFactoryOptions.getRequiredValue(ConnectionFactoryOptions.PASSWORD)) + .isEqualTo("app_user_secret"); + Awaitility.await().atMost(Duration.ofMinutes(1)).ignoreExceptions().untilAsserted(() -> { + Object result = DatabaseClient.create(ConnectionFactories.get(connectionFactoryOptions)) + .sql(DatabaseDriver.ORACLE.getValidationQuery()) + .map((row, metadata) -> row.get(0)) + .first() + .block(Duration.ofSeconds(30)); + assertThat(result).isEqualTo("Hello"); + }); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java similarity index 91% rename from spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java rename to spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java index 0d3c71cdc5a2..19ebd9262605 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -35,15 +35,15 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Integration tests for {@link OracleJdbcDockerComposeConnectionDetailsFactory} + * Integration tests for {@link OracleXeJdbcDockerComposeConnectionDetailsFactory} * * @author Andy Wilkinson */ @DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64", disabledReason = "The Oracle image has no ARM support") -class OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests { +class OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests { - OracleJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() { + OracleXeJdbcDockerComposeConnectionDetailsFactoryIntegrationTests() { super("oracle-compose.yaml", DockerImageNames.oracleXe()); } diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java similarity index 90% rename from spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java rename to spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java index 303f027f0c8b..2b044d3fb36b 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/oracle/OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -34,15 +34,15 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Integration tests for {@link OracleR2dbcDockerComposeConnectionDetailsFactory} + * Integration tests for {@link OracleXeR2dbcDockerComposeConnectionDetailsFactory} * * @author Andy Wilkinson */ @DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64", disabledReason = "The Oracle image has no ARM support") -class OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests { +class OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests { - OracleR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() { + OracleXeR2dbcDockerComposeConnectionDetailsFactoryIntegrationTests() { super("oracle-compose.yaml", DockerImageNames.oracleXe()); } diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..7f303d5082f9 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.otlp; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for + * {@link OpenTelemetryMetricsDockerComposeConnectionDetailsFactory}. + * + * @author Eddú Meléndez + */ +class OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests + extends AbstractDockerComposeIntegrationTests { + + OpenTelemetryMetricsDockerComposeConnectionDetailsFactoryIntegrationTests() { + super("otlp-compose.yaml", DockerImageNames.opentelemetry()); + } + + @Test + void runCreatesConnectionDetails() { + OtlpMetricsConnectionDetails connectionDetails = run(OtlpMetricsConnectionDetails.class); + assertThat(connectionDetails.getUrl()).startsWith("http://").endsWith("/v1/metrics"); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..720b90a014f2 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/otlp/OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.docker.compose.service.connection.otlp; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for + * {@link OpenTelemetryTracingDockerComposeConnectionDetailsFactory}. + * + * @author Eddú Meléndez + */ +class OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests + extends AbstractDockerComposeIntegrationTests { + + OpenTelemetryTracingDockerComposeConnectionDetailsFactoryIntegrationTests() { + super("otlp-compose.yaml", DockerImageNames.opentelemetry()); + } + + @Test + void runCreatesConnectionDetails() { + OtlpTracingConnectionDetails connectionDetails = run(OtlpTracingConnectionDetails.class); + assertThat(connectionDetails.getUrl()).startsWith("http://").endsWith("/v1/traces"); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..c4c18d55ba0e --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/service/connection/pulsar/PulsarDockerComposeConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023-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.boot.docker.compose.service.connection.pulsar; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.pulsar.PulsarConnectionDetails; +import org.springframework.boot.docker.compose.service.connection.test.AbstractDockerComposeIntegrationTests; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for {@link PulsarDockerComposeConnectionDetailsFactory}. + * + * @author Chris Bono + */ +class PulsarDockerComposeConnectionDetailsFactoryIntegrationTests extends AbstractDockerComposeIntegrationTests { + + PulsarDockerComposeConnectionDetailsFactoryIntegrationTests() { + super("pulsar-compose.yaml", DockerImageNames.pulsar()); + } + + @Test + void runCreatesConnectionDetails() { + PulsarConnectionDetails connectionDetails = run(PulsarConnectionDetails.class); + assertThat(connectionDetails).isNotNull(); + assertThat(connectionDetails.getBrokerUrl()).matches("^pulsar:\\/\\/\\S+:\\d+"); + assertThat(connectionDetails.getAdminUrl()).matches("^http:\\/\\/\\S+:\\d+"); + } + +} diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/activemq/activemq-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/activemq/activemq-compose.yaml new file mode 100644 index 000000000000..9ae6911655e9 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/activemq/activemq-compose.yaml @@ -0,0 +1,8 @@ +services: + activemq: + image: '{imageName}' + ports: + - '61616' + environment: + ACTIVEMQ_USERNAME: 'root' + ACTIVEMQ_PASSWORD: 'secret' diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/ldap/ldap-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/ldap/ldap-compose.yaml new file mode 100644 index 000000000000..a55e16be4358 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/ldap/ldap-compose.yaml @@ -0,0 +1,11 @@ +services: + ldap: + image: '{imageName}' + environment: + - 'LDAP_DOMAIN=ldap.example.org' + - 'LDAP_ADMIN_PASSWORD=somepassword' + - 'LDAP_TLS=true' + hostname: ldap + ports: + - "389" + - "636" diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/neo4j/neo4j-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/neo4j/neo4j-compose.yaml new file mode 100644 index 000000000000..313cce779274 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/neo4j/neo4j-compose.yaml @@ -0,0 +1,8 @@ +services: + neo4j: + image: '{imageName}' + ports: + - '7687' + environment: + - 'NEO4J_AUTH=neo4j/secret' + diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/otlp/otlp-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/otlp/otlp-compose.yaml new file mode 100644 index 000000000000..258e73e333ee --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/otlp/otlp-compose.yaml @@ -0,0 +1,5 @@ +services: + otlp: + image: '{imageName}' + ports: + - '4318' diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/pulsar/pulsar-compose.yaml b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/pulsar/pulsar-compose.yaml new file mode 100644 index 000000000000..76cdd274f431 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/test/resources/org/springframework/boot/docker/compose/service/connection/pulsar/pulsar-compose.yaml @@ -0,0 +1,9 @@ +services: + pulsar: + image: '{imageName}' + ports: + - '8080' + - '6650' + command: bin/pulsar standalone + healthcheck: + test: curl http://127.0.0.1:8080/admin/v2/namespaces/public/default diff --git a/spring-boot-project/spring-boot-docs/build.gradle b/spring-boot-project/spring-boot-docs/build.gradle index f55df27a85a7..dba2031dd9b1 100644 --- a/spring-boot-project/spring-boot-docs/build.gradle +++ b/spring-boot-project/spring-boot-docs/build.gradle @@ -17,6 +17,17 @@ configurations { remoteSpringApplicationExample springApplicationExample testSlices + asciidoctorExtensions { + resolutionStrategy { + eachDependency { dependency -> + // Downgrade SnakeYAML as Asciidoctor fails due to an incompatibility + // in the Pysch gem + if (dependency.requested.group.equals("org.yaml")) { + dependency.useVersion("1.33") + } + } + } + } } jar { @@ -50,6 +61,7 @@ dependencies { asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-autoconfigure")) asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-devtools")) asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-docker-compose")) + asciidoctorExtensions(project(path: ":spring-boot-project:spring-boot-testcontainers")) autoConfiguration(project(path: ":spring-boot-project:spring-boot-autoconfigure", configuration: "autoConfigurationMetadata")) autoConfiguration(project(path: ":spring-boot-project:spring-boot-actuator-autoconfigure", configuration: "autoConfigurationMetadata")) @@ -63,6 +75,7 @@ dependencies { configurationProperties(project(path: ":spring-boot-project:spring-boot-docker-compose", configuration: "configurationPropertiesMetadata")) configurationProperties(project(path: ":spring-boot-project:spring-boot-devtools", configuration: "configurationPropertiesMetadata")) configurationProperties(project(path: ":spring-boot-project:spring-boot-test-autoconfigure", configuration: "configurationPropertiesMetadata")) + configurationProperties(project(path: ":spring-boot-project:spring-boot-testcontainers", configuration: "configurationPropertiesMetadata")) gradlePluginDocumentation(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin", configuration: "documentation")) @@ -78,7 +91,7 @@ dependencies { implementation(project(path: ":spring-boot-project:spring-boot-devtools")) implementation("ch.qos.logback:logback-classic") implementation("com.zaxxer:HikariCP") - implementation("io.micrometer:micrometer-core") + implementation("io.micrometer:micrometer-jakarta9") implementation("io.micrometer:micrometer-tracing") implementation("io.micrometer:micrometer-registry-graphite") implementation("io.micrometer:micrometer-registry-jmx") @@ -151,7 +164,11 @@ dependencies { implementation("org.springframework.graphql:spring-graphql") implementation("org.springframework.graphql:spring-graphql-test") implementation("org.springframework.kafka:spring-kafka") - implementation("org.springframework.kafka:spring-kafka-test") + implementation("org.springframework.kafka:spring-kafka-test") { + exclude group: "commons-logging", module: "commons-logging" + } + implementation("org.springframework.pulsar:spring-pulsar") + implementation("org.springframework.pulsar:spring-pulsar-reactive") implementation("org.springframework.restdocs:spring-restdocs-mockmvc") implementation("org.springframework.restdocs:spring-restdocs-restassured") implementation("org.springframework.restdocs:spring-restdocs-webtestclient") @@ -283,7 +300,7 @@ task runSpringApplicationExample(type: org.springframework.boot.build.docs.Appli task runLoggingFormatExample(type: org.springframework.boot.build.docs.ApplicationRunner) { classpath = configurations.springApplicationExample + sourceSets.main.output mainClass = "org.springframework.boot.docs.features.logexample.MyApplication" - args = ["--spring.main.banner-mode=off", "--server.port=0"] + args = ["--spring.main.banner-mode=off", "--server.port=0", "--spring.application.name=myapp"] output = file("$buildDir/example-output/logging-format.txt") expectedLogging = "Started MyApplication in " normalizeTomcatPort() @@ -311,6 +328,7 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) { "native-build-tools-version": nativeBuildToolsVersion, "spring-amqp-version": versionConstraints["org.springframework.amqp:spring-amqp"], "spring-batch-version": versionConstraints["org.springframework.batch:spring-batch-core"], + "spring-batch-version-antora": toAntoraVersion(versionConstraints["org.springframework.batch:spring-batch-core"]), "spring-boot-version": project.version, "spring-data-commons-version": versionConstraints["org.springframework.data:spring-data-commons"], "spring-data-couchbase-version": versionConstraints["org.springframework.data:spring-data-couchbase"], @@ -322,11 +340,12 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) { "spring-data-rest-version": versionConstraints["org.springframework.data:spring-data-rest-core"], "spring-framework-version": versionConstraints["org.springframework:spring-core"], "spring-framework-version-antora": toAntoraVersion(versionConstraints["org.springframework:spring-core"]), - "spring-graphql-version": versionConstraints["org.springframework.graphql:spring-graphql"], - "spring-integration-version": versionConstraints["org.springframework.integration:spring-integration-core"], + "spring-graphql-version-antora": toAntoraVersion(versionConstraints["org.springframework.graphql:spring-graphql"]), + "spring-integration-version-antora": toAntoraVersion(versionConstraints["org.springframework.integration:spring-integration-core"]), "spring-kafka-version": versionConstraints["org.springframework.kafka:spring-kafka"], + "spring-pulsar-version": versionConstraints["org.springframework.pulsar:spring-pulsar"], "spring-security-version-antora": toAntoraVersion(versionConstraints["org.springframework.security:spring-security-core"]), - "spring-authorization-server-version": versionConstraints["org.springframework.security:spring-security-oauth2-authorization-server"], + "spring-authorization-server-version-antora": toAntoraVersion(versionConstraints["org.springframework.security:spring-security-oauth2-authorization-server"]), "spring-webservices-version": versionConstraints["org.springframework.ws:spring-ws-core"], "tomcat-version": tomcatVersion.split("\\.").take(2).join('.'), "remote-spring-application-output": runRemoteSpringApplicationExample.outputs.files.singleFile, diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc index 0d5a036e94ed..ad5176280120 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/endpoints.adoc @@ -1093,12 +1093,17 @@ When appropriate, Spring auto-configures the following `InfoContributor` beans: | Exposes Operating System information. | None. +| `process` +| {spring-boot-actuator-module-code}/info/ProcessInfoContributor.java[`ProcessInfoContributor`] +| Exposes process information. +| None. + |=== Whether an individual contributor is enabled is controlled by its `management.info..enabled` property. Different contributors have different defaults for this property, depending on their prerequisites and the nature of the information that they expose. -With no prerequisites to indicate that they should be enabled, the `env`, `java`, and `os` contributors are disabled by default. +With no prerequisites to indicate that they should be enabled, the `env`, `java`, `os`, and `process` contributors are disabled by default. Each can be enabled by setting its `management.info..enabled` property to `true`. The `build` and `git` info contributors are enabled by default. @@ -1196,6 +1201,12 @@ The `info` endpoint publishes information about your Operating System, see {spri +[[actuator.endpoints.info.process-information]] +==== Process Information +The `info` endpoint publishes information about your process, see {spring-boot-module-api}/info/ProcessInfo.html[`Process`] for more details. + + + [[actuator.endpoints.info.writing-custom-info-contributors]] ==== Writing Custom InfoContributors To provide custom application information, you can register Spring beans that implement the {spring-boot-actuator-module-code}/info/InfoContributor.java[`InfoContributor`] interface. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc index 39a21c43711d..0fe6739f3c44 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc @@ -230,6 +230,8 @@ If tags with the same key are specified with Micrometer, they overwrite the defa In Micrometer 1.9.x, this was fixed by introducing Dynatrace-specific summary instruments. Setting this toggle to `false` forces Micrometer to fall back to the behavior that was the default before 1.9.x. It should only be used when encountering problems while migrating from Micrometer 1.8.x to 1.9.x. +* Export meter metadata: Starting from Micrometer 1.12.0, the Dynatrace exporter will also export meter metadata, such as unit and description by default. +Use the `export-meter-metadata` toggle to turn this feature off. It is possible to not specify a URI and API token, as shown in the following example. In this scenario, the automatically configured endpoint is used: @@ -248,6 +250,7 @@ In this scenario, the automatically configured endpoint is used: key1: "value1" key2: "value2" use-dynatrace-summary-instruments: true # (default: true) + export-meter-metadata: true # (default: true) ---- @@ -740,6 +743,13 @@ Metrics are tagged by the name of the executor, which is derived from the bean n +[[actuator.metrics.supported.jms]] +==== JMS Metrics +Auto-configuration enables the instrumentation of all available `JmsTemplate` beans and `@JmsListener` annotated methods. +This will produce `"jms.message.publish"` and `"jms.message.process"` metrics respectively. +See the {spring-framework-docs}/integration/observability.html#observability.jms[Spring Framework reference documentation for more information on produced observations]. + + [[actuator.metrics.supported.spring-mvc]] ==== Spring MVC Metrics @@ -811,20 +821,21 @@ To customize the tags, provide a `@Bean` that implements `JerseyTagsProvider`. [[actuator.metrics.supported.http-clients]] ==== HTTP Client Metrics -Spring Boot Actuator manages the instrumentation of both `RestTemplate` and `WebClient`. +Spring Boot Actuator manages the instrumentation of `RestTemplate`, `WebClient` and `RestClient`. For that, you have to inject the auto-configured builder and use it to create instances: * `RestTemplateBuilder` for `RestTemplate` * `WebClient.Builder` for `WebClient` +* `RestClient.Builder` for `RestClient` -You can also manually apply the customizers responsible for this instrumentation, namely `ObservationRestTemplateCustomizer` and `ObservationWebClientCustomizer`. +You can also manually apply the customizers responsible for this instrumentation, namely `ObservationRestTemplateCustomizer`, `ObservationWebClientCustomizer` and `ObservationRestClientCustomizer`. By default, metrics are generated with the name, `http.client.requests`. You can customize the name by setting the configprop:management.observations.http.client.requests.name[] property. See the {spring-framework-docs}/integration/observability.html#observability.http-client[Spring Framework reference documentation for more information on produced observations]. -To customize the tags when using `RestTemplate`, provide a `@Bean` that implements `ClientRequestObservationConvention` from the `org.springframework.http.client.observation` package. +To customize the tags when using `RestTemplate` or `RestClient`, provide a `@Bean` that implements `ClientRequestObservationConvention` from the `org.springframework.http.client.observation` package. To customize the tags when using `WebClient`, provide a `@Bean` that implements `ClientRequestObservationConvention` from the `org.springframework.web.reactive.function.client` package. @@ -863,14 +874,14 @@ A `CacheMetricsRegistrar` bean is made available to make that process easier. [[actuator.metrics.supported.spring-batch]] ==== Spring Batch Metrics -See the {spring-batch-docs}monitoring-and-metrics.html[Spring Batch reference documentation]. +See the {spring-batch-docs}/monitoring-and-metrics.html[Spring Batch reference documentation]. [[actuator.metrics.supported.spring-graphql]] ==== Spring GraphQL Metrics -See the {spring-graphql-docs}[Spring GraphQL reference documentation]. +See the {spring-graphql-docs}/observability.html[Spring GraphQL reference documentation]. @@ -951,7 +962,7 @@ Auto-configuration enables the instrumentation of all available RabbitMQ connect [[actuator.metrics.supported.spring-integration]] ==== Spring Integration Metrics -Spring Integration automatically provides {spring-integration-docs}system-management.html#micrometer-integration[Micrometer support] whenever a `MeterRegistry` bean is available. +Spring Integration automatically provides {spring-integration-docs}/metrics.html#micrometer-integration[Micrometer support] whenever a `MeterRegistry` bean is available. Metrics are published under the `spring.integration.` meter name. @@ -960,7 +971,7 @@ Metrics are published under the `spring.integration.` meter name. ==== Kafka Metrics Auto-configuration registers a `MicrometerConsumerListener` and `MicrometerProducerListener` for the auto-configured consumer factory and producer factory, respectively. It also registers a `KafkaStreamsMicrometerListener` for `StreamsBuilderFactoryBean`. -For more detail, see the {spring-kafka-docs}#micrometer-native[Micrometer Native Metrics] section of the Spring Kafka documentation. +For more detail, see the {spring-kafka-docs}kafka/micrometer.html#micrometer-native[Micrometer Native Metrics] section of the Spring Kafka documentation. @@ -1056,7 +1067,8 @@ Metrics for Jetty's `Connector` instances are bound by using Micrometer's `Jetty [[actuator.metrics.supported.timed-annotation]] ==== @Timed Annotation Support -To use `@Timed` where it is not directly supported by Spring Boot, refer to the {micrometer-concepts-docs}#_the_timed_annotation[Micrometer documentation]. +To enable scanning of `@Timed` annotations, you will need to set the configprop:micrometer.observations.annotations.enabled[] property to `true`. +Please refer to the {micrometer-concepts-docs}#_the_timed_annotation[Micrometer documentation]. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/observability.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/observability.adoc index b7db70d50219..e0b02262c7b6 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/observability.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/observability.adoc @@ -1,6 +1,5 @@ [[actuator.observability]] == Observability - Observability is the ability to observe the internal state of a running system from the outside. It consists of the three pillars logging, metrics and traces. @@ -11,14 +10,86 @@ include::code:MyCustomObservation[] NOTE: Low cardinality tags will be added to metrics and traces, while high cardinality tags will only be added to traces. -Beans of type `ObservationPredicate`, `GlobalObservationConvention` and `ObservationHandler` will be automatically registered on the `ObservationRegistry`. +Beans of type `ObservationPredicate`, `GlobalObservationConvention`, `ObservationFilter` and `ObservationHandler` will be automatically registered on the `ObservationRegistry`. You can additionally register any number of `ObservationRegistryCustomizer` beans to further configure the registry. -For more details please see the https://micrometer.io/docs/observation[Micrometer Observation documentation]. +Observability support relies on the https://github.com/micrometer-metrics/context-propagation[Context Propagation library] for forwarding the current observation across threads and reactive pipelines. +By default, `ThreadLocal` values are not automatically reinstated in reactive operators. +This behavior is controlled with the configprop:spring.reactor.context-propagation[] property, which can be set to `auto` to enable automatic propagation. + +For more details about observations please see the https://micrometer.io/docs/observation[Micrometer Observation documentation]. -TIP: Observability for JDBC and R2DBC can be configured using separate projects. -For JDBC, the https://github.com/jdbc-observations/datasource-micrometer[Datasource Micrometer project] provides a Spring Boot starter which automatically creates observations when JDBC operations are invoked. +TIP: Observability for JDBC can be configured using a separate project. +The https://github.com/jdbc-observations/datasource-micrometer[Datasource Micrometer project] provides a Spring Boot starter which automatically creates observations when JDBC operations are invoked. Read more about it https://jdbc-observations.github.io/datasource-micrometer/docs/current/docs/html/[in the reference documentation]. -For R2DBC, the https://github.com/spring-projects-experimental/r2dbc-micrometer-spring-boot[Spring Boot Auto Configuration for R2DBC Observation] creates observations for R2DBC query invocations. + +TIP: Observability for R2DBC is built into Spring Boot. +To enable it, add the `io.r2dbc:r2dbc-proxy` dependency to your project. + + + +[[actuator.observability.common-tags]] +=== Common tags +Common tags are generally used for dimensional drill-down on the operating environment, such as host, instance, region, stack, and others. +Common tags are applied to all observations as low cardinality tags and can be configured, as the following example shows: + +[source,yaml,indent=0,subs="verbatim",configprops,configblocks] +---- + management: + observations: + key-values: + region: "us-east-1" + stack: "prod" +---- + +The preceding example adds `region` and `stack` tags to all observations with a value of `us-east-1` and `prod`, respectively. + +[[actuator.observability.preventing-observations]] +=== Preventing Observations + +If you'd like to prevent some observations from being reported, you can use the configprop:management.observations.enable[] properties: + +[source,yaml,indent=0,subs="verbatim",configprops,configblocks] +---- + management: + observations: + enable: + denied: + prefix: false + another: + denied: + prefix: false +---- + +The preceding example will prevent all observations with a name starting with `denied.prefix` or `another.denied.prefix`. + +TIP: If you want to prevent Spring Security from reporting observations, set the property configprop:management.observations.enable.spring.security[] to `false`. + +If you need greater control over the prevention of observations, you can register beans of type `ObservationPredicate`. +Observations are only reported if all the `ObservationPredicate` beans return `true` for that observation. + +include::code:MyObservationPredicate[] + +The preceding example will prevent all observations whose name contains "denied". + + + +[[actuator.observability.opentelemetry]] +=== OpenTelemetry Support +Spring Boot's actuator module includes basic support for https://opentelemetry.io/[OpenTelemetry]. + +It provides a bean of type `OpenTelemetry`, and if there are beans of type `SdkTracerProvider`, `ContextPropagators`, `SdkLoggerProvider` or `SdkMeterProvider` in the application context, they automatically get registered. +Additionally, it provides a `Resource` bean. +The attributes of the `Resource` can be configured via the configprop:management.opentelemetry.resource-attributes[] configuration property. + +NOTE: Spring Boot does not provide auto-configuration for OpenTelemetry metrics or logging. +OpenTelemetry tracing is only auto-configured when used together with <>. The next sections will provide more details about logging, metrics and traces. + + + +[[actuator.observability.annotations]] +=== Micrometer Observation Annotations support +To enable scanning of metrics and tracing annotations like `@Timed`, `@Counted`, `@MeterTag` and `@NewSpan` annotations, you will need to set the configprop:micrometer.observations.annotations.enabled[] property to `true`. +This feature is supported Micrometer directly, please refer to the {micrometer-concepts-docs}#_the_timed_annotation[Micrometer] and {micrometer-tracing-docs}/api.html#_aspect_oriented_programming[Micrometer Tracing] reference docs. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/tracing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/tracing.adoc index fbe341a65ffa..e4e9189b2462 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/tracing.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/tracing.adoc @@ -65,7 +65,29 @@ Now open the Zipkin UI at `http://localhost:9411` and press the "Run Query" butt You should see one trace. Press the "Show" button to see the details of that trace. -TIP: You can include the current trace and span id in the logs by setting the configprop:logging.pattern.level[] property to `%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]` + + +[[actuator.micrometer-tracing.logging]] +=== Logging Correlation IDs +Correlation IDs provide a helpful way to link lines in your log files to spans/traces. +If you are using Micrometer Tracing, Spring Boot will include correlation IDs in your logs by default. + +The default correlation ID is built from `traceId` and `spanId` https://logback.qos.ch/manual/mdc.html[MDC] values. +For example, if Micrometer Tracing has added an MDC `traceId` of `803B448A0489F84084905D3093480352` and an MDC `spanId` of `3425F23BB2432450` the log output will include the correlation ID `[803B448A0489F84084905D3093480352-3425F23BB2432450]`. + +If you prefer to use a different format for your correlation ID, you can use the configprop:logging.pattern.correlation[] property to define one. +For example, the following will provide a correlation ID for Logback in format previously used by Spring Cloud Sleuth: + +[source,yaml,indent=0,subs="verbatim",configprops,configblocks] +---- + logging: + pattern: + correlation: "[${spring.application.name:},%X{traceId:-},%X{spanId:-}] " + include-application-name: false +---- + +NOTE: In the example above, configprop:logging.include-application-name[] is set to `false` to avoid the application name being duplicated in the log messages (configprop:logging.pattern.correlation[] already contains it). +It's also worth mentioning that configprop:logging.pattern.correlation[] contains a trailing space so that it is separated from the logger name that comes right after it by default. @@ -180,5 +202,5 @@ For the example above, setting this property to `baggage1` results in an MDC ent [[actuator.micrometer-tracing.tests]] === Tests -Tracing is not auto-configured when using `@SpringBootTest`. +Tracing components which are reporting data are not auto-configured when using `@SpringBootTest`. See <> for more details. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/anchor-rewrite.properties b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/anchor-rewrite.properties index e29b38c8e5fe..593f2ec75ae4 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/anchor-rewrite.properties +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/anchor-rewrite.properties @@ -306,7 +306,6 @@ boot-features-webclient-customization=features.webclient.customization boot-features-validation=features.validation boot-features-email=features.email boot-features-jta=features.jta -boot-features-jta-atomikos=features.jta.atomikos boot-features-jta-javaee=features.jta.javaee boot-features-jta-mixed-jms=features.jta.mixing-xa-and-non-xa-connections boot-features-jta-supporting-alternative-embedded=features.jta.supporting-alternative-embedded-transaction-manager @@ -1021,6 +1020,9 @@ howto.testing.testcontainers.dynamic-properties=features.testing.testcontainers. # gh-32905 container-images.efficient-images.unpacking=deployment.efficient.unpacking +# Spring Boot 3.1 - 3.2 migrations +io.rest-client.resttemplate.http-client=io.rest-client.clienthttprequestfactory + # gh-35917 howto.actuator.sanitize-sensitive-values=actuator.endpoints.sanitization howto.actuator.sanitize-sensitive-values.customizing-sanitization=howto.actuator.customizing-sanitization @@ -1041,3 +1043,6 @@ features.testing.testcontainers.at-development-time=features.testcontainers.at-d features.testing.testcontainers.at-development-time.dynamic-properties=features.testcontainers.at-development-time.dynamic-properties features.testing.testcontainers.at-development-time.importing-container-declarations=features.testcontainers.at-development-time.importing-container-declarations features.testing.testcontainers.at-development-time.devtools=features.testcontainers.at-development-time.devtools + +# gh-39125 +actuator.observability.common-key-values=actuator.observability.common-tags diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc index 7c51da9f53fc..ab3b79f7eb71 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/application-properties.adoc @@ -47,4 +47,6 @@ include::application-properties/devtools.adoc[] include::application-properties/docker-compose.adoc[] +include::application-properties/testcontainers.adoc[] + include::application-properties/testing.adoc[] diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc index f17f32c7eef0..d0a5ad56a482 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc @@ -55,8 +55,8 @@ :spring-boot-test-autoconfigure-module-api: {spring-boot-api}/org/springframework/boot/test/autoconfigure :spring-amqp-api: https://docs.spring.io/spring-amqp/docs/{spring-amqp-version}/api/org/springframework/amqp :spring-batch: https://spring.io/projects/spring-batch -:spring-batch-api: https://docs.spring.io/spring-batch/docs/{spring-batch-version}/api/org/springframework/batch -:spring-batch-docs: https://docs.spring.io/spring-batch/docs/{spring-batch-version}/reference/html/ +:spring-batch-api: https://docs.spring.io/spring-batch/docs/{spring-batch-version}/org/springframework/batch +:spring-batch-docs: https://docs.spring.io/spring-batch/reference/{spring-batch-version-antora} :spring-data: https://spring.io/projects/spring-data :spring-data-cassandra: https://spring.io/projects/spring-data-cassandra :spring-data-commons-api: https://docs.spring.io/spring-data/commons/docs/{spring-data-commons-version}/api/org/springframework/data @@ -69,7 +69,7 @@ :spring-data-geode: https://spring.io/projects/spring-data-geode :spring-data-jpa: https://spring.io/projects/spring-data-jpa :spring-data-jpa-api: https://docs.spring.io/spring-data/jpa/docs/{spring-data-jpa-version}/api/org/springframework/data/jpa -:spring-data-jpa-docs: https://docs.spring.io/spring-data/jpa/docs/{spring-data-jpa-version}/reference/html +:spring-data-jpa-docs: https://docs.spring.io/spring-data/jpa/reference/{spring-data-jpa-version}/ :spring-data-jdbc-docs: https://docs.spring.io/spring-data/jdbc/docs/{spring-data-jdbc-version}/reference/html/ :spring-data-ldap: https://spring.io/projects/spring-data-ldap :spring-data-mongodb: https://spring.io/projects/spring-data-mongodb @@ -84,16 +84,16 @@ :spring-framework-api: https://docs.spring.io/spring-framework/docs/{spring-framework-version}/javadoc-api/org/springframework :spring-framework-docs: https://docs.spring.io/spring-framework/reference/{spring-framework-version-antora} :spring-graphql: https://spring.io/projects/spring-graphql -:spring-graphql-api: https://docs.spring.io/spring-graphql/docs/{spring-graphql-version}/api/ -:spring-graphql-docs: https://docs.spring.io/spring-graphql/docs/{spring-graphql-version}/reference/html/ +:spring-graphql-docs: https://docs.spring.io/spring-graphql/reference/{spring-graphql-version-antora} :spring-integration: https://spring.io/projects/spring-integration -:spring-integration-docs: https://docs.spring.io/spring-integration/docs/{spring-integration-version}/reference/html/ -:spring-kafka-docs: https://docs.spring.io/spring-kafka/docs/{spring-kafka-version}/reference/html/ +:spring-integration-docs: https://docs.spring.io/spring-integration/reference/{spring-integration-version-antora} +:spring-kafka-docs: https://docs.spring.io/spring-kafka/docs/{spring-kafka-version}/reference/ +:spring-pulsar-docs: https://docs.spring.io/spring-pulsar/docs/{spring-pulsar-version}/reference/ :spring-restdocs: https://spring.io/projects/spring-restdocs :spring-security: https://spring.io/projects/spring-security :spring-security-docs: https://docs.spring.io/spring-security/reference/{spring-security-version-antora} :spring-authorization-server: https://spring.io/projects/spring-authorization-server -:spring-authorization-server-docs: https://docs.spring.io/spring-authorization-server/docs/{spring-authorization-server-version}/reference/html +:spring-authorization-server-docs: https://docs.spring.io/spring-authorization-server/reference/{spring-authorization-server-version-antora} :spring-session: https://spring.io/projects/spring-session :spring-webservices-docs: https://docs.spring.io/spring-ws/docs/{spring-webservices-version}/reference/html/ :ant-docs: https://ant.apache.org/manual @@ -109,6 +109,7 @@ :micrometer-docs: https://docs.micrometer.io/micrometer/reference :micrometer-concepts-docs: {micrometer-docs}/concepts :micrometer-implementation-docs: {micrometer-docs}/implementations +:micrometer-tracing-docs: https://docs.micrometer.io/tracing/reference :tomcat-docs: https://tomcat.apache.org/tomcat-{tomcat-version}-doc :graal-version: 22.3 :graal-native-image-docs: https://www.graalvm.org/{graal-version}/reference-manual/native-image diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/format.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/format.adoc index 589851084265..b2ce6c5dae9f 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/format.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/configuration-metadata/format.adoc @@ -198,6 +198,11 @@ The JSON object contained in the `deprecation` attribute of each `properties` el | String | The full name of the property that _replaces_ this deprecated property. If there is no replacement for this property, it may be omitted. + +| `since` +| String +| The version in which the property became deprecated. + Can be omitted. |=== NOTE: Prior to Spring Boot 1.3, a single `deprecated` boolean attribute can be used instead of the `deprecation` element. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/dockerfiles.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/dockerfiles.adoc index 1c3d77cff254..1956cc4a3188 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/dockerfiles.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/dockerfiles.adoc @@ -1,6 +1,6 @@ [[container-images.dockerfiles]] == Dockerfiles -While it is possible to convert a Spring Boot fat jar into a docker image with just a few lines in the Dockerfile, we will use the <> to create an optimized docker image. +While it is possible to convert a Spring Boot uber jar into a docker image with just a few lines in the Dockerfile, we will use the <> to create an optimized docker image. When you create a jar containing the layers index file, the `spring-boot-jarmode-layertools` jar will be added as a dependency to your jar. With this jar on the classpath, you can launch your application in a special mode which allows the bootstrap code to run something entirely different from your application, for example, something that extracts the layers. @@ -44,7 +44,7 @@ COPY --from=builder application/dependencies/ ./ COPY --from=builder application/spring-boot-loader/ ./ COPY --from=builder application/snapshot-dependencies/ ./ COPY --from=builder application/application/ ./ -ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"] +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] ---- Assuming the above `Dockerfile` is in the current directory, your docker image can be built with `docker build .`, or optionally specifying the path to your application jar, as shown in the following example: diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/efficient-images.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/efficient-images.adoc index ad70d6a75eb5..d06327bb05f3 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/efficient-images.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/container-images/efficient-images.adoc @@ -1,8 +1,8 @@ [[container-images.efficient-images]] == Efficient Container Images -It is easily possible to package a Spring Boot fat jar as a docker image. -However, there are various downsides to copying and running the fat jar as is in the docker image. -There’s always a certain amount of overhead when running a fat jar without unpacking it, and in a containerized environment this can be noticeable. +It is easily possible to package a Spring Boot uber jar as a docker image. +However, there are various downsides to copying and running the uber jar as is in the docker image. +There’s always a certain amount of overhead when running a uber jar without unpacking it, and in a containerized environment this can be noticeable. The other issue is that putting your application's code and all its dependencies in one layer in the Docker image is sub-optimal. Since you probably recompile your code more often than you upgrade the version of Spring Boot you use, it’s often better to separate things a bit more. If you put jar files in the layer before your application classes, Docker often only needs to change the very bottom layer and can pick others up from its cache. @@ -28,8 +28,8 @@ The following shows an example of a `layers.idx` file: - BOOT-INF/lib/library1.jar - BOOT-INF/lib/library2.jar - "spring-boot-loader": - - org/springframework/boot/loader/JarLauncher.class - - org/springframework/boot/loader/jar/JarEntry.class + - org/springframework/boot/loader/launch/JarLauncher.class + - ... - "snapshot-dependencies": - BOOT-INF/lib/library3-SNAPSHOT.jar - "application": diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/nosql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/nosql.adoc index 1335dbfa99ab..c1816b18d9c7 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/nosql.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/nosql.adoc @@ -16,7 +16,7 @@ Additionally, {spring-boot-for-apache-geode}[Spring Boot for Apache Geode] provi You can make use of the other projects, but you must configure them yourself. See the appropriate reference documentation at {spring-data}. -Spring Boot also provides auto-configuration for the InfluxDB client. +Spring Boot also provides auto-configuration for the InfluxDB client but it is deprecated in favor of https://github.com/influxdata/influxdb-client-java[the new InfluxDB Java client] that provides its own Spring Boot integration. @@ -330,7 +330,7 @@ If you have `co.elastic.clients:elasticsearch-java` on the classpath, Spring Boo The `ElasticsearchClient` uses a transport that depends upon the previously described `RestClient`. Therefore, the properties described previously can be used to configure the `ElasticsearchClient`. -Furthermore, you can define a `TransportOptions` bean to take further control of the behavior of the transport. +Furthermore, you can define a `RestClientOptions` bean to take further control of the behavior of the transport. @@ -341,7 +341,7 @@ If you have Spring Data Elasticsearch and Reactor on the classpath, Spring Boot The `ReactiveElasticsearchclient` uses a transport that depends upon the previously described `RestClient`. Therefore, the properties described previously can be used to configure the `ReactiveElasticsearchClient`. -Furthermore, you can define a `TransportOptions` bean to take further control of the behavior of the transport. +Furthermore, you can define a `RestClientOptions` bean to take further control of the behavior of the transport. @@ -661,22 +661,17 @@ If you have custom attributes, you can use configprop:spring.ldap.embedded.valid [[data.nosql.influxdb]] === InfluxDB +WARNING: Auto-configuration for InfluxDB is deprecated and scheduled for removal in Spring Boot 3.4 in favor of https://github.com/influxdata/influxdb-client-java[the new InfluxDB Java client] that provides its own Spring Boot integration. + https://www.influxdata.com/[InfluxDB] is an open-source time series database optimized for fast, high-availability storage and retrieval of time series data in fields such as operations monitoring, application metrics, Internet-of-Things sensor data, and real-time analytics. [[data.nosql.influxdb.connecting]] ==== Connecting to InfluxDB -Spring Boot auto-configures an `InfluxDB` instance, provided the `influxdb-java` client is on the classpath and the URL of the database is set, as shown in the following example: - -[source,yaml,indent=0,subs="verbatim",configprops,configblocks] ----- - spring: - influx: - url: "https://172.0.0.1:8086" ----- +Spring Boot auto-configures an `InfluxDB` instance, provided the `influxdb-java` client is on the classpath and the URL of the database is set using configprop:spring.influx.url[deprecated]. -If the connection to InfluxDB requires a user and password, you can set the `spring.influx.user` and `spring.influx.password` properties accordingly. +If the connection to InfluxDB requires a user and password, you can set the configprop:spring.influx.user[deprecated] and configprop:spring.influx.password[deprecated] properties accordingly. InfluxDB relies on OkHttp. If you need to tune the http client `InfluxDB` uses behind the scenes, you can register an `InfluxDbOkHttpClientBuilderProvider` bean. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/sql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/sql.adoc index 8d59561c5f1c..17e506da15c6 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/sql.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/data/sql.adoc @@ -1,6 +1,6 @@ [[data.sql]] == SQL Databases -The {spring-framework}[Spring Framework] provides extensive support for working with SQL databases, from direct JDBC access using `JdbcTemplate` to complete "`object relational mapping`" technologies such as Hibernate. +The {spring-framework}[Spring Framework] provides extensive support for working with SQL databases, from direct JDBC access using `JdbcClient` or `JdbcTemplate` to complete "`object relational mapping`" technologies such as Hibernate. {spring-data}[Spring Data] provides an additional level of functionality: creating `Repository` implementations directly from interfaces and using conventions to generate queries from your method names. @@ -176,6 +176,17 @@ If more than one `JdbcTemplate` is defined and no primary candidate exists, the +[[data.sql.jdbc-client]] +=== Using JdbcClient +Spring's `JdbcClient` is auto-configured based on the presence of a `NamedParameterJdbcTemplate`. +You can inject it directly in your own beans as well, as shown in the following example: + +include::code:MyBean[] + +If you rely on auto-configuration to create the underlying `JdbcTemplate`, any customization using `spring.jdbc.template.*` properties is taken into account in the client as well. + + + [[data.sql.jpa-and-spring-data]] === JPA and Spring Data JPA The Java Persistence API is a standard technology that lets you "`map`" objects to relational databases. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/efficient.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/efficient.adoc index 9064f844c7f8..37ee8b8fade2 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/efficient.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/efficient.adoc @@ -13,7 +13,7 @@ One way to run an unpacked archive is by starting the appropriate launcher, as f [source,shell,indent=0,subs="verbatim"] ---- $ jar -xf myapp.jar - $ java org.springframework.boot.loader.JarLauncher + $ java org.springframework.boot.loader.launch.JarLauncher ---- This is actually slightly faster on startup (depending on the size of the jar) than running from an unexploded archive. @@ -34,7 +34,6 @@ The jar contains a `classpath.idx` file which is used by the `JarLauncher` when [[deployment.efficient.aot]] === Using Ahead-of-time Processing With the JVM - It's beneficial for the startup time to run your application using the AOT generated initialization code. First, you need to ensure that the jar you are building includes AOT generated code. @@ -51,9 +50,9 @@ When the JAR has been built, run it with `spring.aot.enabled` system property se [source,shell,indent=0,subs="verbatim"] ---- - $ java -Dspring.aot.enabled=true -jar myapplication.jar + $ java -Dspring.aot.enabled=true -jar myapplication.jar - ........ Starting AOT-processed MyApplication ... + ........ Starting AOT-processed MyApplication ... ---- Beware that using the ahead-of-time processing has drawbacks. @@ -65,3 +64,21 @@ It implies the following restrictions: - Properties that change if a bean is created are not supported (for example, `@ConditionalOnProperty` and `.enable` properties). To learn more about ahead-of-time processing, please see the <>. + + + +[[deployment.efficient.checkpoint-restore]] +=== Checkpoint and Restore With the JVM +https://wiki.openjdk.org/display/crac/Main[Coordinated Restore at Checkpoint] (CRaC) is an OpenJDK project that defines a new Java API to allow you to checkpoint and restore an application on the HotSpot JVM. +It is based on https://github.com/checkpoint-restore/criu[CRIU], a project that implements checkpoint/restore functionality on Linux. + +The principle is the following: you start your application almost as usual but with a CRaC enabled version of the JDK like https://bell-sw.com/pages/downloads/?package=jdk-crac[Bellsoft Liberica JDK with CRaC] or https://www.azul.com/downloads/?package=jdk-crac#zulu[Azul Zulu JDK with CRaC]. +Then at some point, potentially after some workloads that will warm up your JVM by executing all common code paths, you trigger a checkpoint using an API call, a `jcmd` command, an HTTP endpoint, or a different mechanism. + +A memory representation of the running JVM, including its warmness, is then serialized to disk, allowing a fast restoration at a later point, potentially on another machine with a similar operating system and CPU architecture. +The restored process retains all the capabilities of the HotSpot JVM, including further JIT optimizations at runtime. + +Based on the foundations provided by Spring Framework, Spring Boot provides support for checkpointing and restoring your application, and manages out-of-the-box the lifecycle of resources such as socket, files and thread pools https://github.com/spring-projects/spring-lifecycle-smoke-tests/blob/main/STATUS.adoc[on a limited scope]. +Additional lifecycle management is expected for other dependencies and potentially for the application code dealing with such resources. + +You can find more details about the two modes supported ("on demand checkpoint/restore of a running application" and "automatic checkpoint/restore at startup"), how to enable checkpoint and restore support and some guidelines in {spring-framework-docs}/integration/checkpoint-restore.html[the Spring Framework JVM Checkpoint Restore support documentation]. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/installing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/installing.adoc index e4af64af3f02..9461d903a34a 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/installing.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/deployment/installing.adoc @@ -355,7 +355,7 @@ See the https://www.freedesktop.org/software/systemd/man/systemd.service.html[se [[deployment.installing.init-d.script-customization.when-running.conf-file]] -====== Using a Conf Gile +====== Using a Conf File With the exception of `JARFILE` and `APP_NAME`, the settings listed in the preceding section can be configured by using a `.conf` file. The file is expected to be next to the jar file and have the same name but suffixed with `.conf` rather than `.jar`. For example, a jar named `/var/myapp/myapp.jar` uses the configuration file named `/var/myapp/myapp.conf`, as shown in the following example: diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc index 51412fde0c9b..0ccc798988bd 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc @@ -5,5 +5,6 @@ If your application uses any messaging protocol, see one or more of the followin * *JMS:* <> * *AMQP:* <> * *Kafka:* <> +* *Pulsar:* <> * *RSocket:* <> * *Spring Integration:* <> diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc index da7c616fb314..b1db0c87261a 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc @@ -1,7 +1,7 @@ [[appendix.executable-jar.jarfile-class]] -== Spring Boot's "`JarFile`" Class -The core class used to support loading nested jars is `org.springframework.boot.loader.jar.JarFile`. -It lets you load jar content from a standard jar file or from nested child jar data. +== Spring Boot's "`NestedJarFile`" Class +The core class used to support loading nested jars is `org.springframework.boot.loader.jar.NestedJarFile`. +It lets you load jar content from nested child jar data. When first loaded, the location of each `JarEntry` is mapped to a physical file offset of the outer jar, as shown in the following example: [indent=0] @@ -28,5 +28,7 @@ We do not need to unpack the archive, and we do not need to read all entry data [[appendix.executable-jar.jarfile-class.compatibility]] === Compatibility With the Standard Java "`JarFile`" Spring Boot Loader strives to remain compatible with existing code and libraries. -`org.springframework.boot.loader.jar.JarFile` extends from `java.util.jar.JarFile` and should work as a drop-in replacement. -The `getURL()` method returns a `URL` that opens a connection compatible with `java.net.JarURLConnection` and can be used with Java's `URLClassLoader`. +`org.springframework.boot.loader.jar.NestedJarFile` extends from `java.util.jar.JarFile` and should work as a drop-in replacement. + +Nested JAR URLs of the form `jar:nested:/path/myjar.jar/!BOOT-INF/lib/mylib.jar!/B.class` are supported and open a connection compatible with `java.net.JarURLConnection`. +These can be used with Java's `URLClassLoader`. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc index a672c6963495..690b85c438d8 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc @@ -1,13 +1,14 @@ [[appendix.executable-jar.launching]] == Launching Executable Jars -The `org.springframework.boot.loader.Launcher` class is a special bootstrap class that is used as an executable jar's main entry point. -It is the actual `Main-Class` in your jar file, and it is used to setup an appropriate `URLClassLoader` and ultimately call your `main()` method. +The `org.springframework.boot.loader.launch.Launcher` class is a special bootstrap class that is used as an executable jar's main entry point. +It is the actual `Main-Class` in your jar file, and it is used to setup an appropriate `ClassLoader` and ultimately call your `main()` method. There are three launcher subclasses (`JarLauncher`, `WarLauncher`, and `PropertiesLauncher`). Their purpose is to load resources (`.class` files and so on) from nested jar files or war files in directories (as opposed to those explicitly on the classpath). In the case of `JarLauncher` and `WarLauncher`, the nested paths are fixed. `JarLauncher` looks in `BOOT-INF/lib/`, and `WarLauncher` looks in `WEB-INF/lib/` and `WEB-INF/lib-provided/`. You can add extra jars in those locations if you want more. + The `PropertiesLauncher` looks in `BOOT-INF/lib/` in your application archive by default. You can add additional locations by setting an environment variable called `LOADER_PATH` or `loader.path` in `loader.properties` (which is a comma-separated list of directories, archives, or directories within archives). @@ -22,7 +23,7 @@ The following example shows a typical `MANIFEST.MF` for an executable jar file: [indent=0] ---- - Main-Class: org.springframework.boot.loader.JarLauncher + Main-Class: org.springframework.boot.loader.launch.JarLauncher Start-Class: com.mycompany.project.MyApplication ---- @@ -30,7 +31,7 @@ For a war file, it would be as follows: [indent=0] ---- - Main-Class: org.springframework.boot.loader.WarLauncher + Main-Class: org.springframework.boot.loader.launch.WarLauncher Start-Class: com.mycompany.project.MyApplication ---- diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/property-launcher.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/property-launcher.adoc index 675a2bc27801..ba6ae905b834 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/property-launcher.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/property-launcher.adoc @@ -64,7 +64,7 @@ When specified as environment variables or manifest entries, the following names | `LOADER_SYSTEM` |=== -TIP: Build plugins automatically move the `Main-Class` attribute to `Start-Class` when the fat jar is built. +TIP: Build plugins automatically move the `Main-Class` attribute to `Start-Class` when the uber jar is built. If you use that, specify the name of the class to launch by using the `Main-Class` attribute and leaving out `Start-Class`. The following rules apply to working with `PropertiesLauncher`: diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc index ce6c3dbf8bb7..4184cef7eb00 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/developing-auto-configuration.adoc @@ -274,7 +274,7 @@ When building with Maven, it is recommended to add the following dependency in a ---- -If you have defined auto-configurations directly in your application, make sure to configure the `spring-boot-maven-plugin` to prevent the `repackage` goal from adding the dependency into the fat jar: +If you have defined auto-configurations directly in your application, make sure to configure the `spring-boot-maven-plugin` to prevent the `repackage` goal from adding the dependency into the uber jar: [source,xml,indent=0,subs="verbatim"] ---- diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc index d0e2070b4220..6b8c5688e232 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/docker-compose.adoc @@ -69,6 +69,9 @@ The following service connections are currently supported: |=== | Connection Details | Matched on +| `ActiveMQConnectionDetails` +| Containers named "symptoma/activemq" + | `CassandraConnectionDetails` | Containers named "cassandra" @@ -76,13 +79,28 @@ The following service connections are currently supported: | Containers named "elasticsearch" | `JdbcConnectionDetails` -| Containers named "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres" +| Containers named "gvenzl/oracle-free", "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres" + +| `LdapConnectionDetails` +| Containers named "osixia/openldap" | `MongoConnectionDetails` | Containers named "mongo" +| `Neo4jConnectionDetails` +| Containers named "neo4j" + +| `OtlpMetricsConnectionDetails` +| Containers named "otel/opentelemetry-collector-contrib" + +| `OtlpTracingConnectionDetails` +| Containers named "otel/opentelemetry-collector-contrib" + +| `PulsarConnectionDetails` +| Containers named "apachepulsar/pulsar" + | `R2dbcConnectionDetails` -| Containers named "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres" +| Containers named "gvenzl/oracle-free", "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres" | `RabbitConnectionDetails` | Containers named "rabbitmq" diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/logging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/logging.adoc index 20572e63276e..a206537903e2 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/logging.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/logging.adoc @@ -31,13 +31,16 @@ The following items are output: * Log Level: `ERROR`, `WARN`, `INFO`, `DEBUG`, or `TRACE`. * Process ID. * A `---` separator to distinguish the start of actual log messages. +* Application name: Enclosed in square brackets (logged by default only if configprop:spring.application.name[] is set) * Thread name: Enclosed in square brackets (may be truncated for console output). +* Correlation ID: If tracing is enabled (not shown in the sample above) * Logger name: This is usually the source class name (often abbreviated). * The log message. NOTE: Logback does not have a `FATAL` level. It is mapped to `ERROR`. +TIP: If you have a configprop:spring.application.name[] property but don't want it logged you can set configprop:logging.include-application-name[] to `false`. [[features.logging.console-output]] diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/spring-application.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/spring-application.adoc index 8e7eb8f2b6f6..6618484a237b 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/spring-application.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/spring-application.adoc @@ -133,7 +133,7 @@ The `application.title`, `application.version`, and `application.formatted-versi The values will not be resolved if you are running an unpacked jar and starting it with `java -cp ` or running your application as a native image. -To use the `application.*` properties, launch your application as a packed jar using `java -jar` or as an unpacked jar using `java org.springframework.boot.loader.JarLauncher`. +To use the `application.*` properties, launch your application as a packed jar using `java -jar` or as an unpacked jar using `java org.springframework.boot.loader.launch.JarLauncher`. This will initialize the `application.*` banner properties before building the classpath and launching your app. ==== @@ -377,3 +377,21 @@ Spring Boot ships with the `BufferingApplicationStartup` variant; this implement Applications can ask for the bean of type `BufferingApplicationStartup` in any component. Spring Boot can also be configured to expose a {spring-boot-actuator-restapi-docs}/#startup[`startup` endpoint] that provides this information as a JSON document. + + + +[[features.spring-application.virtual-threads]] +=== Virtual threads +If you're running on Java 21 or up, you can enable virtual threads by setting the property configprop:spring.threads.virtual.enabled[] to `true`. + +Before turning on this option for your application, you should consider https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html[reading the official Java virtual threads documentation]. +In some cases, applications can experience lower throughput because of "Pinned Virtual Threads"; this page also explains how to detect such cases with JDK Flight Recorder or the `jcmd` CLI. + +WARNING: One side effect of virtual threads is that these threads are daemon threads. +A JVM will exit if there are no non-daemon threads. +This behavior can be a problem when you rely on, e.g. `@Scheduled` beans to keep your application alive. +If you use virtual threads, the scheduler thread is a virtual thread and therefore a daemon thread and won't keep the JVM alive. +This does not only affect scheduling, but can be the case with other technologies, too! +To keep the JVM running in all cases, it is recommended to set the property configprop:spring.main.keep-alive[] to `true`. +This ensures that the JVM is kept alive, even if all threads are virtual threads. + diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc index b5ffd5de590a..7a7c925adb37 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc @@ -132,3 +132,33 @@ The following example shows retrieving an `SslBundle` and using it to create an include::code:MyComponent[] + + +[[features.ssl.reloading]] +=== Reloading SSL bundles +SSL bundles can be reloaded when the key material changes. +The component consuming the bundle has to be compatible with reloadable SSL bundles. +Currently the following components are compatible: + +* Tomcat web server +* Netty web server + +To enable reloading, you need to opt-in via a configuration property as shown in this example: + +[source,yaml,indent=0,subs="verbatim",configblocks] +---- + spring: + ssl: + bundle: + pem: + mybundle: + reload-on-update: true + keystore: + certificate: "file:/some/directory/application.crt" + private-key: "file:/some/directory/application.key" +---- + +A file watcher is then watching the files and if they change, the SSL bundle will be reloaded. +This in turn triggers a reload in the consuming component, e.g. Tomcat rotates the certificates in the SSL enabled connectors. + +You can configure the quiet period (to make sure that there are no more changes) of the file watcher with the configprop:spring.ssl.bundle.watch.file.quiet-period[] property. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/task-execution-and-scheduling.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/task-execution-and-scheduling.adoc index 71c66a419add..547be8751408 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/task-execution-and-scheduling.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/task-execution-and-scheduling.adoc @@ -1,16 +1,25 @@ [[features.task-execution-and-scheduling]] == Task Execution and Scheduling -In the absence of an `Executor` bean in the context, Spring Boot auto-configures a `ThreadPoolTaskExecutor` with sensible defaults that can be automatically associated to asynchronous task execution (`@EnableAsync`) and Spring MVC asynchronous request processing. +In the absence of an `Executor` bean in the context, Spring Boot auto-configures an `AsyncTaskExecutor`. +When virtual threads are enabled (using Java 21+ and configprop:spring.threads.virtual.enabled[] set to `true`) this will be a `SimpleAsyncTaskExecutor` that uses virtual threads. +Otherwise, it will be a `ThreadPoolTaskExecutor` with sensible defaults. +In either case, the auto-configured executor will be automatically used for: + +- asynchronous task execution (`@EnableAsync`) +- Spring for GraphQL's asynchronous handling of `Callable` return values from controller methods +- Spring MVC's asynchronous request processing +- Spring WebFlux's blocking execution support [TIP] ==== -If you have defined a custom `Executor` in the context, regular task execution (that is `@EnableAsync`) will use it transparently but the Spring MVC support will not be configured as it requires an `AsyncTaskExecutor` implementation (named `applicationTaskExecutor`). -Depending on your target arrangement, you could change your `Executor` into a `ThreadPoolTaskExecutor` or define both a `ThreadPoolTaskExecutor` and an `AsyncConfigurer` wrapping your custom `Executor`. +If you have defined a custom `Executor` in the context, both regular task execution (that is `@EnableAsync`) and Spring for GraphQL will use it. +However, the Spring MVC and Spring WebFlux support will only use it if it is an `AsyncTaskExecutor` implementation (named `applicationTaskExecutor`). +Depending on your target arrangement, you could change your `Executor` into an `AsyncTaskExecutor` or define both an `AsyncTaskExecutor` and an `AsyncConfigurer` wrapping your custom `Executor`. -The auto-configured `TaskExecutorBuilder` allows you to easily create instances that reproduce what the auto-configuration does by default. +The auto-configured `ThreadPoolTaskExecutorBuilder` allows you to easily create instances that reproduce what the auto-configuration does by default. ==== -The thread pool uses 8 core threads that can grow and shrink according to the load. +When a `ThreadPoolTaskExecutor` is auto-configured, the thread pool uses 8 core threads that can grow and shrink according to the load. Those default settings can be fine-tuned using the `spring.task.execution` namespace, as shown in the following example: [source,yaml,indent=0,subs="verbatim",configprops,configblocks] @@ -27,8 +36,11 @@ Those default settings can be fine-tuned using the `spring.task.execution` names This changes the thread pool to use a bounded queue so that when the queue is full (100 tasks), the thread pool increases to maximum 16 threads. Shrinking of the pool is more aggressive as threads are reclaimed when they are idle for 10 seconds (rather than 60 seconds by default). -A `ThreadPoolTaskScheduler` can also be auto-configured if need to be associated to scheduled task execution (using `@EnableScheduling` for instance). -The thread pool uses one thread by default and its settings can be fine-tuned using the `spring.task.scheduling` namespace, as shown in the following example: +A scheduler can also be auto-configured if it needs to be associated with scheduled task execution (using `@EnableScheduling` for instance). +When virtual threads are enabled (using Java 21+ and configprop:spring.threads.virtual.enabled[] set to `true`) this will be a `SimpleAsyncTaskScheduler` that uses virtual threads. +Otherwise, it will be a `ThreadPoolTaskScheduler` with sensible defaults. + +The `ThreadPoolTaskScheduler` uses one thread by default and its settings can be fine-tuned using the `spring.task.scheduling` namespace, as shown in the following example: [source,yaml,indent=0,subs="verbatim",configprops,configblocks] ---- @@ -40,4 +52,5 @@ The thread pool uses one thread by default and its settings can be fine-tuned us size: 2 ---- -Both a `TaskExecutorBuilder` bean and a `TaskSchedulerBuilder` bean are made available in the context if a custom executor or scheduler needs to be created. +A `ThreadPoolTaskExecutorBuilder` bean, a `SimpleAsyncTaskExecutorBuilder` bean, a `ThreadPoolTaskSchedulerBuilder` bean and a `SimpleAsyncTaskSchedulerBuilder` are made available in the context if a custom executor or scheduler needs to be created. +The `SimpleAsyncTaskExecutorBuilder` and `SimpleAsyncTaskSchedulerBuilder` beans are auto-configured to use virtual threads if they are enabled (using Java 21+ and configprop:spring.threads.virtual.enabled[] set to `true`). diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testcontainers.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testcontainers.adoc index f7d806bc2702..826db3cb7e31 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testcontainers.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testcontainers.adoc @@ -34,6 +34,9 @@ include::code:test/MyContainersConfiguration[] NOTE: The lifecycle of `Container` beans is automatically managed by Spring Boot. Containers will be started and stopped automatically. +TIP: You can use the configprop:spring.testcontainers.beans.startup[] property to change how containers are started. +By default `sequential` startup is used, but you may also choose `parallel` if you wish to start multiple containers in parallel. + Once you have defined your test configuration, you can use the `with(...)` method to attach it to your test launcher: include::code:test/TestMyApplication[] @@ -86,5 +89,5 @@ This is especially useful for Testcontainer `Container` beans, as they keep thei include::code:MyContainersConfiguration[] -WARNING: If you're using Gradle and want to use this feature, you need to change the configuration of the `spring-boot-devtools` dependency from `developmentOnly` to `testImplementation`. +WARNING: If you're using Gradle and want to use this feature, you need to change the configuration of the `spring-boot-devtools` dependency from `developmentOnly` to `testAndDevelopmentOnly`. With the default scope of `developmentOnly`, the `bootTestRun` task will not pick up changes in your code, as the devtools are not active. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc index e96aef405cd3..24bcf0d1ee44 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc @@ -41,6 +41,7 @@ The `spring-boot-starter-test` "`Starter`" (in the `test` `scope`) contains the * https://site.mockito.org/[Mockito]: A Java mocking framework. * https://github.com/skyscreamer/JSONassert[JSONassert]: An assertion library for JSON. * https://github.com/jayway/JsonPath[JsonPath]: XPath for JSON. +* https://https://github.com/awaitility/awaitility[Awaitility]: A library for testing asynchronous systems. We generally find these common libraries to be useful when writing tests. If these libraries do not suit your needs, you can add additional test dependencies of your own. @@ -254,6 +255,11 @@ If such test needs access to an `MBeanServer`, consider marking it dirty as well include::code:MyJmxTests[] +[[features.testing.spring-boot-applications.observations]] +==== Using Observations +If you annotate <> with `@AutoConfigureObservability`, it auto-configures an `ObservationRegistry`. + + [[features.testing.spring-boot-applications.metrics]] ==== Using Metrics @@ -261,13 +267,21 @@ Regardless of your classpath, meter registries, except the in-memory backed, are If you need to export metrics to a different backend as part of an integration test, annotate it with `@AutoConfigureObservability`. +If you annotate <> with `@AutoConfigureObservability`, it auto-configures an in-memory `MeterRegistry`. +Data exporting in sliced tests is not supported with the `@AutoConfigureObservability` annotation. + [[features.testing.spring-boot-applications.tracing]] ==== Using Tracing -Regardless of your classpath, tracing is not auto-configured when using `@SpringBootTest`. +Regardless of your classpath, tracing components which are reporting data are not auto-configured when using `@SpringBootTest`. -If you need tracing as part of an integration test, annotate it with `@AutoConfigureObservability`. +If you need those components as part of an integration test, annotate the test with `@AutoConfigureObservability`. + +If you have created your own reporting components (e.g. a custom `SpanExporter` or `SpanHandler`) and you don't want them to be active in tests, you can use the `@ConditionalOnEnabledTracing` annotation to disable them. + +If you annotate <> with `@AutoConfigureObservability`, it auto-configures a no-op `Tracer`. +Data exporting in sliced tests is not supported with the `@AutoConfigureObservability` annotation. @@ -477,7 +491,7 @@ There are `GraphQlTester` variants and Spring Boot will auto-configure them depe * the `ExecutionGraphQlServiceTester` performs tests on the server side, without a client nor a transport * the `HttpGraphQlTester` performs tests with a client that connects to a server, with or without a live server -Spring Boot helps you to test your {spring-graphql-docs}#controllers[Spring GraphQL Controllers] with the `@GraphQlTest` annotation. +Spring Boot helps you to test your {spring-graphql-docs}/#controllers[Spring GraphQL Controllers] with the `@GraphQlTest` annotation. `@GraphQlTest` auto-configures the Spring GraphQL infrastructure, without any transport nor server being involved. This limits scanned beans to `@Controller`, `RuntimeWiringConfigurer`, `JsonComponent`, `Converter`, `GenericConverter`, `DataFetcherExceptionResolver`, `Instrumentation` and `GraphQlSourceBuilderCustomizer`. Regular `@Component` and `@ConfigurationProperties` beans are not scanned when the `@GraphQlTest` annotation is used. @@ -737,15 +751,21 @@ include::code:server/MyDataLdapTests[] [[features.testing.spring-boot-applications.autoconfigured-rest-client]] ==== Auto-configured REST Clients You can use the `@RestClientTest` annotation to test REST clients. -By default, it auto-configures Jackson, GSON, and Jsonb support, configures a `RestTemplateBuilder`, and adds support for `MockRestServiceServer`. +By default, it auto-configures Jackson, GSON, and Jsonb support, configures a `RestTemplateBuilder` and a `RestClient.Builder`, and adds support for `MockRestServiceServer`. Regular `@Component` and `@ConfigurationProperties` beans are not scanned when the `@RestClientTest` annotation is used. `@EnableConfigurationProperties` can be used to include `@ConfigurationProperties` beans. TIP: A list of the auto-configuration settings that are enabled by `@RestClientTest` can be <>. -The specific beans that you want to test should be specified by using the `value` or `components` attribute of `@RestClientTest`, as shown in the following example: +The specific beans that you want to test should be specified by using the `value` or `components` attribute of `@RestClientTest`. + +When using a `RestTemplateBuilder` in the beans under test and `RestTemplateBuilder.rootUri(String rootUri)` has been called when building the `RestTemplate`, then the root URI should be omitted from the `MockRestServiceServer` expectations as shown in the following example: -include::code:MyRestClientTests[] +include::code:MyRestTemplateServiceTests[] + +When using a `RestClient.Builder` in the beans under test, or when using a `RestTemplateBuilder` without calling `rootUri(String rootURI)`, the full URI must be used in the `MockRestServiceServer` expectations as shown in the following example: + +include::code:MyRestClientServiceTests[] @@ -960,6 +980,9 @@ The following service connection factories are provided in the `spring-boot-test |=== | Connection Details | Matched on +| `ActiveMQConnectionDetails` +| Containers named "symptoma/activemq" + | `CassandraConnectionDetails` | Containers of type `CassandraContainer` @@ -987,6 +1010,15 @@ The following service connection factories are provided in the `spring-boot-test | `Neo4jConnectionDetails` | Containers of type `Neo4jContainer` +| `OtlpMetricsConnectionDetails` +| Containers named "otel/opentelemetry-collector-contrib" + +| `OtlpTracingConnectionDetails` +| Containers named "otel/opentelemetry-collector-contrib" + +| `PulsarConnectionDetails` +| Containers of type `PulsarContainer` + | `R2dbcConnectionDetails` | Containers of type `MariaDBContainer`, `MSSQLServerContainer`, `MySQLContainer`, `OracleContainer`, or `PostgreSQLContainer` diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/first-application.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/first-application.adoc index c49470f65cd4..3556aed2c0c7 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/first-application.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/first-application.adoc @@ -381,7 +381,7 @@ To gracefully exit the application, press `ctrl-c`. [[getting-started.first-application.executable-jar]] === Creating an Executable Jar We finish our example by creating a completely self-contained executable jar file that we could run in production. -Executable jars (sometimes called "`fat jars`") are archives containing your compiled classes along with all of the jar dependencies that your code needs to run. +Executable jars (sometimes called "`uber jars`" or "`fat jars`") are archives containing your compiled classes along with all of the jar dependencies that your code needs to run. .Executable jars and Java **** diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/system-requirements.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/system-requirements.adoc index 9029d7a99626..fb7208597a61 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/system-requirements.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/getting-started/system-requirements.adoc @@ -27,8 +27,8 @@ Spring Boot supports the following embedded servlet containers: | Tomcat 10.1 | 6.0 -| Jetty 11.0 -| 5.0 +| Jetty 12.0 +| 6.0 | Undertow 2.3 | 6.0 diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/application.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/application.adoc index 12220eda9229..946efc45c38d 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/application.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/application.adoc @@ -61,7 +61,6 @@ Spring Boot loads a number of such customizations for use internally from `META- There is more than one way to register additional customizations: * Programmatically, per application, by calling the `addListeners` and `addInitializers` methods on `SpringApplication` before you run it. -* Declaratively, per application, by setting the `context.initializer.classes` or `context.listener.classes` properties. * Declaratively, for all applications, by adding a `META-INF/spring.factories` and packaging a jar file that the applications all use as a library. The `SpringApplication` sends some special `ApplicationEvents` to the listeners (some even before the context is created) and then registers the listeners for events published by the `ApplicationContext` as well. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/batch.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/batch.adoc index ee78bac2023e..c03c81520509 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/batch.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/batch.adoc @@ -23,7 +23,7 @@ For more info about Spring Batch, see the {spring-batch}[Spring Batch project pa === Running Spring Batch Jobs on Startup Spring Batch auto-configuration is enabled by adding `spring-boot-starter-batch` to your application's classpath. -If a single `Job` is found in the application context, it is executed on startup (see {spring-boot-autoconfigure-module-code}/batch/JobLauncherApplicationRunner.java[`JobLauncherApplicationRunner`] for details). +If a single `Job` bean is found in the application context, it is executed on startup (see {spring-boot-autoconfigure-module-code}/batch/JobLauncherApplicationRunner.java[`JobLauncherApplicationRunner`] for details). If multiple `Job` beans are found, the job that should be executed must be specified using configprop:spring.batch.job.name[]. To disable running a `Job` found in the application context, set the configprop:spring.batch.job.enabled[] to `false`. @@ -69,4 +69,4 @@ NOTE: When you're using a custom `JobParametersIncrementer`, you have to gather === Storing the Job Repository Spring Batch requires a data store for the `Job` repository. If you use Spring Boot, you must use an actual database. -Note that it can be an in-memory database, see {spring-batch-docs}job.html#configuringJobRepository[Configuring a Job Repository]. +Note that it can be an in-memory database, see {spring-batch-docs}/job.html#configuringJobRepository[Configuring a Job Repository]. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/build.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/build.adoc index 97c24447a62d..cfae774a9500 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/build.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/build.adoc @@ -290,7 +290,7 @@ The following example shows how to build an executable archive with Ant: - + diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-access.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-access.adoc index 50d30a2c2d96..5c5894d83724 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-access.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-access.adoc @@ -150,7 +150,7 @@ Note that each `configuration` sub namespace provides advanced settings based on [[howto.data-access.spring-data-repositories]] === Use Spring Data Repositories Spring Data can create implementations of `@Repository` interfaces of various flavors. -Spring Boot handles all of that for you, as long as those `@Repositories` are included in one of the <>, typically the package (or a sub-package) of your main application class that is annotated with `@SpringBootApplication` or `@EnableAutoConfiguration`. +Spring Boot handles all of that for you, as long as those `@Repository` annotations are included in one of the <>, typically the package (or a sub-package) of your main application class that is annotated with `@SpringBootApplication` or `@EnableAutoConfiguration`. For many applications, all you need is to put the right Spring Data dependencies on your classpath. There is a `spring-boot-starter-data-jpa` for JPA, `spring-boot-starter-data-mongodb` for Mongodb, and various other starters for supported technologies. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-initialization.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-initialization.adoc index fcb20a4e5b79..d602fed56b84 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-initialization.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/data-initialization.adoc @@ -19,11 +19,11 @@ This is controlled through two external properties: [[howto.data-initialization.using-hibernate]] === Initialize a Database Using Hibernate -You can set `spring.jpa.hibernate.ddl-auto` explicitly and the standard Hibernate property values are `none`, `validate`, `update`, `create`, and `create-drop`. +You can set `spring.jpa.hibernate.ddl-auto` explicitly to one of the standard Hibernate property values which are `none`, `validate`, `update`, `create`, and `create-drop`. Spring Boot chooses a default value for you based on whether it thinks your database is embedded. It defaults to `create-drop` if no schema manager has been detected or `none` in all other cases. An embedded database is detected by looking at the `Connection` type and JDBC url. -`hsqldb`, `h2`, and `derby` are candidates, and others are not. +`hsqldb`, `h2`, and `derby` are candidates, while others are not. Be careful when switching from in-memory to a '`real`' database that you do not make assumptions about the existence of the tables and data in the new platform. You either have to set `ddl-auto` explicitly or use one of the other mechanisms to initialize the database. @@ -41,8 +41,8 @@ It is a Hibernate feature (and has nothing to do with Spring). Spring Boot can automatically create the schema (DDL scripts) of your JDBC `DataSource` or R2DBC `ConnectionFactory` and initialize its data (DML scripts). By default, it loads schema scripts from `optional:classpath*:schema.sql` and data scripts from `optional:classpath*:data.sql`. -The locations of these schema and data scripts can customized using configprop:spring.sql.init.schema-locations[] and configprop:spring.sql.init.data-locations[] respectively. -The `optional:` prefix means that the application will start when the files do not exist. +The locations of these schema and data scripts can be customized using configprop:spring.sql.init.schema-locations[] and configprop:spring.sql.init.data-locations[] respectively. +The `optional:` prefix means that the application will start even when the files do not exist. To have the application fail to start when the files are absent, remove the `optional:` prefix. In addition, Spring Boot processes the `optional:classpath*:schema-$\{platform}.sql` and `optional:classpath*:data-$\{platform}.sql` files (if present), where `$\{platform}` is the value of configprop:spring.sql.init.platform[]. @@ -271,6 +271,7 @@ Spring Boot will automatically detect beans of the following types that depends - `AbstractEntityManagerFactoryBean` (unless configprop:spring.jpa.defer-datasource-initialization[] is set to `true`) - `DSLContext` (jOOQ) - `EntityManagerFactory` (unless configprop:spring.jpa.defer-datasource-initialization[] is set to `true`) +- `JdbcClient` - `JdbcOperations` - `NamedParameterJdbcOperations` diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/docker-compose.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/docker-compose.adoc index 9b59fadc5635..d889637d1964 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/docker-compose.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/docker-compose.adoc @@ -33,6 +33,6 @@ With this Docker Compose file in place, the JDBC URL used is `jdbc:postgresql:// === Sharing services between multiple applications If you want to share services between multiple applications, create the `compose.yaml` file in one of the applications and then use the configuration property configprop:spring.docker.compose.file[] in the other applications to reference the `compose.yaml` file. -You should also set configprop:spring.docker.compose.lifecycle-management[] to `start-only`, as it defaults to `start-and-stop` and stopping one application would shut down the shared services for the other still running applications, too. -Setting it to `start-only` won't stop the shared services on application stop, but a caveat is that if you shut down all applications, the services stay running. -You can stop the services manually by running `docker compose stop` on the commandline in the directory which contains the `compose.yaml` file. +You should also set configprop:spring.docker.compose.lifecycle-management[] to `start-only`, as it defaults to `start-and-stop` and stopping one application would shut down the shared services for the other still running applications as well. +Setting it to `start-only` won't stop the shared services on application stop, but a caveat is that if you shut down all applications, the services remain running. +You can stop the services manually by running `docker compose stop` on the command line in the directory which contains the `compose.yaml` file. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/logging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/logging.adoc index e0a3ebf2d677..5afd42281878 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/logging.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/logging.adoc @@ -3,7 +3,7 @@ Spring Boot has no mandatory logging dependency, except for the Commons Logging API, which is typically provided by Spring Framework's `spring-jcl` module. To use https://logback.qos.ch[Logback], you need to include it and `spring-jcl` on the classpath. The recommended way to do that is through the starters, which all depend on `spring-boot-starter-logging`. -For a web application, you need only `spring-boot-starter-web`, since it depends transitively on the logging starter. +For a web application, you only need `spring-boot-starter-web`, since it depends transitively on the logging starter. If you use Maven, the following dependency adds logging for you: [source,xml,indent=0,subs="verbatim"] @@ -27,7 +27,7 @@ If the only change you need to make to logging is to set the levels of various l org.hibernate: "error" ---- -You can also set the location of a file to which to write the log (in addition to the console) by using `logging.file.name`. +You can also set the location of a file to which the log will be written (in addition to the console) by using `logging.file.name`. To configure the more fine-grained settings of a logging system, you need to use the native configuration format supported by the `LoggingSystem` in question. By default, Spring Boot picks up the native configuration from its default location for the system (such as `classpath:logback.xml` for Logback), but you can set the location of the config file by using the configprop:logging.config[] property. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/nosql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/nosql.adoc index c7be21231a0b..67fc1d167799 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/nosql.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/nosql.adoc @@ -9,9 +9,9 @@ This section answers questions that arise from using NoSQL with Spring Boot. === Use Jedis Instead of Lettuce By default, the Spring Boot starter (`spring-boot-starter-data-redis`) uses https://github.com/lettuce-io/lettuce-core/[Lettuce]. You need to exclude that dependency and include the https://github.com/xetorthio/jedis/[Jedis] one instead. -Spring Boot manages both of these dependencies so you can switch to Jedis without specifying a version. +Spring Boot manages both of these dependencies, allowing you to switch to Jedis without specifying a version. -The following example shows how to do so in Maven: +The following example shows how to accomplish this in Maven: [source,xml,indent=0,subs="verbatim"] ---- @@ -31,7 +31,7 @@ The following example shows how to do so in Maven: ---- -The following example shows how to do so in Gradle: +The following example shows how to accomplish this in Gradle: [source,gradle,indent=0,subs="verbatim"] ---- diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/security.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/security.adoc index 9f7509e035f1..5f813b9c76fb 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/security.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/security.adoc @@ -8,7 +8,7 @@ For more about Spring Security, see the {spring-security}[Spring Security projec [[howto.security.switch-off-spring-boot-configuration]] === Switch off the Spring Boot Security Configuration -If you define a `@Configuration` with a `SecurityFilterChain` bean in your application, it switches off the default webapp security settings in Spring Boot. +If you define a `@Configuration` with a `SecurityFilterChain` bean in your application, this action switches off the default webapp security settings in Spring Boot. @@ -17,14 +17,14 @@ If you define a `@Configuration` with a `SecurityFilterChain` bean in your appli If you provide a `@Bean` of type `AuthenticationManager`, `AuthenticationProvider`, or `UserDetailsService`, the default `@Bean` for `InMemoryUserDetailsManager` is not created. This means you have the full feature set of Spring Security available (such as {spring-security-docs}/servlet/authentication/index.html[various authentication options]). -The easiest way to add user accounts is to provide your own `UserDetailsService` bean. +The easiest way to add user accounts is by providing your own `UserDetailsService` bean. [[howto.security.enable-https]] === Enable HTTPS When Running behind a Proxy Server Ensuring that all your main endpoints are only available over HTTPS is an important chore for any application. -If you use Tomcat as a servlet container, then Spring Boot adds Tomcat's own `RemoteIpValve` automatically if it detects some environment settings, and you should be able to rely on the `HttpServletRequest` to report whether it is secure or not (even downstream of a proxy server that handles the real SSL termination). +If you use Tomcat as a servlet container, then Spring Boot adds Tomcat's own `RemoteIpValve` automatically if it detects some environment settings, allowing you to rely on the `HttpServletRequest` to report whether it is secure or not (even downstream of a proxy server that handles the real SSL termination). The standard behavior is determined by the presence or absence of certain request headers (`x-forwarded-for` and `x-forwarded-proto`), whose names are conventional, so it should work with most front-end proxies. You can switch on the valve by adding some entries to `application.properties`, as shown in the following example: diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/spring-mvc.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/spring-mvc.adoc index 898e15d3f1c5..1a137f05a2a6 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/spring-mvc.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/spring-mvc.adoc @@ -65,11 +65,19 @@ Spring Boot also has some features to make it easier to customize this behavior. You can configure the `ObjectMapper` and `XmlMapper` instances by using the environment. Jackson provides an extensive suite of on/off features that can be used to configure various aspects of its processing. -These features are described in six enums (in Jackson) that map onto properties in the environment: +These features are described in several enums (in Jackson) that map onto properties in the environment: |=== | Enum | Property | Values +| `com.fasterxml.jackson.databind.cfg.EnumFeature` +| `spring.jackson.datatype.enum.` +| `true`, `false` + +| `com.fasterxml.jackson.databind.cfg.JsonNodeFeature` +| `spring.jackson.datatype.json-node.` +| `true`, `false` + | `com.fasterxml.jackson.databind.DeserializationFeature` | `spring.jackson.deserialization.` | `true`, `false` diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/testing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/testing.adoc index 2c76b8689b8f..f0a1a1f92327 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/testing.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/testing.adoc @@ -12,7 +12,7 @@ For example, the test in the snippet below will run with an authenticated user t include::code:MySecurityTests[] -Spring Security provides comprehensive integration with Spring MVC Test and this can also be used when testing controllers using the `@WebMvcTest` slice and `MockMvc`. +Spring Security provides comprehensive integration with Spring MVC Test, and this can also be used when testing controllers using the `@WebMvcTest` slice and `MockMvc`. For additional details on Spring Security's testing support, see Spring Security's {spring-security-docs}/servlet/test/index.html[reference documentation]. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc index e1b98ecd29d6..a8502afc7f19 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc @@ -20,9 +20,6 @@ The following Maven example shows how to exclude Tomcat and include Jetty for Sp [source,xml,indent=0,subs="verbatim"] ---- - - 5.0.0 - org.springframework.boot spring-boot-starter-web @@ -41,20 +38,6 @@ The following Maven example shows how to exclude Tomcat and include Jetty for Sp ---- -NOTE: The version of the Jakarta Servlet API has been overridden as, unlike Tomcat 10 and Undertow 2.3, Jetty 11 does not support Servlet 6.0. - -[WARNING] -==== -Downgrading the Servlet API to 5.0 breaks Spring Framework's Servlet-related mocks! - -As Jetty needs the Servlet API 5.0, this leaves you with two working arrangements: - -* Tests use the Servlet 5.0 API and avoid using Framework's Servlet mocks by only using a full-blown web environment -* Tests use the Servlet 6.0 API and avoid starting Jetty by only using a mock web environment - -If a mixture of web environments is required by your application's tests, your test setup may require some structural changes to strictly separate the two web environments. -==== - The following Gradle example configures the necessary dependencies and a {gradle-docs}/resolution_rules.html#sec:module_replacement[module replacement] to use Undertow in place of Reactor Netty for Spring WebFlux: [source,gradle,indent=0,subs="verbatim"] @@ -238,7 +221,7 @@ More on this in the {tomcat-docs}/apr.html[official Tomcat documentation]. [[howto.webserver.configure-http2.jetty]] ==== HTTP/2 With Jetty -For HTTP/2 support, Jetty requires the additional `org.eclipse.jetty.http2:http2-server` dependency. +For HTTP/2 support, Jetty requires the additional `org.eclipse.jetty.http2:jetty-http2-server` dependency. To use `h2c` no other dependencies are required. To use `h2`, you also need to choose one of the following dependencies, depending on your deployment: diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc index 3d6604e85b76..4966c197d1b7 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc @@ -19,7 +19,7 @@ The reference documentation consists of the following sections: <> :: Servlet Web, Reactive Web, Embedded Container Support, Graceful Shutdown, and more. <> :: SQL and NOSQL data access. <> :: Caching, Quartz Scheduler, REST clients, Sending email, Spring Web Services, and more. -<> :: JMS, AMQP, Apache Kafka, RSocket, WebSocket, and Spring Integration. +<> :: JMS, AMQP, Apache Kafka, Apache Pulsar, RSocket, WebSocket, and Spring Integration. <> :: Efficient container images and Building container images with Dockerfiles and Cloud Native Buildpacks. <> :: Monitoring, Metrics, Auditing, and more. <> :: Deploying to the Cloud, and Installing as a Unix application. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc index c4ede9b41c40..6ec0da3ca536 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc @@ -1,79 +1,23 @@ [[io.rest-client]] == Calling REST Services -If your application calls remote REST services, Spring Boot makes that very convenient using a `RestTemplate` or a `WebClient`. - -[[io.rest-client.resttemplate]] -=== RestTemplate -If you need to call remote REST services from your application, you can use the Spring Framework's {spring-framework-api}/web/client/RestTemplate.html[`RestTemplate`] class. -Since `RestTemplate` instances often need to be customized before being used, Spring Boot does not provide any single auto-configured `RestTemplate` bean. -It does, however, auto-configure a `RestTemplateBuilder`, which can be used to create `RestTemplate` instances when needed. -The auto-configured `RestTemplateBuilder` ensures that sensible `HttpMessageConverters` are applied to `RestTemplate` instances. - -The following code shows a typical example: - -include::code:MyService[] - -`RestTemplateBuilder` includes a number of useful methods that can be used to quickly configure a `RestTemplate`. -For example, to add BASIC authentication support, you can use `builder.basicAuthentication("user", "password").build()`. - - - -[[io.rest-client.resttemplate.http-client]] -==== RestTemplate HTTP Client -Spring Boot will auto-detect which HTTP client to use with `RestTemplate` depending on the libraries available on the application classpath. -In order of preference, the following clients are supported: - -. Apache HttpClient -. OkHttp -. Simple JDK client (`HttpURLConnection`) - -If multiple clients are available on the classpath, the most preferred client will be used. - - - -[[io.rest-client.resttemplate.customization]] -==== RestTemplate Customization -There are three main approaches to `RestTemplate` customization, depending on how broadly you want the customizations to apply. - -To make the scope of any customizations as narrow as possible, inject the auto-configured `RestTemplateBuilder` and then call its methods as required. -Each method call returns a new `RestTemplateBuilder` instance, so the customizations only affect this use of the builder. - -To make an application-wide, additive customization, use a `RestTemplateCustomizer` bean. -All such beans are automatically registered with the auto-configured `RestTemplateBuilder` and are applied to any templates that are built with it. - -The following example shows a customizer that configures the use of a proxy for all hosts except `192.168.0.5`: - -include::code:MyRestTemplateCustomizer[] - -Finally, you can define your own `RestTemplateBuilder` bean. -Doing so will replace the auto-configured builder. -If you want any `RestTemplateCustomizer` beans to be applied to your custom builder, as the auto-configuration would have done, configure it using a `RestTemplateBuilderConfigurer`. -The following example exposes a `RestTemplateBuilder` that matches what Spring Boot's auto-configuration would have done, except that custom connect and read timeouts are also specified: - -include::code:MyRestTemplateBuilderConfiguration[] - -The most extreme (and rarely used) option is to create your own `RestTemplateBuilder` bean without using a configurer. -In addition to replacing the auto-configured builder, this also prevents any `RestTemplateCustomizer` beans from being used. - - - -[[io.rest-client.resttemplate.ssl]] -==== RestTemplate SSL Support -If you need custom SSL configuration on the `RestTemplate`, you can apply an <> to the `RestTemplateBuilder` as shown in this example: - -include::code:MyService[] +Spring Boot provides various convenient ways to call remote REST services. +If you are developing a non-blocking reactive application and you're using Spring WebFlux, then you can use `WebClient`. +If you prefer blocking APIs then you can use `RestClient` or `RestTemplate`. [[io.rest-client.webclient]] === WebClient -If you have Spring WebFlux on your classpath, you can also choose to use `WebClient` to call remote REST services. -Compared to `RestTemplate`, this client has a more functional feel and is fully reactive. +If you have Spring WebFlux on your classpath we recommend that you use `WebClient` to call remote REST services. +The `WebClient` interface provides a functional style API and is fully reactive. You can learn more about the `WebClient` in the dedicated {spring-framework-docs}/web/webflux-webclient.html[section in the Spring Framework docs]. -Spring Boot creates and pre-configures a `WebClient.Builder` for you. +TIP: If you are not writing a reactive Spring WebFlux application you can use the <> instead of a `WebClient`. +This provides a similar functional API, but is blocking rather than reactive. + +Spring Boot creates and pre-configures a prototype `WebClient.Builder` bean for you. It is strongly advised to inject it in your components and use it to create `WebClient` instances. -Spring Boot is configuring that builder to share HTTP resources, reflect codecs setup in the same fashion as the server ones (see <>), and more. +Spring Boot is configuring that builder to share HTTP resources and reflect codecs setup in the same fashion as the server ones (see <>), and more. The following code shows a typical example: @@ -130,3 +74,115 @@ The following code shows a typical example: include::code:MyService[] + + +[[io.rest-client.restclient]] +=== RestClient +If you are not using Spring WebFlux or Project Reactor in your application we recommend that you use `RestClient` to call remote REST services. + +The `RestClient` interface provides a functional style blocking API. + +Spring Boot creates and pre-configures a prototype `RestClient.Builder` bean for you. +It is strongly advised to inject it in your components and use it to create `RestClient` instances. +Spring Boot is configuring that builder with `HttpMessageConverters` and an appropriate `ClientHttpRequestFactory`. + +The following code shows a typical example: + +include::code:MyService[] + + + +[[io.rest-client.restclient.customization]] +==== RestClient Customization +There are three main approaches to `RestClient` customization, depending on how broadly you want the customizations to apply. + +To make the scope of any customizations as narrow as possible, inject the auto-configured `RestClient.Builder` and then call its methods as required. +`RestClient.Builder` instances are stateful: Any change on the builder is reflected in all clients subsequently created with it. +If you want to create several clients with the same builder, you can also consider cloning the builder with `RestClient.Builder other = builder.clone();`. + +To make an application-wide, additive customization to all `RestClient.Builder` instances, you can declare `RestClientCustomizer` beans and change the `RestClient.Builder` locally at the point of injection. + +Finally, you can fall back to the original API and use `RestClient.create()`. +In that case, no auto-configuration or `RestClientCustomizer` is applied. + + + +[[io.rest-client.restclient.ssl]] +==== RestClient SSL Support +If you need custom SSL configuration on the `ClientHttpRequestFactory` used by the `RestClient`, you can inject a `RestClientSsl` instance that can be used with the builder's `apply` method. + +The `RestClientSsl` interface provides access to any <> that you have defined in your `application.properties` or `application.yaml` file. + +The following code shows a typical example: + +include::code:MyService[] + +If you need to apply other customization in addition to an SSL bundle, you can use the `ClientHttpRequestFactorySettings` class with `ClientHttpRequestFactories`: + +include::code:settings/MyService[] + + + +[[io.rest-client.resttemplate]] +=== RestTemplate +Spring Framework's {spring-framework-api}/web/client/RestTemplate.html[`RestTemplate`] class predates `RestClient` and is the classic way that many applications use to call remote REST services. +You might choose to use `RestTemplate` when you have existing code that you don't want to migrate to `RestClient`, or because you're already familiar with the `RestTemplate` API. + +Since `RestTemplate` instances often need to be customized before being used, Spring Boot does not provide any single auto-configured `RestTemplate` bean. +It does, however, auto-configure a `RestTemplateBuilder`, which can be used to create `RestTemplate` instances when needed. +The auto-configured `RestTemplateBuilder` ensures that sensible `HttpMessageConverters` and an appropriate `ClientHttpRequestFactory` are applied to `RestTemplate` instances. + +The following code shows a typical example: + +include::code:MyService[] + +`RestTemplateBuilder` includes a number of useful methods that can be used to quickly configure a `RestTemplate`. +For example, to add BASIC authentication support, you can use `builder.basicAuthentication("user", "password").build()`. + + + +[[io.rest-client.resttemplate.customization]] +==== RestTemplate Customization +There are three main approaches to `RestTemplate` customization, depending on how broadly you want the customizations to apply. + +To make the scope of any customizations as narrow as possible, inject the auto-configured `RestTemplateBuilder` and then call its methods as required. +Each method call returns a new `RestTemplateBuilder` instance, so the customizations only affect this use of the builder. + +To make an application-wide, additive customization, use a `RestTemplateCustomizer` bean. +All such beans are automatically registered with the auto-configured `RestTemplateBuilder` and are applied to any templates that are built with it. + +The following example shows a customizer that configures the use of a proxy for all hosts except `192.168.0.5`: + +include::code:MyRestTemplateCustomizer[] + +Finally, you can define your own `RestTemplateBuilder` bean. +Doing so will replace the auto-configured builder. +If you want any `RestTemplateCustomizer` beans to be applied to your custom builder, as the auto-configuration would have done, configure it using a `RestTemplateBuilderConfigurer`. +The following example exposes a `RestTemplateBuilder` that matches what Spring Boot's auto-configuration would have done, except that custom connect and read timeouts are also specified: + +include::code:MyRestTemplateBuilderConfiguration[] + +The most extreme (and rarely used) option is to create your own `RestTemplateBuilder` bean without using a configurer. +In addition to replacing the auto-configured builder, this also prevents any `RestTemplateCustomizer` beans from being used. + + + +[[io.rest-client.resttemplate.ssl]] +==== RestTemplate SSL Support +If you need custom SSL configuration on the `RestTemplate`, you can apply an <> to the `RestTemplateBuilder` as shown in this example: + +include::code:MyService[] + + + +[[io.rest-client.clienthttprequestfactory]] +=== HTTP Client Detection for RestClient and RestTemplate +Spring Boot will auto-detect which HTTP client to use with `RestClient` and `RestTemplate` depending on the libraries available on the application classpath. +In order of preference, the following clients are supported: + +. Apache HttpClient +. Jetty HttpClient +. OkHttp (deprecated) +. Simple JDK client (`HttpURLConnection`) + +If multiple clients are available on the classpath, the most preferred client will be used. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc index 8b6a5ec6e626..12aca393d1a6 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc @@ -6,7 +6,7 @@ The Spring Framework provides extensive support for integrating with messaging s Spring AMQP provides a similar feature set for the Advanced Message Queuing Protocol. Spring Boot also provides auto-configuration options for `RabbitTemplate` and RabbitMQ. Spring WebSocket natively includes support for STOMP messaging, and Spring Boot has support for that through starters and a small amount of auto-configuration. -Spring Boot also has support for Apache Kafka. +Spring Boot also has support for Apache Kafka and Apache Pulsar. include::messaging/jms.adoc[] @@ -15,6 +15,8 @@ include::messaging/amqp.adoc[] include::messaging/kafka.adoc[] +include::messaging/pulsar.adoc[] + include::messaging/rsocket.adoc[] include::messaging/spring-integration.adoc[] diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/kafka.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/kafka.adoc index 9956d84f5f8e..314b0e3a0a93 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/kafka.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/kafka.adoc @@ -142,7 +142,7 @@ IMPORTANT: Properties set in this way override any configuration item that Sprin === Testing with Embedded Kafka Spring for Apache Kafka provides a convenient way to test projects with an embedded Apache Kafka broker. To use this feature, annotate a test class with `@EmbeddedKafka` from the `spring-kafka-test` module. -For more information, please see the Spring for Apache Kafka {spring-kafka-docs}#embedded-kafka-annotation[reference manual]. +For more information, please see the Spring for Apache Kafka {spring-kafka-docs}testing.html#ekb[reference manual]. To make Spring Boot auto-configuration work with the aforementioned embedded Apache Kafka broker, you need to remap a system property for embedded broker addresses (populated by the `EmbeddedKafkaBroker`) into the Spring Boot configuration property for Apache Kafka. There are several ways to do that: diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc new file mode 100644 index 000000000000..4a4d435bb5f4 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc @@ -0,0 +1,208 @@ +[[messaging.pulsar]] +== Apache Pulsar Support +https://pulsar.apache.org/[Apache Pulsar] is supported by providing auto-configuration of the {spring-pulsar-docs}[Spring for Apache Pulsar] project. + +Spring Boot will auto-configure and register the classic (imperative) Spring for Apache Pulsar components when `org.springframework.pulsar:spring-pulsar` is on the classpath. +It will do the same for the reactive components when `org.springframework.pulsar:spring-pulsar-reactive` is on the classpath. + +There are `spring-boot-starter-pulsar` and `spring-boot-starter-pulsar-reactive` "`Starters`" for conveniently collecting the dependencies for imperative and reactive use, respectively. + + + +[[messaging.pulsar.connecting]] +=== Connecting to Pulsar +When you use the Pulsar starter, Spring Boot will auto-configure and register a `PulsarClient` bean. + +By default, the application tries to connect to a local Pulsar instance at `pulsar://localhost:6650`. +This can be adjusted by setting the configprop:spring.pulsar.client.service-url[] property to a different value. + +NOTE: The value must be a valid https://pulsar.apache.org/docs/client-libraries-java/#connection-urls[Pulsar Protocol] URL + +You can configure the client by specifying any of the `spring.pulsar.client.*` prefixed application properties. + +If you need more control over the configuration, consider registering one or more `PulsarClientBuilderCustomizer` beans. + + + +[[messaging.pulsar.connecting.auth]] +==== Authentication +To connect to a Pulsar cluster that requires authentication, you need to specify which authentication plugin to use by setting the `pluginClassName` and any parameters required by the plugin. +You can set the parameters as a map of parameter names to parameter values. +The following example shows how to configure the `AuthenticationOAuth2` plugin. + +[source,yaml,indent=0,subs="verbatim",configprops,configblocks] +---- +spring: + pulsar: + client: + authentication: + plugin-class-name: org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2 + param: + issuerUrl: https://auth.server.cloud/ + privateKey: file:///Users/some-key.json + audience: urn:sn:acme:dev:my-instance +---- + +[NOTE] +==== +You need to ensure that names defined under `+spring.pulsar.client.authentication.param.*+` exactly match those expected by your auth plugin (which is typically camel cased). +Spring Boot will not attempt any kind of relaxed binding for these entries. + +For example, if you want to configure the issuer url for the `AuthenticationOAuth2` auth plugin you must use `+spring.pulsar.client.authentication.param.issuerUrl+`. +If you use other forms, such as `issuerurl` or `issuer-url`, the setting will not be applied to the plugin. +==== + + + +[[messaging.pulsar.connecting.ssl]] +==== SSL +By default, Pulsar clients communicate with Pulsar services in plain text. +You can follow {spring-pulsar-docs}reference/pulsar.html#tls-encryption[these steps] in the Spring for Apache Pulsar reference documentation to enable TLS encryption. + +For complete details on the client and authentication see the Spring for Apache Pulsar {spring-pulsar-docs}reference/pulsar.html#pulsar-client[reference documentation]. + + + +[[messaging.pulsar.connecting-reactive]] +=== Connecting to Pulsar Reactively +When the Reactive auto-configuration is activated, Spring Boot will auto-configure and register a `ReactivePulsarClient` bean. + +The `ReactivePulsarClient` adapts an instance of the previously described `PulsarClient`. +Therefore, follow the previous section to configure the `PulsarClient` used by the `ReactivePulsarClient`. + + + +[[messaging.pulsar.admin]] +=== Connecting to Pulsar Administration +Spring for Apache Pulsar's `PulsarAdministration` client is also auto-configured. + +By default, the application tries to connect to a local Pulsar instance at `\http://localhost:8080`. +This can be adjusted by setting the configprop:spring.pulsar.admin.service-url[] property to a different value in the form `(http|https)://:`. + +If you need more control over the configuration, consider registering one or more `PulsarAdminBuilderCustomizer` beans. + + +[[messaging.pulsar.admin.auth]] +==== Authentication +When accessing a Pulsar cluster that requires authentication, the admin client requires the same security configuration as the regular Pulsar client. +You can use the aforementioned <> by replacing `spring.pulsar.client.authentication` with `spring.pulsar.admin.authentication`. + +TIP: To create a topic on startup, add a bean of type `PulsarTopic`. +If the topic already exists, the bean is ignored. + + + +[[messaging.pulsar.sending]] +=== Sending a Message +Spring's `PulsarTemplate` is auto-configured, and you can use it to send messages, as shown in the following example: + +include::code:MyBean[] + +The `PulsarTemplate` relies on a `PulsarProducerFactory` to create the underlying Pulsar producer. +Spring Boot auto-configuration also provides this producer factory, which by default, caches the producers that it creates. +You can configure the producer factory and cache settings by specifying any of the `spring.pulsar.producer.\*` and `spring.pulsar.producer.cache.*` prefixed application properties. + +If you need more control over the producer factory configuration, consider registering one or more `ProducerBuilderCustomizer` beans. +These customizers are applied to all created producers. +You can also pass in a `ProducerBuilderCustomizer` when sending a message to only affect the current producer. + +If you need more control over the message being sent, you can pass in a `TypedMessageBuilderCustomizer` when sending a message. + + + +[[messaging.pulsar.sending-reactive]] +=== Sending a Message Reactively +When the Reactive auto-configuration is activated, Spring's `ReactivePulsarTemplate` is auto-configured, and you can use it to send messages, as shown in the following example: + +include::code:MyBean[] + +The `ReactivePulsarTemplate` relies on a `ReactivePulsarSenderFactory` to actually create the underlying sender. +Spring Boot auto-configuration also provides this sender factory, which by default, caches the producers that it creates. +You can configure the sender factory and cache settings by specifying any of the `spring.pulsar.producer.\*` and `spring.pulsar.producer.cache.*` prefixed application properties. + +If you need more control over the sender factory configuration, consider registering one or more `ReactiveMessageSenderBuilderCustomizer` beans. +These customizers are applied to all created senders. +You can also pass in a `ReactiveMessageSenderBuilderCustomizer` when sending a message to only affect the current sender. + +If you need more control over the message being sent, you can pass in a `MessageSpecBuilderCustomizer` when sending a message. + + + +[[messaging.pulsar.receiving]] +=== Receiving a Message +When the Apache Pulsar infrastructure is present, any bean can be annotated with `@PulsarListener` to create a listener endpoint. +The following component creates a listener endpoint on the `someTopic` topic: + +include::code:MyBean[] + +Spring Boot auto-configuration provides all the components necessary for `PulsarListener`, such as the `PulsarListenerContainerFactory` and the consumer factory it uses to construct the underlying Pulsar consumers. +You can configure these components by specifying any of the `spring.pulsar.listener.\*` and `spring.pulsar.consumer.*` prefixed application properties. + +If you need more control over the consumer factory configuration, consider registering one or more `ConsumerBuilderCustomizer` beans. +These customizers are applied to all consumers created by the factory, and therefore all `@PulsarListener` instances. +You can also customize a single listener by setting the `consumerCustomizer` attribute of the `@PulsarListener` annotation. + + + +[[messaging.pulsar.receiving-reactive]] +=== Receiving a Message Reactively +When the Apache Pulsar infrastructure is present and the Reactive auto-configuration is activated, any bean can be annotated with `@ReactivePulsarListener` to create a reactive listener endpoint. +The following component creates a reactive listener endpoint on the `someTopic` topic: + +include::code:MyBean[] + +Spring Boot auto-configuration provides all the components necessary for `ReactivePulsarListener`, such as the `ReactivePulsarListenerContainerFactory` and the consumer factory it uses to construct the underlying reactive Pulsar consumers. +You can configure these components by specifying any of the `spring.pulsar.listener.*` and `spring.pulsar.consumer.*` prefixed application properties. + +If you need more control over the consumer factory configuration, consider registering one or more `ReactiveMessageConsumerBuilderCustomizer` beans. +These customizers are applied to all consumers created by the factory, and therefore all `@ReactivePulsarListener` instances. +You can also customize a single listener by setting the `consumerCustomizer` attribute of the `@ReactivePulsarListener` annotation. + + + +[[messaging.pulsar.reading]] +=== Reading a Message +The Pulsar reader interface enables applications to manually manage cursors. +When you use a reader to connect to a topic you need to specify which message the reader begins reading from when it connects to a topic. + +When the Apache Pulsar infrastructure is present, any bean can be annotated with `@PulsarReader` to consume messages using a reader. +The following component creates a reader endpoint that starts reading messages from the beginning of the `someTopic` topic: + +include::code:MyBean[] + +The `@PulsarReader` relies on a `PulsarReaderFactory` to create the underlying Pulsar reader. +Spring Boot auto-configuration provides this reader factory which can be customized by setting any of the `spring.pulsar.reader.*` prefixed application properties. + +If you need more control over the reader factory configuration, consider registering one or more `ReaderBuilderCustomizer` beans. +These customizers are applied to all readers created by the factory, and therefore all `@PulsarReader` instances. +You can also customize a single listener by setting the `readerCustomizer` attribute of the `@PulsarReader` annotation. + + + +[[messaging.pulsar.reading-reactive]] +=== Reading a Message Reactively +When the Apache Pulsar infrastructure is present and the Reactive auto-configuration is activated, Spring's `ReactivePulsarReaderFactory` is provided, and you can use it to create a reader in order to read messages in a reactive fashion. +The following component creates a reader using the provided factory and reads a single message from 5 minutes ago from the `someTopic` topic: + +include::code:MyBean[] + +Spring Boot auto-configuration provides this reader factory which can be customized by setting any of the `spring.pulsar.reader.*` prefixed application properties. + +If you need more control over the reader factory configuration, consider passing in one or more `ReactiveMessageReaderBuilderCustomizer` instances when using the factory to create a reader. + +If you need more control over the reader factory configuration, consider registering one or more `ReactiveMessageReaderBuilderCustomizer` beans. +These customizers are applied to all created readers. +You can also pass one or more `ReactiveMessageReaderBuilderCustomizer` when creating a reader to only apply the customizations to the created reader. + +TIP: For more details on any of the above components and to discover other available features, see the Spring for Apache Pulsar {spring-pulsar-docs}[reference documentation]. + + + +[[messaging.pulsar.additional-properties]] +=== Additional Pulsar Properties +The properties supported by auto-configuration are shown in the <> section of the Appendix. +Note that, for the most part, these properties (hyphenated or camelCase) map directly to the Apache Pulsar configuration properties. +See the Apache Pulsar documentation for details. + +Only a subset of the properties supported by Pulsar are available directly through the `PulsarProperties` class. +If you wish to tune the auto-configured components with additional properties that are not directly supported, you can use the customizer supported by each aforementioned component. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/advanced-topics.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/advanced-topics.adoc index d4bc685f82ca..e1735662d94d 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/advanced-topics.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/native-image/advanced-topics.adoc @@ -159,7 +159,7 @@ You can then use `@ImportRuntimeHints` on any `@Configuration` class (for exampl If you have classes which need binding (mostly needed when serializing or deserializing JSON), you can use {spring-framework-docs}/core/aot.html#aot.hints.register-reflection-for-binding[`@RegisterReflectionForBinding`] on any bean. Most of the hints are automatically inferred, for example when accepting or returning data from a `@RestController` method. -But when you work with `WebClient` or `RestTemplate` directly, you might need to use `@RegisterReflectionForBinding`. +But when you work with `WebClient`, `RestClient` or `RestTemplate` directly, you might need to use `@RegisterReflectionForBinding`. [[native-image.advanced.custom-hints.testing]] ==== Testing custom hints diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/structuring-your-code.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/structuring-your-code.adoc index 896651f20550..406f22260865 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/structuring-your-code.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/using/structuring-your-code.adoc @@ -3,6 +3,8 @@ Spring Boot does not require any specific code layout to work. However, there are some best practices that help. +TIP: If you wish to enforce a structure based on domains, take a look at https://spring.io/projects/spring-modulith#overview[Spring Modulith]. + [[using.structuring-your-code.using-the-default-package]] diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/reactive.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/reactive.adoc index 33c52b1d743c..6e5a2d1b60bc 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/reactive.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/reactive.adoc @@ -122,6 +122,21 @@ It first looks for an `index.html` file in the configured static content locatio If one is not found, it then looks for an `index` template. If either is found, it is automatically used as the welcome page of the application. +This only acts as a fallback for actual index routes defined by the application. +The ordering is defined by the order of `HandlerMapping` beans which is by default the following: + +[cols="1,1"] +|=== +|`RouterFunctionMapping` +|Endpoints declared with `RouterFunction` beans + +|`RequestMappingHandlerMapping` +|Endpoints declared in `@Controller` beans + +|`RouterFunctionMapping` for the Welcome Page +|The welcome page support +|=== + [[web.reactive.webflux.template-engines]] @@ -174,10 +189,8 @@ include::code:MyErrorWebExceptionHandler[] For a more complete picture, you can also subclass `DefaultErrorWebExceptionHandler` directly and override specific methods. -In some cases, errors handled at the controller or handler function level are not recorded by the <>. -Applications can ensure that such exceptions are recorded with the request metrics by setting the handled exception as a request attribute: - -include::code:MyExceptionHandlingController[] +In some cases, errors handled at the controller level are not recorded by web observations or the <>. +Applications can ensure that such exceptions are recorded with the observations by {spring-framework-docs}/integration/observability.html#observability.http-server.reactive[setting the handled exception on the observation context]. @@ -232,9 +245,6 @@ When it does so, the orders shown in the following table will be used: |=== | Web Filter | Order -| `ServerHttpObservationFilter` (Micrometer Observability) -| `Ordered.HIGHEST_PRECEDENCE + 1` - | `WebFilterChainProxy` (Spring Security) | `-100` @@ -259,7 +269,7 @@ Usually, you would define the properties in your `application.properties` or `ap Common server settings include: -* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to `server.address`, and so on. +* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to (`server.address`), and so on. * Error management: Location of the error page (`server.error.path`) and so on. * <> * <> @@ -279,7 +289,7 @@ The following example shows programmatically setting the port: include::code:MyWebServerFactoryCustomizer[] -`JettyReactiveWebServerFactory`, `NettyReactiveWebServerFactory`, `TomcatReactiveWebServerFactory`, and `UndertowServletWebServerFactory` are dedicated variants of `ConfigurableReactiveWebServerFactory` that have additional customization setter methods for Jetty, Reactor Netty, Tomcat, and Undertow respectively. +`JettyReactiveWebServerFactory`, `NettyReactiveWebServerFactory`, `TomcatReactiveWebServerFactory`, and `UndertowReactiveWebServerFactory` are dedicated variants of `ConfigurableReactiveWebServerFactory` that have additional customization setter methods for Jetty, Reactor Netty, Tomcat, and Undertow respectively. The following example shows how to customize `NettyReactiveWebServerFactory` that provides access to Reactor Netty-specific configuration options: include::code:MyNettyWebServerFactoryCustomizer[] diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/servlet.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/servlet.adoc index 759afb22a087..fbc330fd0ce9 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/servlet.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/servlet.adoc @@ -198,6 +198,20 @@ It first looks for an `index.html` file in the configured static content locatio If one is not found, it then looks for an `index` template. If either is found, it is automatically used as the welcome page of the application. +This only acts as a fallback for actual index routes defined by the application. +The ordering is defined by the order of `HandlerMapping` beans which is by default the following: + +[cols="1,1"] +|=== +|`RouterFunctionMapping` +|Endpoints declared with `RouterFunction` beans + +|`RequestMappingHandlerMapping` +|Endpoints declared in `@Controller` beans + +|`WelcomePageHandlerMapping` +|The welcome page support +|=== [[web.servlet.spring-mvc.favicon]] @@ -341,10 +355,8 @@ include::code:MyControllerAdvice[] In the preceding example, if `MyException` is thrown by a controller defined in the same package as `SomeController`, a JSON representation of the `MyErrorBody` POJO is used instead of the `ErrorAttributes` representation. -In some cases, errors handled at the controller level are not recorded by the <>. -Applications can ensure that such exceptions are recorded with the request metrics by setting the handled exception as a request attribute: - -include::code:MyController[] +In some cases, errors handled at the controller level are not recorded by web observations or the <>. +Applications can ensure that such exceptions are recorded with the observations by {spring-framework-docs}/integration/observability.html#observability.http-server.servlet[setting the handled exception on the observation context]. @@ -561,7 +573,7 @@ Usually, you would define the properties in your `application.properties` or `ap Common server settings include: -* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to `server.address`, and so on. +* Network settings: Listen port for incoming HTTP requests (`server.port`), interface address to bind to (`server.address`), and so on. * Session settings: Whether the session is persistent (`server.servlet.session.persistent`), session timeout (`server.servlet.session.timeout`), location of session data (`server.servlet.session.store-dir`), and session-cookie configuration (`server.servlet.session.cookie.*`). * Error management: Location of the error page (`server.error.path`) and so on. * <> @@ -610,7 +622,7 @@ include::code:MySameSiteConfiguration[] The character encoding behavior of the embedded servlet container for request and response handling can be configured using the `server.servlet.encoding.*` configuration properties. When a request's `Accept-Language` header indicates a locale for the request it will be automatically mapped to a charset by the servlet container. -Each containers providers default locale to charset mappings and you should verify that they meet your application's needs. +Each container provides default locale to charset mappings and you should verify that they meet your application's needs. When they do not, use the configprop:server.servlet.encoding.mapping[] configuration property to customize the mappings, as shown in the following example: [source,yaml,indent=0,subs="verbatim",configprops,configblocks] diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc index 76f2e33abf3b..656edbeaadfa 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc @@ -54,9 +54,9 @@ If you wish to not expose information about the schema, you can disable introspe === GraphQL RuntimeWiring The GraphQL Java `RuntimeWiring.Builder` can be used to register custom scalar types, directives, type resolvers, `DataFetcher`, and more. You can declare `RuntimeWiringConfigurer` beans in your Spring config to get access to the `RuntimeWiring.Builder`. -Spring Boot detects such beans and adds them to the {spring-graphql-docs}#execution-graphqlsource[GraphQlSource builder]. +Spring Boot detects such beans and adds them to the {spring-graphql-docs}/#execution-graphqlsource[GraphQlSource builder]. -Typically, however, applications will not implement `DataFetcher` directly and will instead create {spring-graphql-docs}#controllers[annotated controllers]. +Typically, however, applications will not implement `DataFetcher` directly and will instead create {spring-graphql-docs}/#controllers[annotated controllers]. Spring Boot will automatically detect `@Controller` classes with annotated handler methods and register those as ``DataFetcher``s. Here's a sample implementation for our greeting query with a `@Controller` class: @@ -67,7 +67,7 @@ include::code:GreetingController[] [[web.graphql.data-query]] === Querydsl and QueryByExample Repositories Support Spring Data offers support for both Querydsl and QueryByExample repositories. -Spring GraphQL can {spring-graphql-docs}#data[configure Querydsl and QueryByExample repositories as `DataFetcher`]. +Spring GraphQL can {spring-graphql-docs}/#data[configure Querydsl and QueryByExample repositories as `DataFetcher`]. Spring Data repositories annotated with `@GraphQlRepository` and extending one of: @@ -98,7 +98,7 @@ The GraphQL WebSocket endpoint is off by default. To enable it: * For a WebFlux application, no additional dependency is required * For both, the configprop:spring.graphql.websocket.path[] application property must be set -Spring GraphQL provides a {spring-graphql-docs}#web-interception[Web Interception] model. +Spring GraphQL provides a {spring-graphql-docs}/#web-interception[Web Interception] model. This is quite useful for retrieving information from an HTTP request header and set it in the GraphQL context or fetching information from the same context and writing it to a response header. With Spring Boot, you can declare a `WebInterceptor` bean to have it registered with the web transport. @@ -138,7 +138,7 @@ include::code:RSocketGraphQlClientExample[tag=request] [[web.graphql.exception-handling]] === Exception Handling Spring GraphQL enables applications to register one or more Spring `DataFetcherExceptionResolver` components that are invoked sequentially. -The Exception must be resolved to a list of `graphql.GraphQLError` objects, see {spring-graphql-docs}#execution-exceptions[Spring GraphQL exception handling documentation]. +The Exception must be resolved to a list of `graphql.GraphQLError` objects, see {spring-graphql-docs}/#execution-exceptions[Spring GraphQL exception handling documentation]. Spring Boot will automatically detect `DataFetcherExceptionResolver` beans and register them with the `GraphQlSource.Builder`. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc index 2fc187ddba33..b893db247226 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-security.adoc @@ -34,10 +34,18 @@ You can provide a different `AuthenticationEventPublisher` by adding a bean for === MVC Security The default security configuration is implemented in `SecurityAutoConfiguration` and `UserDetailsServiceAutoConfiguration`. `SecurityAutoConfiguration` imports `SpringBootWebSecurityConfiguration` for web security and `UserDetailsServiceAutoConfiguration` configures authentication, which is also relevant in non-web applications. -To switch off the default web application security configuration completely or to combine multiple Spring Security components such as OAuth2 Client and Resource Server, add a bean of type `SecurityFilterChain` (doing so does not disable the `UserDetailsService` configuration or Actuator's security). +To switch off the default web application security configuration completely or to combine multiple Spring Security components such as OAuth2 Client and Resource Server, add a bean of type `SecurityFilterChain` (doing so does not disable the `UserDetailsService` configuration or Actuator's security). To also switch off the `UserDetailsService` configuration, you can add a bean of type `UserDetailsService`, `AuthenticationProvider`, or `AuthenticationManager`. +The auto-configuration of a `UserDetailsService` will also back off any of the following Spring Security modules is on the classpath: + +- `spring-security-oauth2-client` +- `spring-security-oauth2-resource-server` +- `spring-security-saml2-service-provider` + +To use `UserDetailsService` in addition to one or more of these dependencies, define your own `InMemoryUserDetailsManager` bean. + Access rules can be overridden by adding a custom `SecurityFilterChain` bean. Spring Boot provides convenience methods that can be used to override access rules for actuator endpoints and static resources. `EndpointRequest` can be used to create a `RequestMatcher` that is based on the configprop:management.endpoints.web.base-path[] property. @@ -50,10 +58,17 @@ Spring Boot provides convenience methods that can be used to override access rul Similar to Spring MVC applications, you can secure your WebFlux applications by adding the `spring-boot-starter-security` dependency. The default security configuration is implemented in `ReactiveSecurityAutoConfiguration` and `UserDetailsServiceAutoConfiguration`. `ReactiveSecurityAutoConfiguration` imports `WebFluxSecurityConfiguration` for web security and `UserDetailsServiceAutoConfiguration` configures authentication, which is also relevant in non-web applications. -To switch off the default web application security configuration completely, you can add a bean of type `WebFilterChainProxy` (doing so does not disable the `UserDetailsService` configuration or Actuator's security). +To switch off the default web application security configuration completely, you can add a bean of type `WebFilterChainProxy` (doing so does not disable the `UserDetailsService` configuration or Actuator's security). To also switch off the `UserDetailsService` configuration, you can add a bean of type `ReactiveUserDetailsService` or `ReactiveAuthenticationManager`. +The auto-configuration will also back off when any of the following Spring Security modules is on the classpath: + +- `spring-security-oauth2-client` +- `spring-security-oauth2-resource-server` + +To use `ReactiveUserDetailsService` in addition to one or more of these dependencies, define your own `MapReactiveUserDetailsService` bean. + Access rules and the use of multiple Spring Security components such as OAuth 2 Client and Resource Server can be configured by adding a custom `SecurityWebFilterChain` bean. Spring Boot provides convenience methods that can be used to override access rules for actuator endpoints and static resources. `EndpointRequest` can be used to create a `ServerWebExchangeMatcher` that is based on the configprop:management.endpoints.web.base-path[] property. diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/observability/preventingobservations/MyObservationPredicate.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/observability/preventingobservations/MyObservationPredicate.java new file mode 100644 index 000000000000..f065c8e2ca83 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/actuator/observability/preventingobservations/MyObservationPredicate.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.docs.actuator.observability.preventingobservations; + +import io.micrometer.observation.Observation.Context; +import io.micrometer.observation.ObservationPredicate; + +import org.springframework.stereotype.Component; + +@Component +class MyObservationPredicate implements ObservationPredicate { + + @Override + public boolean test(String name, Context context) { + return !name.contains("denied"); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.java new file mode 100644 index 000000000000..4441070de213 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-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.boot.docs.data.sql.jdbcclient; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + private final JdbcClient jdbcClient; + + public MyBean(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + public void doSomething() { + /* @chomp:line this.jdbcClient ... */ this.jdbcClient.sql("delete from customer").update(); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.java new file mode 100644 index 000000000000..8d8437fc8f5b --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.autoconfiguredrestclient; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@RestClientTest(RemoteVehicleDetailsService.class) +class MyRestClientServiceTests { + + @Autowired + private RemoteVehicleDetailsService service; + + @Autowired + private MockRestServiceServer server; + + @Test + void getVehicleDetailsWhenResultIsSuccessShouldReturnDetails() { + this.server.expect(requestTo("https://example.com/greet/details")) + .andRespond(withSuccess("hello", MediaType.TEXT_PLAIN)); + String greeting = this.service.callRestService(); + assertThat(greeting).isEqualTo("hello"); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.java similarity index 94% rename from spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.java rename to spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.java index 90a1a82c2e4e..fdf67b9621b3 100644 --- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.java +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -28,7 +28,7 @@ import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @RestClientTest(RemoteVehicleDetailsService.class) -class MyRestClientTests { +class MyRestTemplateServiceTests { @Autowired private RemoteVehicleDetailsService service; diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.java index 8b67fb450e23..b47d7dd48408 100644 --- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.java +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -22,9 +22,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.http.client.reactive.ReactorResourceFactory; @Configuration(proxyBeanMethods = false) public class MyReactorNettyClientConfiguration { diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/Details.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/Details.java new file mode 100644 index 000000000000..28c038969b28 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/Details.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient; + +public class Details { + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/MyService.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/MyService.java new file mode 100644 index 000000000000..98bd2049406c --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/MyService.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +@Service +public class MyService { + + private final RestClient restClient; + + public MyService(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.baseUrl("https://example.org").build(); + } + + public Details someRestCall(String name) { + return this.restClient.get().uri("/{name}/details", name).retrieve().body(Details.class); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.java new file mode 100644 index 000000000000..eb853cba5e36 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient.ssl; + +public class Details { + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.java new file mode 100644 index 000000000000..0fa7fa50cbba --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient.ssl; + +import org.springframework.boot.autoconfigure.web.client.RestClientSsl; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +@Service +public class MyService { + + private final RestClient restClient; + + public MyService(RestClient.Builder restClientBuilder, RestClientSsl ssl) { + this.restClient = restClientBuilder.baseUrl("https://example.org").apply(ssl.fromBundle("mybundle")).build(); + } + + public Details someRestCall(String name) { + return this.restClient.get().uri("/{name}/details", name).retrieve().body(Details.class); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.java new file mode 100644 index 000000000000..1b0bbdb7533b --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient.ssl.settings; + +public class Details { + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.java new file mode 100644 index 000000000000..8fef86df53e3 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient.ssl.settings; + +import java.time.Duration; + +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +@Service +public class MyService { + + private final RestClient restClient; + + public MyService(RestClient.Builder restClientBuilder, SslBundles sslBundles) { + ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS + .withReadTimeout(Duration.ofMinutes(2)) + .withSslBundle(sslBundles.getBundle("mybundle")); + ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings); + this.restClient = restClientBuilder.baseUrl("https://example.org").requestFactory(requestFactory).build(); + } + + public Details someRestCall(String name) { + return this.restClient.get().uri("/{name}/details", name).retrieve().body(Details.class); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java new file mode 100644 index 000000000000..f13cf6ec5451 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023-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.boot.docs.messaging.pulsar.reading; + +import org.springframework.pulsar.annotation.PulsarReader; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + @PulsarReader(topics = "someTopic", startMessageId = "earliest") + public void processMessage(String content) { + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java new file mode 100644 index 000000000000..c42145288d55 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023-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.boot.docs.messaging.pulsar.readingreactive; + +import java.time.Instant; +import java.util.List; + +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.reactive.client.api.StartAtSpec; +import reactor.core.publisher.Mono; + +import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + private final ReactivePulsarReaderFactory pulsarReaderFactory; + + public MyBean(ReactivePulsarReaderFactory pulsarReaderFactory) { + this.pulsarReaderFactory = pulsarReaderFactory; + } + + public void someMethod() { + ReactiveMessageReaderBuilderCustomizer readerBuilderCustomizer = (readerBuilder) -> readerBuilder + .topic("someTopic") + .startAtSpec(StartAtSpec.ofInstant(Instant.now().minusSeconds(5))); + Mono> message = this.pulsarReaderFactory + .createReader(Schema.STRING, List.of(readerBuilderCustomizer)) + .readOne(); + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java new file mode 100644 index 000000000000..103e4ac8d65a --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023-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.boot.docs.messaging.pulsar.receiving; + +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + @PulsarListener(topics = "someTopic") + public void processMessage(String content) { + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java new file mode 100644 index 000000000000..3dd9e8ffba98 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023-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.boot.docs.messaging.pulsar.receivingreactive; + +import reactor.core.publisher.Mono; + +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + @ReactivePulsarListener(topics = "someTopic") + public Mono processMessage(String content) { + // ... + return Mono.empty(); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java new file mode 100644 index 000000000000..7b6610b03e92 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023-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.boot.docs.messaging.pulsar.sending; + +import org.apache.pulsar.client.api.PulsarClientException; + +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + private final PulsarTemplate pulsarTemplate; + + public MyBean(PulsarTemplate pulsarTemplate) { + this.pulsarTemplate = pulsarTemplate; + } + + public void someMethod() throws PulsarClientException { + this.pulsarTemplate.send("someTopic", "Hello"); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java new file mode 100644 index 000000000000..1784f4ea8059 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java @@ -0,0 +1,35 @@ +/* + * Copyright 2023-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.boot.docs.messaging.pulsar.sendingreactive; + +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + private final ReactivePulsarTemplate pulsarTemplate; + + public MyBean(ReactivePulsarTemplate pulsarTemplate) { + this.pulsarTemplate = pulsarTemplate; + } + + public void someMethod() { + this.pulsarTemplate.send("someTopic", "Hello").subscribe(); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyExceptionHandlingController.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyExceptionHandlingController.java deleted file mode 100644 index b1913940a84e..000000000000 --- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyExceptionHandlingController.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2012-2021 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.docs.web.reactive.webflux.errorhandling; - -import org.springframework.boot.web.reactive.error.ErrorAttributes; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.reactive.result.view.Rendering; -import org.springframework.web.server.ServerWebExchange; - -@Controller -public class MyExceptionHandlingController { - - @GetMapping("/profile") - public Rendering userProfile() { - // ... - throw new IllegalStateException(); - } - - @ExceptionHandler(IllegalStateException.class) - public Rendering handleIllegalState(ServerWebExchange exchange, IllegalStateException exc) { - exchange.getAttributes().putIfAbsent(ErrorAttributes.ERROR_ATTRIBUTE, exc); - return Rendering.view("errorView").modelAttribute("message", exc.getMessage()).build(); - } - -} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/servlet/springmvc/errorhandling/MyController.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/servlet/springmvc/errorhandling/MyController.java deleted file mode 100644 index e93f0ba92b43..000000000000 --- a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/servlet/springmvc/errorhandling/MyController.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2012-2021 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.docs.web.servlet.springmvc.errorhandling; - -import jakarta.servlet.http.HttpServletRequest; - -import org.springframework.boot.web.servlet.error.ErrorAttributes; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.ExceptionHandler; - -@Controller -public class MyController { - - @ExceptionHandler(CustomException.class) - String handleCustomException(HttpServletRequest request, CustomException ex) { - request.setAttribute(ErrorAttributes.ERROR_ATTRIBUTE, ex); - return "errorView"; - } - -} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/elasticsearch/connectingusingspringdata/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/elasticsearch/connectingusingspringdata/MyBean.kt index 95442da426a2..001ee654ce00 100644 --- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/elasticsearch/connectingusingspringdata/MyBean.kt +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/elasticsearch/connectingusingspringdata/MyBean.kt @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -19,8 +19,7 @@ package org.springframework.boot.docs.data.nosql.elasticsearch.connectingusingsp import org.springframework.stereotype.Component @Component -@Suppress("DEPRECATION") -class MyBean(private val template: org.springframework.data.elasticsearch.client.erhlc.ElasticsearchRestTemplate ) { +class MyBean(private val template: org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate ) { // @fold:on // ... fun someMethod(id: String): Boolean { diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.kt new file mode 100644 index 000000000000..02cd7243bd7d --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/sql/jdbcclient/MyBean.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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.boot.docs.data.sql.jdbcclient + +import org.springframework.jdbc.core.simple.JdbcClient +import org.springframework.stereotype.Component + +@Component +class MyBean(private val jdbcClient: JdbcClient) { + + fun doSomething() { + jdbcClient.sql("delete from customer").update() + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.kt new file mode 100644 index 000000000000..9d6d561da02a --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientServiceTests.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2012-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.boot.docs.features.testing.springbootapplications.autoconfiguredrestclient + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest +import org.springframework.http.MediaType +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers +import org.springframework.test.web.client.response.MockRestResponseCreators + +@RestClientTest(RemoteVehicleDetailsService::class) +class MyRestClientServiceTests( + @Autowired val service: RemoteVehicleDetailsService, + @Autowired val server: MockRestServiceServer) { + + @Test + fun getVehicleDetailsWhenResultIsSuccessShouldReturnDetails() { + server.expect(MockRestRequestMatchers.requestTo("https://example.com/greet/details")) + .andRespond(MockRestResponseCreators.withSuccess("hello", MediaType.TEXT_PLAIN)) + val greeting = service.callRestService() + assertThat(greeting).isEqualTo("hello") + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.kt similarity index 94% rename from spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.kt rename to spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.kt index d0372b89ed22..5b51eae5c68d 100644 --- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestClientTests.kt +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/autoconfiguredrestclient/MyRestTemplateServiceTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -26,7 +26,7 @@ import org.springframework.test.web.client.match.MockRestRequestMatchers import org.springframework.test.web.client.response.MockRestResponseCreators @RestClientTest(RemoteVehicleDetailsService::class) -class MyRestClientTests( +class MyRestTemplateServiceTests( @Autowired val service: RemoteVehicleDetailsService, @Autowired val server: MockRestServiceServer) { diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/actuator/maphealthindicatorstometrics/MyHealthMetricsExportConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/actuator/maphealthindicatorstometrics/MyHealthMetricsExportConfiguration.kt index 950e30242ce4..38e8f94f4c07 100644 --- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/actuator/maphealthindicatorstometrics/MyHealthMetricsExportConfiguration.kt +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/actuator/maphealthindicatorstometrics/MyHealthMetricsExportConfiguration.kt @@ -32,13 +32,11 @@ class MyHealthMetricsExportConfiguration(registry: MeterRegistry, healthEndpoint }.strongReference(true).register(registry) } - private fun getStatusCode(health: HealthEndpoint): Int { - return when (health.health().status) { - Status.UP -> 3 - Status.OUT_OF_SERVICE -> 2 - Status.DOWN -> 1 - else -> 0 - } + private fun getStatusCode(health: HealthEndpoint) = when (health.health().status) { + Status.UP -> 3 + Status.OUT_OF_SERVICE -> 2 + Status.DOWN -> 1 + else -> 0 } } diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.kt index 85c140e3f078..1dac3ab5de0e 100644 --- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.kt +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/howto/httpclients/webclientreactornettycustomization/MyReactorNettyClientConfiguration.kt @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -22,7 +22,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.client.reactive.ClientHttpConnector import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.http.client.reactive.ReactorResourceFactory +import org.springframework.http.client.ReactorResourceFactory import reactor.netty.http.client.HttpClient @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/Details.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/Details.kt new file mode 100644 index 000000000000..219b0a9ffe29 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/Details.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient + +class Details diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/servlet/springmvc/errorhandling/MyController.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/MyService.kt similarity index 52% rename from spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/servlet/springmvc/errorhandling/MyController.kt rename to spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/MyService.kt index deead1e60da0..cb1854c03c5e 100644 --- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/servlet/springmvc/errorhandling/MyController.kt +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/MyService.kt @@ -14,20 +14,25 @@ * limitations under the License. */ -package org.springframework.boot.docs.web.servlet.springmvc.errorhandling +package org.springframework.boot.docs.io.restclient.restclient -import jakarta.servlet.http.HttpServletRequest -import org.springframework.boot.web.servlet.error.ErrorAttributes -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.boot.docs.io.restclient.restclient.ssl.Details +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient -@Controller -class MyController { +@Service +class MyService(restClientBuilder: RestClient.Builder) { - @ExceptionHandler(CustomException::class) - fun handleCustomException(request: HttpServletRequest, ex: CustomException?): String { - request.setAttribute(ErrorAttributes.ERROR_ATTRIBUTE, ex) - return "errorView" + private val restClient: RestClient + + init { + restClient = restClientBuilder.baseUrl("https://example.org").build() + } + + fun someRestCall(name: String?): Details { + return restClient.get().uri("/{name}/details", name) + .retrieve().body(Details::class.java)!! } } + diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.kt new file mode 100644 index 000000000000..613bbadb3fd7 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/Details.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient.ssl + +class Details diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.kt new file mode 100644 index 000000000000..220a44252e7f --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/MyService.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient.ssl + +import org.springframework.boot.autoconfigure.web.client.RestClientSsl +import org.springframework.boot.docs.io.restclient.restclient.ssl.settings.Details +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient + +@Service +class MyService(restClientBuilder: RestClient.Builder, ssl: RestClientSsl) { + + private val restClient: RestClient + + init { + restClient = restClientBuilder.baseUrl("https://example.org") + .apply(ssl.fromBundle("mybundle")).build() + } + + fun someRestCall(name: String?): Details { + return restClient.get().uri("/{name}/details", name) + .retrieve().body(Details::class.java)!! + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.kt new file mode 100644 index 000000000000..3a73e355e1c1 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/Details.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient.ssl.settings + +class Details diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.kt new file mode 100644 index 000000000000..e153262f8248 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/restclient/ssl/settings/MyService.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2012-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.boot.docs.io.restclient.restclient.ssl.settings + +import org.springframework.boot.ssl.SslBundles +import org.springframework.boot.web.client.ClientHttpRequestFactories +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient +import java.time.Duration + +@Service +class MyService(restClientBuilder: RestClient.Builder, sslBundles: SslBundles) { + + private val restClient: RestClient + + init { + val settings = ClientHttpRequestFactorySettings.DEFAULTS + .withReadTimeout(Duration.ofMinutes(2)) + .withSslBundle(sslBundles.getBundle("mybundle")) + val requestFactory = ClientHttpRequestFactories.get(settings) + restClient = restClientBuilder + .baseUrl("https://example.org") + .requestFactory(requestFactory).build() + } + + fun someRestCall(name: String?): Details { + return restClient.get().uri("/{name}/details", name).retrieve().body(Details::class.java)!! + } + +} + diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt new file mode 100644 index 000000000000..bb2936cc07d5 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.docs.messaging.pulsar.reading + +import org.springframework.pulsar.annotation.PulsarReader +import org.springframework.stereotype.Component + +@Suppress("UNUSED_PARAMETER") +@Component +class MyBean { + + @PulsarReader(topics = ["someTopic"], startMessageId = "earliest") + fun processMessage(content: String?) { + // ... + } + +} + diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt new file mode 100644 index 000000000000..7651be558113 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt @@ -0,0 +1,44 @@ +/* +* Copyright 2023-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.boot.docs.messaging.pulsar.readingreactive + +import org.apache.pulsar.client.api.Schema +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder +import org.apache.pulsar.reactive.client.api.StartAtSpec +import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer +import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory +import org.springframework.stereotype.Component +import java.time.Instant + +@Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE") +@Component +class MyBean(private val pulsarReaderFactory: ReactivePulsarReaderFactory) { + + fun someMethod() { + val readerBuilderCustomizer = ReactiveMessageReaderBuilderCustomizer { + readerBuilder: ReactiveMessageReaderBuilder -> + readerBuilder + .topic("someTopic") + .startAtSpec(StartAtSpec.ofInstant(Instant.now().minusSeconds(5))) + } + val message = pulsarReaderFactory + .createReader(Schema.STRING, listOf(readerBuilderCustomizer)) + .readOne() + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt new file mode 100644 index 000000000000..80ee6160ab43 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.docs.messaging.pulsar.receiving + +import org.springframework.pulsar.annotation.PulsarListener +import org.springframework.stereotype.Component + +@Suppress("UNUSED_PARAMETER") +@Component +class MyBean { + + @PulsarListener(topics = ["someTopic"]) + fun processMessage(content: String?) { + // ... + } + +} + diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt new file mode 100644 index 000000000000..6434ff849225 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023-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.boot.docs.messaging.pulsar.receivingreactive + +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +@Component +@Suppress("UNUSED_PARAMETER") +class MyBean { + + @ReactivePulsarListener(topics = ["someTopic"]) + fun processMessage(content: String?): Mono { + // ... + return Mono.empty() + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt new file mode 100644 index 000000000000..8bff88ff696d --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2012-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.boot.docs.messaging.pulsar.sending + +import org.apache.pulsar.client.api.PulsarClientException +import org.springframework.pulsar.core.PulsarTemplate +import org.springframework.stereotype.Component + +@Component +class MyBean(private val pulsarTemplate: PulsarTemplate) { + + @Throws(PulsarClientException::class) + fun someMethod() { + pulsarTemplate.send("someTopic", "Hello") + } + +} + diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt new file mode 100644 index 000000000000..3205912919ec --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023-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.boot.docs.messaging.pulsar.sendingreactive + +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate +import org.springframework.stereotype.Component + +@Component +class MyBean(private val pulsarTemplate: ReactivePulsarTemplate) { + + fun someMethod() { + pulsarTemplate.send("someTopic", "Hello").subscribe() + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyExceptionHandlingController.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyExceptionHandlingController.kt deleted file mode 100644 index 26dfa251d465..000000000000 --- a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/reactive/webflux/errorhandling/MyExceptionHandlingController.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2012-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.boot.docs.web.reactive.webflux.errorhandling - -import org.springframework.boot.web.reactive.error.ErrorAttributes -import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.reactive.result.view.Rendering -import org.springframework.web.server.ServerWebExchange - -@Suppress("UNUSED_PARAMETER") -@Controller -class MyExceptionHandlingController { - - @GetMapping("/profile") - fun userProfile(): Rendering { - // ... - throw IllegalStateException() - } - - @ExceptionHandler(IllegalStateException::class) - fun handleIllegalState(exchange: ServerWebExchange, exc: IllegalStateException): Rendering { - exchange.attributes.putIfAbsent(ErrorAttributes.ERROR_ATTRIBUTE, exc) - return Rendering.view("errorView").modelAttribute("message", exc.message ?: "").build() - } - -} diff --git a/spring-boot-project/spring-boot-parent/build.gradle b/spring-boot-project/spring-boot-parent/build.gradle index 48b8d88b3b6f..4484d617520c 100644 --- a/spring-boot-project/spring-boot-parent/build.gradle +++ b/spring-boot-project/spring-boot-parent/build.gradle @@ -30,11 +30,11 @@ bom { library("C3P0", "0.9.5.5") { group("com.mchange") { modules = [ - "c3p0" + "c3p0" ] } } - library("Commons Compress", "1.21") { + library("Commons Compress", "1.23.0") { group("org.apache.commons") { modules = [ "commons-compress" @@ -73,7 +73,7 @@ bom { ] } } - library("JNA", "5.7.0") { + library("JNA", "5.13.0") { group("net.java.dev.jna") { modules = [ "jna-platform" @@ -97,7 +97,7 @@ bom { ] } } - library("Maven Common Artifact Filters", "3.2.0") { + library("Maven Common Artifact Filters", "3.3.2") { group("org.apache.maven.shared") { modules = [ "maven-common-artifact-filters" @@ -111,7 +111,7 @@ bom { ] } } - library("Maven Plugin Tools", "3.6.0") { + library("Maven Plugin Tools", "3.9.0") { group("org.apache.maven.plugin-tools") { modules = [ "maven-plugin-annotations" @@ -131,13 +131,20 @@ bom { ] } } - library("Maven Shade Plugin", "3.2.4") { + library("Maven Shade Plugin", "3.5.0") { group("org.apache.maven.plugins") { modules = [ "maven-shade-plugin" ] } } + library("Micrometer Context Propagation", "1.0.5") { + group("io.micrometer") { + modules = [ + "context-propagation" + ] + } + } library("MockK", "1.13.5") { group("io.mockk") { modules = [ diff --git a/spring-boot-project/spring-boot-starters/README.adoc b/spring-boot-project/spring-boot-starters/README.adoc index cabb28016056..9c4e2a97af63 100644 --- a/spring-boot-project/spring-boot-starters/README.adoc +++ b/spring-boot-project/spring-boot-starters/README.adoc @@ -154,6 +154,9 @@ do as they were designed before this was clarified. | https://kogito.kie.org/[Kogito] | https://github.com/kiegroup/kogito-runtimes/tree/main/springboot/starters +| https://github.com/langchain4j/langchain4j[LangChain for Java] +| https://github.com/langchain4j/langchain4j/tree/main/langchain4j-spring-boot-starter + | https://www.liquigraph.org/[Liquigraph] | https://github.com/liquigraph/liquigraph diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-actuator/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-actuator/build.gradle index 3e2a471593a3..0ff356b4e0d9 100644 --- a/spring-boot-project/spring-boot-starters/spring-boot-starter-actuator/build.gradle +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-actuator/build.gradle @@ -8,5 +8,5 @@ dependencies { api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) api(project(":spring-boot-project:spring-boot-actuator-autoconfigure")) api("io.micrometer:micrometer-observation") - api("io.micrometer:micrometer-core") + api("io.micrometer:micrometer-jakarta9") } diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis-reactive/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis-reactive/build.gradle index d66f98dcfc40..f0c2d2bb4b22 100644 --- a/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis-reactive/build.gradle +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis-reactive/build.gradle @@ -5,5 +5,8 @@ plugins { description = "Starter for using Redis key-value data store with Spring Data Redis reactive and the Lettuce client" dependencies { - api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-data-redis")) + api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) + api("io.lettuce:lettuce-core") + api("io.projectreactor:reactor-core") + api("org.springframework.data:spring-data-redis") } diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis/build.gradle index 11f150cd1eec..b76ff7e34fe1 100644 --- a/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis/build.gradle +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-data-redis/build.gradle @@ -6,6 +6,6 @@ description = "Starter for using Redis key-value data store with Spring Data Red dependencies { api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) - api("org.springframework.data:spring-data-redis") api("io.lettuce:lettuce-core") + api("org.springframework.data:spring-data-redis") } diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-jetty/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-jetty/build.gradle index 5d677c3fe598..3050b1cd5c98 100644 --- a/spring-boot-project/spring-boot-starters/spring-boot-starter-jetty/build.gradle +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-jetty/build.gradle @@ -9,15 +9,8 @@ dependencies { api("jakarta.websocket:jakarta.websocket-api") api("jakarta.websocket:jakarta.websocket-client-api") api("org.apache.tomcat.embed:tomcat-embed-el") - api("org.eclipse.jetty:jetty-servlets") - api("org.eclipse.jetty:jetty-webapp") { - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - } - api("org.eclipse.jetty.websocket:websocket-jakarta-server") { - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-websocket-api") - } - api("org.eclipse.jetty.websocket:websocket-jetty-server") { - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - } + api("org.eclipse.jetty.ee10:jetty-ee10-servlets") + api("org.eclipse.jetty.ee10:jetty-ee10-webapp") + api("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-server") + api("org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server") } diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle index a57809d9535d..32d88db76916 100644 --- a/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle @@ -249,7 +249,7 @@ publishing.publications.withType(MavenPublication) { delegate.artifactId('spring-boot-maven-plugin') configuration { image { - delegate.builder("paketobuildpacks/builder-jammy-tiny:latest") + delegate.builder("paketobuildpacks/builder-jammy-tiny:latest"); env { delegate.BP_NATIVE_IMAGE("true") } diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle new file mode 100644 index 000000000000..22b23cf2aff3 --- /dev/null +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle @@ -0,0 +1,16 @@ +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for using Spring for Apache Pulsar Reactive" + +dependencies { + api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) + api("org.springframework.pulsar:spring-pulsar-reactive") +} + +checkRuntimeClasspathForConflicts { + ignore { name -> name.startsWith("org/bouncycastle/") || + name.matches("^org\\/apache\\/pulsar\\/.*\\/package-info.class\$") || + name.equals("findbugsExclude.xml") } +} diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle new file mode 100644 index 000000000000..87b4c4b6283b --- /dev/null +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle @@ -0,0 +1,16 @@ +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for using Spring for Apache Pulsar" + +dependencies { + api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) + api("org.springframework.pulsar:spring-pulsar") +} + +checkRuntimeClasspathForConflicts { + ignore { name -> name.startsWith("org/bouncycastle/") || + name.matches("^org\\/apache\\/pulsar\\/.*\\/package-info.class\$") || + name.equals("findbugsExclude.xml") } +} diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-test/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-test/build.gradle index f5a2cc091382..e98d71281766 100644 --- a/spring-boot-project/spring-boot-starters/spring-boot-starter-test/build.gradle +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-test/build.gradle @@ -12,6 +12,7 @@ dependencies { api("jakarta.xml.bind:jakarta.xml.bind-api") api("net.minidev:json-smart") api("org.assertj:assertj-core") + api("org.awaitility:awaitility") api("org.hamcrest:hamcrest") api("org.junit.jupiter:junit-jupiter") api("org.mockito:mockito-core") diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/OverrideAutoConfigurationContextCustomizerFactory.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/OverrideAutoConfigurationContextCustomizerFactory.java index 664e1e035d95..5b7ab2976b28 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/OverrideAutoConfigurationContextCustomizerFactory.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/OverrideAutoConfigurationContextCustomizerFactory.java @@ -44,7 +44,7 @@ public ContextCustomizer createContextCustomizer(Class testClass, } OverrideAutoConfiguration overrideAutoConfiguration = TestContextAnnotationUtils.findMergedAnnotation(testClass, OverrideAutoConfiguration.class); - boolean enabled = (overrideAutoConfiguration != null) ? overrideAutoConfiguration.enabled() : true; + boolean enabled = (overrideAutoConfiguration == null) || overrideAutoConfiguration.enabled(); return !enabled ? new DisableAutoConfigurationContextCustomizer() : null; } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/SpringBootDependencyInjectionTestExecutionListener.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/SpringBootDependencyInjectionTestExecutionListener.java deleted file mode 100644 index e3fbb4d61b96..000000000000 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/SpringBootDependencyInjectionTestExecutionListener.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2012-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.boot.test.autoconfigure; - -import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport; -import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportMessage; -import org.springframework.context.ApplicationContext; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.test.context.ApplicationContextFailureProcessor; -import org.springframework.test.context.TestContext; -import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; - -/** - * Since 3.0.0 this class has been replaced by - * {@link ConditionReportApplicationContextFailureProcessor} and is not used internally. - * - * @author Phillip Webb - * @since 1.4.1 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link ApplicationContextFailureProcessor} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -public class SpringBootDependencyInjectionTestExecutionListener extends DependencyInjectionTestExecutionListener { - - @Override - public void prepareTestInstance(TestContext testContext) throws Exception { - try { - super.prepareTestInstance(testContext); - } - catch (Exception ex) { - outputConditionEvaluationReport(testContext); - throw ex; - } - } - - private void outputConditionEvaluationReport(TestContext testContext) { - try { - ApplicationContext context = testContext.getApplicationContext(); - if (context instanceof ConfigurableApplicationContext configurableContext) { - ConditionEvaluationReport report = ConditionEvaluationReport.get(configurableContext.getBeanFactory()); - System.err.println(new ConditionEvaluationReportMessage(report)); - } - } - catch (Exception ex) { - // Allow original failure to be reported - } - } - -} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/AutoConfigureObservability.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/AutoConfigureObservability.java index fb16e8d1032e..746d0a747d90 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/AutoConfigureObservability.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/AutoConfigureObservability.java @@ -23,9 +23,15 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; + /** * Annotation that can be applied to a test class to enable auto-configuration for * observability. + *

+ * If this annotation is applied to a sliced test, an in-memory {@code MeterRegistry}, a + * no-op {@code Tracer} and an {@code ObservationRegistry} is added to the application + * context. * * @author Moritz Halbritter * @since 3.0.0 @@ -34,17 +40,18 @@ @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited +@ImportAutoConfiguration public @interface AutoConfigureObservability { /** - * Whether metrics should be enabled in the test. - * @return whether metrics should be enabled in the test + * Whether metrics should be reported to external systems in the test. + * @return whether metrics should be reported to external systems in the test */ boolean metrics() default true; /** - * Whether tracing should be enabled in the test. - * @return whether tracing should be enabled in the test + * Whether traces should be reported to external systems in the test. + * @return whether traces should be reported to external systems in the test */ boolean tracing() default true; diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactory.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactory.java index 918777baf1e7..86a003b6b6e0 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactory.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactory.java @@ -19,39 +19,19 @@ import java.util.List; import java.util.Objects; -import io.micrometer.tracing.Tracer; - -import org.springframework.aot.AotDetector; -import org.springframework.beans.BeansException; -import org.springframework.beans.factory.BeanFactory; -import org.springframework.beans.factory.BeanFactoryAware; -import org.springframework.beans.factory.BeanFactoryUtils; -import org.springframework.beans.factory.FactoryBean; -import org.springframework.beans.factory.ListableBeanFactory; -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.BeanDefinitionRegistryPostProcessor; -import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.ConfigurationClassPostProcessor; -import org.springframework.core.Ordered; import org.springframework.core.env.Environment; import org.springframework.test.context.ContextConfigurationAttributes; import org.springframework.test.context.ContextCustomizer; import org.springframework.test.context.ContextCustomizerFactory; import org.springframework.test.context.MergedContextConfiguration; import org.springframework.test.context.TestContextAnnotationUtils; -import org.springframework.util.ClassUtils; /** * {@link ContextCustomizerFactory} that globally disables metrics export and tracing in * tests. The behaviour can be controlled with {@link AutoConfigureObservability} on the * test class or via the {@value #AUTO_CONFIGURE_PROPERTY} property. - *

- * Registers {@link Tracer#NOOP} if tracing is disabled, micrometer-tracing is on the - * classpath, and the user hasn't supplied their own {@link Tracer}. * * @author Chris Bono * @author Moritz Halbritter @@ -87,7 +67,6 @@ public void customizeContext(ConfigurableApplicationContext context, } if (isTracingDisabled(context.getEnvironment())) { TestPropertyValues.of("management.tracing.enabled=false").applyTo(context); - registerNoopTracer(context); } } @@ -105,25 +84,6 @@ private boolean isTracingDisabled(Environment environment) { return !environment.getProperty(AUTO_CONFIGURE_PROPERTY, Boolean.class, false); } - private void registerNoopTracer(ConfigurableApplicationContext context) { - if (AotDetector.useGeneratedArtifacts()) { - return; - } - if (!ClassUtils.isPresent("io.micrometer.tracing.Tracer", context.getClassLoader())) { - return; - } - ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); - if (beanFactory instanceof BeanDefinitionRegistry registry) { - registerNoopTracer(registry); - } - } - - private void registerNoopTracer(BeanDefinitionRegistry registry) { - RootBeanDefinition definition = new RootBeanDefinition(NoopTracerRegistrar.class); - definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); - registry.registerBeanDefinition(NoopTracerRegistrar.class.getName(), definition); - } - @Override public boolean equals(Object o) { if (this == o) { @@ -143,54 +103,4 @@ public int hashCode() { } - /** - * {@link BeanDefinitionRegistryPostProcessor} that runs after the - * {@link ConfigurationClassPostProcessor} and adds a {@link Tracer} bean definition - * when a {@link Tracer} hasn't already been registered. - */ - static class NoopTracerRegistrar implements BeanDefinitionRegistryPostProcessor, Ordered, BeanFactoryAware { - - private BeanFactory beanFactory; - - @Override - public void setBeanFactory(BeanFactory beanFactory) throws BeansException { - this.beanFactory = beanFactory; - } - - @Override - public int getOrder() { - return Ordered.LOWEST_PRECEDENCE; - } - - @Override - public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { - if (AotDetector.useGeneratedArtifacts()) { - return; - } - if (BeanFactoryUtils.beanNamesForTypeIncludingAncestors((ListableBeanFactory) this.beanFactory, - Tracer.class, false, false).length == 0) { - registry.registerBeanDefinition("noopTracer", new RootBeanDefinition(NoopTracerFactoryBean.class)); - } - } - - @Override - public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { - } - - } - - static class NoopTracerFactoryBean implements FactoryBean { - - @Override - public Tracer getObject() { - return Tracer.NOOP; - } - - @Override - public Class getObjectType() { - return Tracer.class; - } - - } - } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/AnnotationCustomizableTypeExcludeFilter.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/AnnotationCustomizableTypeExcludeFilter.java index af92b8a1541c..807c4a7d9cf8 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/AnnotationCustomizableTypeExcludeFilter.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/filter/AnnotationCustomizableTypeExcludeFilter.java @@ -18,6 +18,8 @@ import java.io.IOException; import java.lang.annotation.Annotation; +import java.util.Arrays; +import java.util.Objects; import java.util.Set; import org.springframework.beans.factory.BeanClassLoaderAware; @@ -135,11 +137,11 @@ public int hashCode() { int result = 0; result = prime * result + Boolean.hashCode(hasAnnotation()); for (FilterType filterType : FilterType.values()) { - result = prime * result + ObjectUtils.nullSafeHashCode(getFilters(filterType)); + result = prime * result + Arrays.hashCode(getFilters(filterType)); } result = prime * result + Boolean.hashCode(isUseDefaultFilters()); - result = prime * result + ObjectUtils.nullSafeHashCode(getDefaultIncludes()); - result = prime * result + ObjectUtils.nullSafeHashCode(getComponentIncludes()); + result = prime * result + Objects.hashCode(getDefaultIncludes()); + result = prime * result + Objects.hashCode(getComponentIncludes()); return result; } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServer.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServer.java index 6e9e8def9672..43e1aac70e07 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServer.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -25,20 +25,26 @@ import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.test.autoconfigure.properties.PropertyMapping; +import org.springframework.boot.test.web.client.MockServerRestClientCustomizer; import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; /** * Annotation that can be applied to a test class to enable and configure * auto-configuration of a single {@link MockRestServiceServer}. Only useful when a single - * call is made to {@link RestTemplateBuilder}. If multiple - * {@link org.springframework.web.client.RestTemplate RestTemplates} are in use, inject + * call is made to {@link RestTemplateBuilder} or {@link RestClient.Builder}. If multiple + * {@link org.springframework.web.client.RestTemplate RestTemplates} or + * {@link org.springframework.web.client.RestClient RestClients} are in use, inject a * {@link MockServerRestTemplateCustomizer} and use * {@link MockServerRestTemplateCustomizer#getServer(org.springframework.web.client.RestTemplate) - * getServer(RestTemplate)} or bind a {@link MockRestServiceServer} directly. + * getServer(RestTemplate)}, or inject a {@link MockServerRestClientCustomizer} and use + * {@link MockServerRestClientCustomizer#getServer(org.springframework.web.client.RestClient.Builder) + * * getServer(RestClient.Builder)}, or bind a {@link MockRestServiceServer} directly. * * @author Phillip Webb + * @author Scott Frederick * @since 1.4.0 * @see MockServerRestTemplateCustomizer */ @@ -51,7 +57,8 @@ public @interface AutoConfigureMockRestServiceServer { /** - * If {@link MockServerRestTemplateCustomizer} should be enabled and + * If {@link MockServerRestTemplateCustomizer} and + * {@link MockServerRestClientCustomizer} should be enabled and * {@link MockRestServiceServer} beans should be registered. Defaults to {@code true} * @return if mock support is enabled */ diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.java index aa76319d00a6..a0ada9a512c4 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,12 @@ import java.io.IOException; import java.lang.reflect.Constructor; import java.time.Duration; +import java.util.Collection; import java.util.Map; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.web.client.MockServerRestClientCustomizer; import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.http.client.ClientHttpRequest; @@ -33,12 +35,14 @@ import org.springframework.test.web.client.RequestMatcher; import org.springframework.test.web.client.ResponseActions; import org.springframework.util.Assert; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; /** * Auto-configuration for {@link MockRestServiceServer} support. * * @author Phillip Webb + * @author Scott Frederick * @since 1.4.0 * @see AutoConfigureMockRestServiceServer */ @@ -52,21 +56,29 @@ public MockServerRestTemplateCustomizer mockServerRestTemplateCustomizer() { } @Bean - public MockRestServiceServer mockRestServiceServer(MockServerRestTemplateCustomizer customizer) { + public MockServerRestClientCustomizer mockServerRestClientCustomizer() { + return new MockServerRestClientCustomizer(); + } + + @Bean + public MockRestServiceServer mockRestServiceServer(MockServerRestTemplateCustomizer restTemplateCustomizer, + MockServerRestClientCustomizer restClientCustomizer) { try { - return createDeferredMockRestServiceServer(customizer); + return createDeferredMockRestServiceServer(restTemplateCustomizer, restClientCustomizer); } catch (Exception ex) { throw new IllegalStateException(ex); } } - private MockRestServiceServer createDeferredMockRestServiceServer(MockServerRestTemplateCustomizer customizer) - throws Exception { + private MockRestServiceServer createDeferredMockRestServiceServer( + MockServerRestTemplateCustomizer restTemplateCustomizer, + MockServerRestClientCustomizer restClientCustomizer) throws Exception { Constructor constructor = MockRestServiceServer.class .getDeclaredConstructor(RequestExpectationManager.class); constructor.setAccessible(true); - return constructor.newInstance(new DeferredRequestExpectationManager(customizer)); + return constructor + .newInstance(new DeferredRequestExpectationManager(restTemplateCustomizer, restClientCustomizer)); } /** @@ -77,10 +89,14 @@ private MockRestServiceServer createDeferredMockRestServiceServer(MockServerRest */ private static class DeferredRequestExpectationManager implements RequestExpectationManager { - private final MockServerRestTemplateCustomizer customizer; + private final MockServerRestTemplateCustomizer restTemplateCustomizer; + + private final MockServerRestClientCustomizer restClientCustomizer; - DeferredRequestExpectationManager(MockServerRestTemplateCustomizer customizer) { - this.customizer = customizer; + DeferredRequestExpectationManager(MockServerRestTemplateCustomizer restTemplateCustomizer, + MockServerRestClientCustomizer restClientCustomizer) { + this.restTemplateCustomizer = restTemplateCustomizer; + this.restClientCustomizer = restClientCustomizer; } @Override @@ -105,19 +121,37 @@ public void verify(Duration timeout) { @Override public void reset() { - Map expectationManagers = this.customizer.getExpectationManagers(); + resetExpectations(this.restTemplateCustomizer.getExpectationManagers().values()); + resetExpectations(this.restClientCustomizer.getExpectationManagers().values()); + } + + private void resetExpectations(Collection expectationManagers) { if (expectationManagers.size() == 1) { - getDelegate().reset(); + expectationManagers.iterator().next().reset(); } } private RequestExpectationManager getDelegate() { - Map expectationManagers = this.customizer.getExpectationManagers(); - Assert.state(!expectationManagers.isEmpty(), "Unable to use auto-configured MockRestServiceServer since " - + "MockServerRestTemplateCustomizer has not been bound to a RestTemplate"); - Assert.state(expectationManagers.size() == 1, "Unable to use auto-configured MockRestServiceServer since " - + "MockServerRestTemplateCustomizer has been bound to more than one RestTemplate"); - return expectationManagers.values().iterator().next(); + Map restTemplateExpectationManagers = this.restTemplateCustomizer + .getExpectationManagers(); + Map restClientExpectationManagers = this.restClientCustomizer + .getExpectationManagers(); + boolean neitherBound = restTemplateExpectationManagers.isEmpty() && restClientExpectationManagers.isEmpty(); + boolean bothBound = !restTemplateExpectationManagers.isEmpty() && !restClientExpectationManagers.isEmpty(); + Assert.state(!neitherBound, "Unable to use auto-configured MockRestServiceServer since " + + "a mock server customizer has not been bound to a RestTemplate or RestClient"); + Assert.state(!bothBound, "Unable to use auto-configured MockRestServiceServer since " + + "mock server customizers have been bound to both a RestTemplate and a RestClient"); + if (!restTemplateExpectationManagers.isEmpty()) { + Assert.state(restTemplateExpectationManagers.size() == 1, + "Unable to use auto-configured MockRestServiceServer since " + + "MockServerRestTemplateCustomizer has been bound to more than one RestTemplate"); + return restTemplateExpectationManagers.values().iterator().next(); + } + Assert.state(restClientExpectationManagers.size() == 1, + "Unable to use auto-configured MockRestServiceServer since " + + "MockServerRestClientCustomizer has been bound to more than one RestClient"); + return restClientExpectationManagers.values().iterator().next(); } } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTest.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTest.java index eba5ed69d003..8a93b1b42226 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTest.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -38,11 +38,12 @@ import org.springframework.test.context.BootstrapWith; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; /** * Annotation for a Spring rest client test that focuses only on beans - * that use {@link RestTemplateBuilder}. + * that use {@link RestTemplateBuilder} or {@link RestClient.Builder}. *

* Using this annotation will disable full auto-configuration and instead apply only * configuration relevant to rest client tests (i.e. Jackson or GSON auto-configuration diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/MockMvcAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/MockMvcAutoConfiguration.java index 6d429eb4fae3..9e944e4fad3f 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/MockMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/MockMvcAutoConfiguration.java @@ -131,6 +131,11 @@ private static class MockMvcDispatcherServletCustomizer implements DispatcherSer public void customize(DispatcherServlet dispatcherServlet) { dispatcherServlet.setDispatchOptionsRequest(this.webMvcProperties.isDispatchOptionsRequest()); dispatcherServlet.setDispatchTraceRequest(this.webMvcProperties.isDispatchTraceRequest()); + configureThrowExceptionIfNoHandlerFound(dispatcherServlet); + } + + @SuppressWarnings({ "deprecation", "removal" }) + private void configureThrowExceptionIfNoHandlerFound(DispatcherServlet dispatcherServlet) { dispatcherServlet .setThrowExceptionIfNoHandlerFound(this.webMvcProperties.isThrowExceptionIfNoHandlerFound()); } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizer.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizer.java index 24922c81f211..2fecdadb1f40 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizer.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizer.java @@ -116,12 +116,8 @@ private void addFilters(ConfigurableMockMvcBuilder builder) { private void addFilter(ConfigurableMockMvcBuilder builder, AbstractFilterRegistrationBean registration) { Filter filter = registration.getFilter(); Collection urls = registration.getUrlPatterns(); - if (urls.isEmpty()) { - builder.addFilters(filter); - } - else { - builder.addFilter(filter, StringUtils.toStringArray(urls)); - } + builder.addFilter(filter, registration.getFilterName(), registration.getInitParameters(), + registration.determineDispatcherTypes(), StringUtils.toStringArray(urls)); } public void setAddFilters(boolean addFilters) { diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizer.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizer.java index 5a7420207dec..e0c178db3e2b 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizer.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/web/servlet/WebDriverContextCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,7 @@ public boolean equals(Object obj) { if (obj == this) { return true; } - if (obj == null || obj.getClass() != getClass()) { - return false; - } - return true; + return obj != null && obj.getClass() == getClass(); } @Override diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability.imports new file mode 100644 index 000000000000..af374669f52f --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability.imports @@ -0,0 +1,13 @@ +# AutoConfigureObservability auto-configuration imports + +# Observation +org.springframework.boot.actuate.autoconfigure.observation.ObservationAutoConfiguration + +# Metrics +org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration +org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration +org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration + +# Tracing +org.springframework.boot.actuate.autoconfigure.tracing.NoopTracerAutoConfiguration +org.springframework.boot.actuate.autoconfigure.tracing.MicrometerTracingAutoConfiguration diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.jdbc.AutoConfigureDataJdbc.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.jdbc.AutoConfigureDataJdbc.imports index 2fc2a1e54ad0..eb4b3faada1b 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.jdbc.AutoConfigureDataJdbc.imports +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.data.jdbc.AutoConfigureDataJdbc.imports @@ -3,6 +3,7 @@ org.springframework.boot.autoconfigure.data.jdbc.JdbcRepositoriesAutoConfigurati org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration +org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc.imports index 5aa3ad940cf4..480dcff0e7c1 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc.imports +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc.imports @@ -2,6 +2,7 @@ org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration +org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa.imports index ba99875857e7..83465fdeba7e 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa.imports +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa.imports @@ -3,6 +3,7 @@ org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration +org.springframework.boot.autoconfigure.jdbc.JdbcClientAutoConfiguration org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient.imports index c789b0b5c278..cad2d5fb96fe 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient.imports +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient.imports @@ -6,4 +6,5 @@ org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration org.springframework.boot.autoconfigure.jsonb.JsonbAutoConfiguration org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration +org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration \ No newline at end of file diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsMissingIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsMissingIntegrationTests.java deleted file mode 100644 index ff4e924a993a..000000000000 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsMissingIntegrationTests.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2012-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.boot.test.autoconfigure.actuate.metrics; - -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import io.micrometer.prometheus.PrometheusMeterRegistry; -import org.junit.jupiter.api.Test; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; -import org.springframework.core.env.Environment; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Integration test to verify behaviour when - * {@link AutoConfigureMetrics @AutoConfigureMetrics} is not present on the test class. - * - * @author Chris Bono - */ -@SpringBootTest -class AutoConfigureMetricsMissingIntegrationTests { - - @Test - void customizerRunsAndOnlyEnablesSimpleMeterRegistryWhenNoAnnotationPresent( - @Autowired ApplicationContext applicationContext) { - assertThat(applicationContext.getBean(MeterRegistry.class)).isInstanceOf(SimpleMeterRegistry.class); - assertThat(applicationContext.getBeansOfType(PrometheusMeterRegistry.class)).isEmpty(); - } - - @Test - void customizerRunsAndSetsExclusionPropertiesWhenNoAnnotationPresent(@Autowired Environment environment) { - assertThat(environment.getProperty("management.defaults.metrics.export.enabled")).isEqualTo("false"); - assertThat(environment.getProperty("management.simple.metrics.export.enabled")).isEqualTo("true"); - } - -} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsPresentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsPresentIntegrationTests.java deleted file mode 100644 index dfdef02bdb2c..000000000000 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsPresentIntegrationTests.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2012-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.boot.test.autoconfigure.actuate.metrics; - -import io.micrometer.prometheus.PrometheusMeterRegistry; -import org.junit.jupiter.api.Test; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; -import org.springframework.core.env.Environment; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Integration test to verify behaviour when - * {@link AutoConfigureMetrics @AutoConfigureMetrics} is present on the test class. - * - * @author Chris Bono - */ -@SuppressWarnings("removal") -@SpringBootTest -@AutoConfigureMetrics -@Deprecated(since = "3.0.0", forRemoval = true) -class AutoConfigureMetricsPresentIntegrationTests { - - @Test - void customizerDoesNotDisableAvailableMeterRegistriesWhenAnnotationPresent( - @Autowired ApplicationContext applicationContext) { - assertThat(applicationContext.getBeansOfType(PrometheusMeterRegistry.class)).hasSize(1); - } - - @Test - void customizerDoesNotSetExclusionPropertiesWhenAnnotationPresent(@Autowired Environment environment) { - assertThat(environment.containsProperty("management.defaults.metrics.export.enabled")).isFalse(); - assertThat(environment.containsProperty("management.simple.metrics.export.enabled")).isFalse(); - } - -} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsSpringBootApplication.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsSpringBootApplication.java deleted file mode 100644 index 31c699a44b8d..000000000000 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/metrics/AutoConfigureMetricsSpringBootApplication.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2012-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.boot.test.autoconfigure.actuate.metrics; - -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; -import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration; -import org.springframework.boot.autoconfigure.mongo.MongoReactiveAutoConfiguration; - -/** - * Example {@link SpringBootApplication @SpringBootApplication} for use with - * {@link AutoConfigureMetrics @AutoConfigureMetrics} tests. - * - * @author Chris Bono - */ -@SpringBootConfiguration -@EnableAutoConfiguration(exclude = { CassandraAutoConfiguration.class, MongoAutoConfiguration.class, - MongoReactiveAutoConfiguration.class }) -class AutoConfigureMetricsSpringBootApplication { - -} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/AutoConfigureObservabilitySlicedIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/AutoConfigureObservabilitySlicedIntegrationTests.java new file mode 100644 index 000000000000..4b73857da05a --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/AutoConfigureObservabilitySlicedIntegrationTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-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.boot.test.autoconfigure.actuate.observability; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.tracing.Tracer; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.ApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AutoConfigureObservability} when used on a sliced test. + * + * @author Moritz Halbritter + */ +@WebMvcTest +@AutoConfigureObservability +class AutoConfigureObservabilitySlicedIntegrationTests { + + @Autowired + private ApplicationContext context; + + @Test + void shouldHaveTracer() { + assertThat(this.context.getBean(Tracer.class)).isEqualTo(Tracer.NOOP); + } + + @Test + void shouldHaveMeterRegistry() { + assertThat(this.context.getBean(MeterRegistry.class)).isInstanceOf(SimpleMeterRegistry.class); + } + + @Test + void shouldHaveObservationRegistry() { + assertThat(this.context.getBean(ObservationRegistry.class)).isNotNull(); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactoryTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactoryTests.java index d8e46424c52e..c8604bf53071 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactoryTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/actuate/observability/ObservabilityContextCustomizerFactoryTests.java @@ -18,22 +18,15 @@ import java.util.Collections; -import io.micrometer.tracing.Tracer; import org.junit.jupiter.api.Test; -import org.springframework.boot.context.annotation.UserConfigurations; -import org.springframework.boot.test.context.FilteredClassLoader; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.context.support.GenericApplicationContext; import org.springframework.mock.env.MockEnvironment; import org.springframework.test.context.ContextCustomizer; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; /** * Tests for {@link AutoConfigureObservability} and @@ -82,59 +75,6 @@ void shouldEnableBothWhenAnnotated() { assertThatTracingIsEnabled(context); } - @Test - void shouldRegisterNoopTracerIfTracingIsDisabled() { - ContextCustomizer customizer = createContextCustomizer(NoAnnotation.class); - ConfigurableApplicationContext context = new GenericApplicationContext(); - applyCustomizerToContext(customizer, context); - context.refresh(); - Tracer tracer = context.getBean(Tracer.class); - assertThat(tracer).isNotNull(); - assertThat(tracer.nextSpan().isNoop()).isTrue(); - } - - @Test - void shouldNotRegisterNoopTracerIfTracingIsEnabled() { - ContextCustomizer customizer = createContextCustomizer(WithAnnotation.class); - ConfigurableApplicationContext context = new GenericApplicationContext(); - applyCustomizerToContext(customizer, context); - context.refresh(); - assertThat(context.getBeanProvider(Tracer.class).getIfAvailable()).as("Tracer bean").isNull(); - } - - @Test - void shouldNotRegisterNoopTracerIfMicrometerTracingIsNotPresent() throws Exception { - try (FilteredClassLoader filteredClassLoader = new FilteredClassLoader("io.micrometer.tracing")) { - ContextCustomizer customizer = createContextCustomizer(NoAnnotation.class); - new ApplicationContextRunner().withClassLoader(filteredClassLoader) - .withInitializer(applyCustomizer(customizer)) - .run((context) -> { - assertThat(context).doesNotHaveBean(Tracer.class); - assertThatMetricsAreDisabled(context); - assertThatTracingIsDisabled(context); - }); - } - } - - @Test - void shouldBackOffOnCustomTracer() { - ContextCustomizer customizer = createContextCustomizer(NoAnnotation.class); - new ApplicationContextRunner().withConfiguration(UserConfigurations.of(CustomTracer.class)) - .withInitializer(applyCustomizer(customizer)) - .run((context) -> { - assertThat(context).hasSingleBean(Tracer.class); - assertThat(context).hasBean("customTracer"); - }); - } - - @Test - void shouldNotRunIfAotIsEnabled() { - ContextCustomizer customizer = createContextCustomizer(NoAnnotation.class); - new ApplicationContextRunner().withSystemProperties("spring.aot.enabled:true") - .withInitializer(applyCustomizer(customizer)) - .run((context) -> assertThat(context).doesNotHaveBean(Tracer.class)); - } - @Test void notEquals() { ContextCustomizer customizer1 = createContextCustomizer(OnlyMetrics.class); @@ -256,14 +196,4 @@ static class WithDisabledAnnotation { } - @Configuration(proxyBeanMethods = false) - static class CustomTracer { - - @Bean - Tracer customTracer() { - return mock(Tracer.class); - } - - } - } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestIntegrationTests.java index f505a6fa2353..33e9d853b084 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestIntegrationTests.java @@ -98,7 +98,7 @@ static class KeyspaceTestConfiguration { CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) { try (CqlSession session = cqlSessionBuilder.build()) { session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test" - + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); + + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); } return cqlSessionBuilder.withKeyspace("boot_test").build(); } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestWithIncludeFilterIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestWithIncludeFilterIntegrationTests.java index 05741e8a91de..7766fa22b3ff 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestWithIncludeFilterIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/data/cassandra/DataCassandraTestWithIncludeFilterIntegrationTests.java @@ -77,7 +77,7 @@ static class KeyspaceTestConfiguration { CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) { try (CqlSession session = cqlSessionBuilder.build()) { session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test" - + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); + + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); } return cqlSessionBuilder.withKeyspace("boot_test").build(); } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleEntityRowMapper.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleEntityRowMapper.java new file mode 100644 index 000000000000..5897f26716bc --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleEntityRowMapper.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-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.boot.test.autoconfigure.jdbc; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.springframework.jdbc.core.RowMapper; + +/** + * @author Stephane Nicoll + */ +class ExampleEntityRowMapper implements RowMapper { + + @Override + public ExampleEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + int id = rs.getInt("id"); + String name = rs.getString("name"); + return new ExampleEntity(id, name); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleJdbcClientRepository.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleJdbcClientRepository.java new file mode 100644 index 000000000000..a104017292ab --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleJdbcClientRepository.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023-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.boot.test.autoconfigure.jdbc; + +import java.util.Collection; + +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * Example repository used with {@link JdbcClient JdbcClient} and + * {@link JdbcTest @JdbcTest} tests. + * + * @author Yanming Zhou + */ +class ExampleJdbcClientRepository { + + private static final ExampleEntityRowMapper ROW_MAPPER = new ExampleEntityRowMapper(); + + private final JdbcClient jdbcClient; + + ExampleJdbcClientRepository(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + void save(ExampleEntity entity) { + this.jdbcClient.sql("insert into example (id, name) values (:id, :name)") + .param("id", entity.getId()) + .param("name", entity.getName()) + .update(); + } + + ExampleEntity findById(int id) { + return this.jdbcClient.sql("select id, name from example where id = :id") + .param("id", id) + .query(ROW_MAPPER) + .single(); + } + + Collection findAll() { + return this.jdbcClient.sql("select id, name from example").query(ROW_MAPPER).list(); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleRepository.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleRepository.java index bc238839cb3d..346a524e7de1 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleRepository.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/ExampleRepository.java @@ -16,14 +16,11 @@ package org.springframework.boot.test.autoconfigure.jdbc; -import java.sql.ResultSet; -import java.sql.SQLException; import java.util.Collection; import jakarta.transaction.Transactional; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; /** @@ -55,15 +52,4 @@ Collection findAll() { return this.jdbcTemplate.query("select id, name from example", ROW_MAPPER); } - static class ExampleEntityRowMapper implements RowMapper { - - @Override - public ExampleEntity mapRow(ResultSet rs, int rowNum) throws SQLException { - int id = rs.getInt("id"); - String name = rs.getString("name"); - return new ExampleEntity(id, name); - } - - } - } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/JdbcTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/JdbcTestIntegrationTests.java index 9cf7781d3b99..44d6fc2ed241 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/JdbcTestIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/jdbc/JdbcTestIntegrationTests.java @@ -29,6 +29,7 @@ import org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration; import org.springframework.context.ApplicationContext; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.test.context.TestPropertySource; import static org.assertj.core.api.Assertions.assertThat; @@ -39,12 +40,16 @@ * Integration tests for {@link JdbcTest @JdbcTest}. * * @author Stephane Nicoll + * @author Yanming Zhou */ @JdbcTest @TestPropertySource( properties = "spring.sql.init.schemaLocations=classpath:org/springframework/boot/test/autoconfigure/jdbc/schema.sql") class JdbcTestIntegrationTests { + @Autowired + private JdbcClient jdbcClient; + @Autowired private JdbcTemplate jdbcTemplate; @@ -54,13 +59,30 @@ class JdbcTestIntegrationTests { @Autowired private ApplicationContext applicationContext; + @Test + void testJdbcClient() { + ExampleJdbcClientRepository repository = new ExampleJdbcClientRepository(this.jdbcClient); + repository.save(new ExampleEntity(1, "John")); + ExampleEntity entity = repository.findById(1); + assertThat(entity.getId()).isOne(); + assertThat(entity.getName()).isEqualTo("John"); + Collection entities = repository.findAll(); + assertThat(entities).hasSize(1); + entity = entities.iterator().next(); + assertThat(entity.getId()).isOne(); + assertThat(entity.getName()).isEqualTo("John"); + } + @Test void testJdbcTemplate() { ExampleRepository repository = new ExampleRepository(this.jdbcTemplate); repository.save(new ExampleEntity(1, "John")); + ExampleEntity entity = repository.findById(1); + assertThat(entity.getId()).isOne(); + assertThat(entity.getName()).isEqualTo("John"); Collection entities = repository.findAll(); assertThat(entities).hasSize(1); - ExampleEntity entity = entities.iterator().next(); + entity = entities.iterator().next(); assertThat(entity.getId()).isOne(); assertThat(entity.getName()).isEqualTo("John"); } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTestIntegrationTests.java index 277677ab0f9a..f1f84cf1fe81 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTestIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTestIntegrationTests.java @@ -28,6 +28,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.data.repository.config.BootstrapMode; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.simple.JdbcClient; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -39,6 +40,7 @@ * @author Phillip Webb * @author Andy Wilkinson * @author Scott Frederick + * @author Yanming Zhou */ @DataJpaTest class DataJpaTestIntegrationTests { @@ -46,6 +48,9 @@ class DataJpaTestIntegrationTests { @Autowired private TestEntityManager entities; + @Autowired + private JdbcClient jdbcClient; + @Autowired private JdbcTemplate jdbcTemplate; @@ -72,8 +77,10 @@ void testEntityManagerPersistAndGetId() { Long id = this.entities.persistAndGetId(new ExampleEntity("spring", "123"), Long.class); this.entities.flush(); assertThat(id).isNotNull(); - String reference = this.jdbcTemplate.queryForObject("SELECT REFERENCE FROM EXAMPLE_ENTITY WHERE ID = ?", - String.class, id); + String sql = "SELECT REFERENCE FROM EXAMPLE_ENTITY WHERE ID = ?"; + String reference = this.jdbcTemplate.queryForObject(sql, String.class, id); + assertThat(reference).isEqualTo("123"); + reference = this.jdbcClient.sql(sql).param(id).query(String.class).single(); assertThat(reference).isEqualTo("123"); } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClientService.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClientService.java new file mode 100644 index 000000000000..29d459100f41 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClientService.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-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.boot.test.autoconfigure.web.client; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.Builder; + +/** + * A second example web client used with {@link RestClientTest @RestClientTest} tests. + * + * @author Scott Frederick + */ +@Service +public class AnotherExampleRestClientService { + + private final Builder builder; + + private final RestClient restClient; + + public AnotherExampleRestClientService(RestClient.Builder builder) { + this.builder = builder; + this.restClient = builder.baseUrl("https://example.com").build(); + } + + protected Builder getRestClientBuilder() { + return this.builder; + } + + public String test() { + return this.restClient.get().uri("/test").retrieve().toEntity(String.class).getBody(); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClient.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestTemplateService.java similarity index 87% rename from spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClient.java rename to spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestTemplateService.java index e7452ca904e4..a781d7cc80cc 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestClient.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AnotherExampleRestTemplateService.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -26,11 +26,11 @@ * @author Phillip Webb */ @Service -public class AnotherExampleRestClient { +public class AnotherExampleRestTemplateService { private final RestTemplate restTemplate; - public AnotherExampleRestClient(RestTemplateBuilder builder) { + public AnotherExampleRestTemplateService(RestTemplateBuilder builder) { this.restTemplate = builder.rootUri("https://example.com").build(); } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests.java index 68c31ee5bd0e..fc55c4ad9974 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests.java @@ -20,6 +20,7 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.MockServerRestClientCustomizer; import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer; import org.springframework.context.ApplicationContext; @@ -43,6 +44,8 @@ class AutoConfigureMockRestServiceServerEnabledFalseIntegrationTests { void mockServerRestTemplateCustomizerShouldNotBeRegistered() { assertThatExceptionOfType(NoSuchBeanDefinitionException.class) .isThrownBy(() -> this.applicationContext.getBean(MockServerRestTemplateCustomizer.class)); + assertThatExceptionOfType(NoSuchBeanDefinitionException.class) + .isThrownBy(() -> this.applicationContext.getBean(MockServerRestClientCustomizer.class)); } } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestClientIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestClientIntegrationTests.java new file mode 100644 index 000000000000..7e7b5adcbd46 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestClientIntegrationTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-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.boot.test.autoconfigure.web.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.Builder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for + * {@link AutoConfigureMockRestServiceServer @AutoConfigureMockRestServiceServer} with a + * {@link RestClient} configured with a base URL. + * + * @author Scott Frederick + */ +@SpringBootTest +@AutoConfigureMockRestServiceServer +class AutoConfigureMockRestServiceServerWithRestClientIntegrationTests { + + @Autowired + private RestClient restClient; + + @Autowired + private MockRestServiceServer server; + + @Test + void mockServerExpectationsAreMatched() { + this.server.expect(requestTo("/rest/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML)); + ResponseEntity entity = this.restClient.get().uri("/test").retrieve().toEntity(String.class); + assertThat(entity.getBody()).isEqualTo("hello"); + } + + @EnableAutoConfiguration(exclude = CassandraAutoConfiguration.class) + @Configuration(proxyBeanMethods = false) + static class RootUriConfiguration { + + @Bean + RestClient restClient(Builder restClientBuilder) { + return restClientBuilder.baseUrl("/rest").build(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRootUriIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestTemplateRootUriIntegrationTests.java similarity index 95% rename from spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRootUriIntegrationTests.java rename to spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestTemplateRootUriIntegrationTests.java index 2ac9b41f4144..fabdbf9602ad 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRootUriIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/AutoConfigureMockRestServiceServerWithRestTemplateRootUriIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -44,7 +44,7 @@ */ @SpringBootTest @AutoConfigureMockRestServiceServer -class AutoConfigureMockRestServiceServerWithRootUriIntegrationTests { +class AutoConfigureMockRestServiceServerWithRestTemplateRootUriIntegrationTests { @Autowired private RestTemplate restTemplate; diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClientService.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClientService.java new file mode 100644 index 000000000000..f4e4c922a860 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClientService.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-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.boot.test.autoconfigure.web.client; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.Builder; + +/** + * Example web client using {@code RestClient} with {@link RestClientTest @RestClientTest} + * tests. + * + * @author Scott Frederick + */ +@Service +public class ExampleRestClientService { + + private final Builder builder; + + private final RestClient restClient; + + public ExampleRestClientService(RestClient.Builder builder) { + this.builder = builder; + this.restClient = builder.baseUrl("https://example.com").build(); + } + + protected Builder getRestClientBuilder() { + return this.builder; + } + + public String test() { + return this.restClient.get().uri("/test").retrieve().toEntity(String.class).getBody(); + } + + public void testPostWithBody(String body) { + this.restClient.post().uri("/test").body(body).retrieve().toBodilessEntity(); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClient.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestTemplateService.java similarity index 82% rename from spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClient.java rename to spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestTemplateService.java index 06b923f7acbf..95f55211753c 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestClient.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/ExampleRestTemplateService.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -21,16 +21,17 @@ import org.springframework.web.client.RestTemplate; /** - * Example web client used with {@link RestClientTest @RestClientTest} tests. + * Example web client using {@code RestTemplate} with + * {@link RestClientTest @RestClientTest} tests. * * @author Phillip Webb */ @Service -public class ExampleRestClient { +public class ExampleRestTemplateService { private final RestTemplate restTemplate; - public ExampleRestClient(RestTemplateBuilder builder) { + public ExampleRestTemplateService(RestTemplateBuilder builder) { this.restTemplate = builder.rootUri("https://example.com").build(); } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestNoComponentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestNoComponentIntegrationTests.java index 6ed3e63e46d5..3da4654a6b8d 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestNoComponentIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestNoComponentIntegrationTests.java @@ -50,7 +50,7 @@ class RestClientTestNoComponentIntegrationTests { @Test void exampleRestClientIsNotInjected() { assertThatExceptionOfType(NoSuchBeanDefinitionException.class) - .isThrownBy(() -> this.applicationContext.getBean(ExampleRestClient.class)); + .isThrownBy(() -> this.applicationContext.getBean(ExampleRestTemplateService.class)); } @Test @@ -61,7 +61,7 @@ void examplePropertiesIsNotInjected() { @Test void manuallyCreateBean() { - ExampleRestClient client = new ExampleRestClient(this.restTemplateBuilder); + ExampleRestTemplateService client = new ExampleRestTemplateService(this.restTemplateBuilder); this.server.expect(requestTo("/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML)); assertThat(client.test()).isEqualTo("hello"); } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientIntegrationTests.java new file mode 100644 index 000000000000..098f6456a9b5 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientIntegrationTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-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.boot.test.autoconfigure.web.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link RestClientTest @RestClientTest} with a {@link RestClient}. + * + * @author Scott Frederick + */ +@RestClientTest(ExampleRestClientService.class) +class RestClientTestRestClientIntegrationTests { + + @Autowired + private MockRestServiceServer server; + + @Autowired + private ExampleRestClientService client; + + @Test + void mockServerCall1() { + this.server.expect(requestTo(uri("/test"))).andRespond(withSuccess("1", MediaType.TEXT_HTML)); + assertThat(this.client.test()).isEqualTo("1"); + } + + @Test + void mockServerCall2() { + this.server.expect(requestTo(uri("/test"))).andRespond(withSuccess("2", MediaType.TEXT_HTML)); + assertThat(this.client.test()).isEqualTo("2"); + } + + @Test + void mockServerCallWithContent() { + this.server.expect(requestTo(uri("/test"))) + .andExpect(content().string("test")) + .andRespond(withSuccess("1", MediaType.TEXT_HTML)); + this.client.testPostWithBody("test"); + } + + private static String uri(String path) { + return "https://example.com" + path; + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientTwoComponentsIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientTwoComponentsIntegrationTests.java new file mode 100644 index 000000000000..15695607fe99 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestClientTwoComponentsIntegrationTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-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.boot.test.autoconfigure.web.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.MockServerRestClientCustomizer; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link RestClientTest @RestClientTest} with two {@code RestClient} clients. + * + * @author Phillip Webb + * @author Scott Frederick + */ +@RestClientTest({ ExampleRestClientService.class, AnotherExampleRestClientService.class }) +class RestClientTestRestClientTwoComponentsIntegrationTests { + + @Autowired + private ExampleRestClientService client1; + + @Autowired + private AnotherExampleRestClientService client2; + + @Autowired + private MockServerRestClientCustomizer customizer; + + @Autowired + private MockRestServiceServer server; + + @Test + void serverShouldNotWork() { + assertThatIllegalStateException().isThrownBy( + () -> this.server.expect(requestTo(uri("/test"))).andRespond(withSuccess("hello", MediaType.TEXT_HTML))) + .withMessageContaining("Unable to use auto-configured"); + } + + @Test + void client1RestCallViaCustomizer() { + this.customizer.getServer(this.client1.getRestClientBuilder()) + .expect(requestTo(uri("/test"))) + .andRespond(withSuccess("hello", MediaType.TEXT_HTML)); + assertThat(this.client1.test()).isEqualTo("hello"); + } + + @Test + void client2RestCallViaCustomizer() { + this.customizer.getServer(this.client2.getRestClientBuilder()) + .expect(requestTo(uri("/test"))) + .andRespond(withSuccess("there", MediaType.TEXT_HTML)); + assertThat(this.client2.test()).isEqualTo("there"); + } + + private static String uri(String path) { + return "https://example.com" + path; + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateAndRestClientTogetherIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateAndRestClientTogetherIntegrationTests.java new file mode 100644 index 000000000000..92edda4ef598 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateAndRestClientTogetherIntegrationTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.test.autoconfigure.web.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.MockServerRestClientCustomizer; +import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link RestClientTest @RestClientTest} with a {@code RestTemplate} and a + * {@code RestClient} clients. + * + * @author Scott Frederick + */ +@RestClientTest({ ExampleRestTemplateService.class, ExampleRestClientService.class }) +class RestClientTestRestTemplateAndRestClientTogetherIntegrationTests { + + @Autowired + private ExampleRestTemplateService restTemplateClient; + + @Autowired + private ExampleRestClientService restClientClient; + + @Autowired + private MockServerRestTemplateCustomizer templateCustomizer; + + @Autowired + private MockServerRestClientCustomizer clientCustomizer; + + @Autowired + private MockRestServiceServer server; + + @Test + void serverShouldNotWork() { + assertThatIllegalStateException().isThrownBy( + () -> this.server.expect(requestTo(uri("/test"))).andRespond(withSuccess("hello", MediaType.TEXT_HTML))) + .withMessageContaining("Unable to use auto-configured"); + } + + @Test + void restTemplateClientRestCallViaCustomizer() { + this.templateCustomizer.getServer() + .expect(requestTo("/test")) + .andRespond(withSuccess("hello", MediaType.TEXT_HTML)); + assertThat(this.restTemplateClient.test()).isEqualTo("hello"); + } + + @Test + void restClientClientRestCallViaCustomizer() { + this.clientCustomizer.getServer() + .expect(requestTo(uri("/test"))) + .andRespond(withSuccess("there", MediaType.TEXT_HTML)); + assertThat(this.restClientClient.test()).isEqualTo("there"); + } + + private static String uri(String path) { + return "https://example.com" + path; + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientRestIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateIntegrationTests.java similarity index 93% rename from spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientRestIntegrationTests.java rename to spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateIntegrationTests.java index 3966b197e28c..7f92f7550070 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientRestIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateIntegrationTests.java @@ -32,14 +32,14 @@ * * @author Phillip Webb */ -@RestClientTest(ExampleRestClient.class) -class RestClientRestIntegrationTests { +@RestClientTest(ExampleRestTemplateService.class) +class RestClientTestRestTemplateIntegrationTests { @Autowired private MockRestServiceServer server; @Autowired - private ExampleRestClient client; + private ExampleRestTemplateService client; @Test void mockServerCall1() { diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestTwoComponentsIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateTwoComponentsIntegrationTests.java similarity index 86% rename from spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestTwoComponentsIntegrationTests.java rename to spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateTwoComponentsIntegrationTests.java index 08e00159c61a..c106df3776fc 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestTwoComponentsIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestRestTemplateTwoComponentsIntegrationTests.java @@ -29,18 +29,18 @@ import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; /** - * Tests for {@link RestClientTest @RestClientTest} with two clients. + * Tests for {@link RestClientTest @RestClientTest} with two {@code RestTemplate} clients. * * @author Phillip Webb */ -@RestClientTest({ ExampleRestClient.class, AnotherExampleRestClient.class }) -class RestClientTestTwoComponentsIntegrationTests { +@RestClientTest({ ExampleRestTemplateService.class, AnotherExampleRestTemplateService.class }) +class RestClientTestRestTemplateTwoComponentsIntegrationTests { @Autowired - private ExampleRestClient client1; + private ExampleRestTemplateService client1; @Autowired - private AnotherExampleRestClient client2; + private AnotherExampleRestTemplateService client2; @Autowired private MockServerRestTemplateCustomizer customizer; diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestClientComponentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestClientComponentIntegrationTests.java new file mode 100644 index 000000000000..954ecfbb1bb5 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestClientComponentIntegrationTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-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.boot.test.autoconfigure.web.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link RestClientTest @RestClientTest} with a single client using + * {@code RestClient}. + * + * @author Phillip Webb + * @author Scott Frederick + */ +@RestClientTest(ExampleRestClientService.class) +class RestClientTestWithRestClientComponentIntegrationTests { + + @Autowired + private MockRestServiceServer server; + + @Autowired + private ExampleRestClientService client; + + @Test + void mockServerCall() { + this.server.expect(requestTo("https://example.com/test")).andRespond(withSuccess("hello", MediaType.TEXT_HTML)); + assertThat(this.client.test()).isEqualTo("hello"); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithComponentIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestTemplateComponentIntegrationTests.java similarity index 85% rename from spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithComponentIntegrationTests.java rename to spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestTemplateComponentIntegrationTests.java index 09ba31463e93..f495765f1823 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithComponentIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithRestTemplateComponentIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -27,18 +27,19 @@ import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; /** - * Tests for {@link RestClientTest @RestClientTest} with a single client. + * Tests for {@link RestClientTest @RestClientTest} with a single client using + * {@code RestTemplate}. * * @author Phillip Webb */ -@RestClientTest(ExampleRestClient.class) -class RestClientTestWithComponentIntegrationTests { +@RestClientTest(ExampleRestTemplateService.class) +class RestClientTestWithRestTemplateComponentIntegrationTests { @Autowired private MockRestServiceServer server; @Autowired - private ExampleRestClient client; + private ExampleRestTemplateService client; @Test void mockServerCall() { diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithoutJacksonIntegrationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithoutJacksonIntegrationTests.java index b6aa993467b5..250f50f5d183 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithoutJacksonIntegrationTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/client/RestClientTestWithoutJacksonIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -34,14 +34,14 @@ * @author Andy Wilkinson */ @ClassPathExclusions("jackson-*.jar") -@RestClientTest(ExampleRestClient.class) +@RestClientTest(ExampleRestTemplateService.class) class RestClientTestWithoutJacksonIntegrationTests { @Autowired private MockRestServiceServer server; @Autowired - private ExampleRestClient client; + private ExampleRestTemplateService client; @Test void restClientTestCanBeUsedWhenJacksonIsNotOnTheClassPath() { diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizerTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizerTests.java index 16c8126396cd..87c5cee78d66 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizerTests.java +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/web/servlet/SpringBootMockMvcBuilderCustomizerTests.java @@ -18,16 +18,22 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import jakarta.servlet.DispatcherType; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.FilterConfig; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServlet; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.SpringBootMockMvcBuilderCustomizer.DeferredLinesWriter; @@ -37,11 +43,12 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.mock.web.MockServletContext; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; /** * Tests for {@link SpringBootMockMvcBuilderCustomizer}. @@ -50,10 +57,7 @@ */ class SpringBootMockMvcBuilderCustomizerTests { - private SpringBootMockMvcBuilderCustomizer customizer; - @Test - @SuppressWarnings("unchecked") void customizeShouldAddFilters() { AnnotationConfigServletWebApplicationContext context = new AnnotationConfigServletWebApplicationContext(); MockServletContext servletContext = new MockServletContext(); @@ -61,14 +65,20 @@ void customizeShouldAddFilters() { context.register(ServletConfiguration.class, FilterConfiguration.class); context.refresh(); DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(context); - this.customizer = new SpringBootMockMvcBuilderCustomizer(context); - this.customizer.customize(builder); - FilterRegistrationBean registrationBean = (FilterRegistrationBean) context - .getBean("filterRegistrationBean"); - Filter testFilter = (Filter) context.getBean("testFilter"); - Filter otherTestFilter = registrationBean.getFilter(); - List filters = (List) ReflectionTestUtils.getField(builder, "filters"); - assertThat(filters).containsExactlyInAnyOrder(testFilter, otherTestFilter); + SpringBootMockMvcBuilderCustomizer customizer = new SpringBootMockMvcBuilderCustomizer(context); + customizer.customize(builder); + FilterRegistrationBean registrationBean = (FilterRegistrationBean) context.getBean("otherTestFilter"); + TestFilter testFilter = context.getBean("testFilter", TestFilter.class); + OtherTestFilter otherTestFilter = (OtherTestFilter) registrationBean.getFilter(); + assertThat(builder).extracting("filters", as(InstanceOfAssertFactories.LIST)) + .extracting("delegate", "dispatcherTypes") + .containsExactlyInAnyOrder(tuple(testFilter, EnumSet.of(DispatcherType.REQUEST)), + tuple(otherTestFilter, EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR))); + builder.build(); + assertThat(testFilter.filterName).isEqualTo("testFilter"); + assertThat(testFilter.initParams).isEmpty(); + assertThat(otherTestFilter.filterName).isEqualTo("otherTestFilter"); + assertThat(otherTestFilter.initParams).isEqualTo(Map.of("a", "alpha", "b", "bravo")); } @Test @@ -94,7 +104,7 @@ void whenCalledInParallelDeferredLinesWriterSeparatesOutputByThread() throws Exc }); thread.start(); } - latch.await(60, TimeUnit.SECONDS); + assertThat(latch.await(60, TimeUnit.SECONDS)).isTrue(); assertThat(delegate.allWritten).hasSize(10000); assertThat(delegate.allWritten) @@ -131,8 +141,12 @@ TestServlet testServlet() { static class FilterConfiguration { @Bean - FilterRegistrationBean filterRegistrationBean() { - return new FilterRegistrationBean<>(new OtherTestFilter()); + FilterRegistrationBean otherTestFilter() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>( + new OtherTestFilter()); + filterRegistrationBean.setInitParameters(Map.of("a", "alpha", "b", "bravo")); + filterRegistrationBean.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR)); + return filterRegistrationBean; } @Bean @@ -148,9 +162,15 @@ static class TestServlet extends HttpServlet { static class TestFilter implements Filter { + private String filterName; + + private Map initParams = new HashMap<>(); + @Override public void init(FilterConfig filterConfig) { - + this.filterName = filterConfig.getFilterName(); + Collections.list(filterConfig.getInitParameterNames()) + .forEach((name) -> this.initParams.put(name, filterConfig.getInitParameter(name))); } @Override @@ -167,9 +187,15 @@ public void destroy() { static class OtherTestFilter implements Filter { + private String filterName; + + private Map initParams = new HashMap<>(); + @Override public void init(FilterConfig filterConfig) { - + this.filterName = filterConfig.getFilterName(); + Collections.list(filterConfig.getInitParameterNames()) + .forEach((name) -> this.initParams.put(name, filterConfig.getInitParameter(name))); } @Override diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/DefaultTestExecutionListenersPostProcessor.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/DefaultTestExecutionListenersPostProcessor.java deleted file mode 100644 index 695c3f460f6b..000000000000 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/DefaultTestExecutionListenersPostProcessor.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2012-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.boot.test.context; - -import java.util.List; - -import org.springframework.test.context.ApplicationContextFailureProcessor; -import org.springframework.test.context.TestExecutionListener; - -/** - * Callback interface trigger from {@link SpringBootTestContextBootstrapper} that can be - * used to post-process the list of default {@link TestExecutionListener - * TestExecutionListeners} to be used by a test. Can be used to add or remove existing - * listeners. - * - * @author Phillip Webb - * @since 1.4.1 - * @deprecated since 3.0.0 removal in 3.2.0 in favor of - * {@link ApplicationContextFailureProcessor} - */ -@FunctionalInterface -@Deprecated(since = "3.0.0", forRemoval = true) -public interface DefaultTestExecutionListenersPostProcessor { - - /** - * Post process the list of default {@link TestExecutionListener listeners} to be - * used. - * @param listeners the source listeners - * @return the actual listeners that should be used - * @since 3.0.0 - */ - List postProcessDefaultTestExecutionListeners(List listeners); - -} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java index 4ec97096d575..2c2aa3d73c8f 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/ImportsContextCustomizer.java @@ -17,7 +17,6 @@ package org.springframework.boot.test.context; import java.lang.annotation.Annotation; -import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Constructor; import java.util.Collections; import java.util.HashSet; @@ -44,10 +43,9 @@ import org.springframework.context.annotation.ImportSelector; import org.springframework.context.support.AbstractApplicationContext; import org.springframework.core.Ordered; -import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.annotation.AnnotationFilter; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; -import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; import org.springframework.core.annotation.Order; import org.springframework.core.style.ToStringCreator; import org.springframework.core.type.AnnotationMetadata; @@ -61,6 +59,7 @@ * * @author Phillip Webb * @author Andy Wilkinson + * @author Laurent Martelli * @see ImportsContextCustomizerFactory */ class ImportsContextCustomizer implements ContextCustomizer { @@ -220,79 +219,48 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t */ static class ContextCustomizerKey { - private static final Class[] NO_IMPORTS = {}; - private static final Set ANNOTATION_FILTERS; - static { - Set filters = new HashSet<>(); - filters.add(new JavaLangAnnotationFilter()); - filters.add(new KotlinAnnotationFilter()); - filters.add(new SpockAnnotationFilter()); - filters.add(new JUnitAnnotationFilter()); - ANNOTATION_FILTERS = Collections.unmodifiableSet(filters); + Set annotationFilters = new LinkedHashSet<>(); + annotationFilters.add(AnnotationFilter.PLAIN); + annotationFilters.add("kotlin.Metadata"::equals); + annotationFilters.add(AnnotationFilter.packages("kotlin.annotation")); + annotationFilters.add(AnnotationFilter.packages("org.spockframework", "spock")); + annotationFilters.add(AnnotationFilter.packages("org.junit")); + ANNOTATION_FILTERS = Collections.unmodifiableSet(annotationFilters); } - private final Set key; ContextCustomizerKey(Class testClass) { - Set annotations = new HashSet<>(); - Set> seen = new HashSet<>(); - collectClassAnnotations(testClass, annotations, seen); + MergedAnnotations annotations = MergedAnnotations.search(MergedAnnotations.SearchStrategy.TYPE_HIERARCHY) + .withAnnotationFilter(this::isFilteredAnnotation) + .from(testClass); Set determinedImports = determineImports(annotations, testClass); if (determinedImports == null) { - this.key = Collections.unmodifiableSet(annotations); + this.key = Collections.unmodifiableSet(synthesize(annotations)); } else { Set key = new HashSet<>(); key.addAll(determinedImports); Set componentScanning = annotations.stream() - .filter(ComponentScan.class::isInstance) + .filter((annotation) -> annotation.getType().equals(ComponentScan.class)) + .map(MergedAnnotation::synthesize) .collect(Collectors.toSet()); key.addAll(componentScanning); this.key = Collections.unmodifiableSet(key); } } - private void collectClassAnnotations(Class classType, Set annotations, Set> seen) { - if (seen.add(classType)) { - collectElementAnnotations(classType, annotations, seen); - for (Class interfaceType : classType.getInterfaces()) { - collectClassAnnotations(interfaceType, annotations, seen); - } - if (classType.getSuperclass() != null) { - collectClassAnnotations(classType.getSuperclass(), annotations, seen); - } - } - } - - private void collectElementAnnotations(AnnotatedElement element, Set annotations, - Set> seen) { - for (MergedAnnotation mergedAnnotation : MergedAnnotations.from(element, - SearchStrategy.DIRECT)) { - Annotation annotation = mergedAnnotation.synthesize(); - if (!isIgnoredAnnotation(annotation)) { - annotations.add(annotation); - collectClassAnnotations(annotation.annotationType(), annotations, seen); - } - } - } - - private boolean isIgnoredAnnotation(Annotation annotation) { - for (AnnotationFilter annotationFilter : ANNOTATION_FILTERS) { - if (annotationFilter.isIgnored(annotation)) { - return true; - } - } - return false; + private boolean isFilteredAnnotation(String typeName) { + return ANNOTATION_FILTERS.stream().anyMatch((filter) -> filter.matches(typeName)); } - private Set determineImports(Set annotations, Class testClass) { + private Set determineImports(MergedAnnotations annotations, Class testClass) { Set determinedImports = new LinkedHashSet<>(); - AnnotationMetadata testClassMetadata = AnnotationMetadata.introspect(testClass); - for (Annotation annotation : annotations) { - for (Class source : getImports(annotation)) { - Set determinedSourceImports = determineImports(source, testClassMetadata); + AnnotationMetadata metadata = AnnotationMetadata.introspect(testClass); + for (MergedAnnotation annotation : annotations.stream(Import.class).toList()) { + for (Class source : annotation.getClassArray(MergedAnnotation.VALUE)) { + Set determinedSourceImports = determineImports(source, metadata); if (determinedSourceImports == null) { return null; } @@ -302,13 +270,6 @@ private Set determineImports(Set annotations, Class testC return determinedImports; } - private Class[] getImports(Annotation annotation) { - if (annotation instanceof Import importAnnotation) { - return importAnnotation.value(); - } - return NO_IMPORTS; - } - private Set determineImports(Class source, AnnotationMetadata metadata) { if (DeterminableImports.class.isAssignableFrom(source)) { // We can determine the imports @@ -324,6 +285,10 @@ private Set determineImports(Class source, AnnotationMetadata metadat return Collections.singleton(source.getName()); } + private Set synthesize(MergedAnnotations annotations) { + return annotations.stream().map(MergedAnnotation::synthesize).collect(Collectors.toSet()); + } + @SuppressWarnings("unchecked") private T instantiate(Class source) { try { @@ -354,67 +319,4 @@ public String toString() { } - /** - * Filter used to limit considered annotations. - */ - private interface AnnotationFilter { - - boolean isIgnored(Annotation annotation); - - } - - /** - * {@link AnnotationFilter} for {@literal java.lang} annotations. - */ - private static final class JavaLangAnnotationFilter implements AnnotationFilter { - - @Override - public boolean isIgnored(Annotation annotation) { - return AnnotationUtils.isInJavaLangAnnotationPackage(annotation); - } - - } - - /** - * {@link AnnotationFilter} for Kotlin annotations. - */ - private static final class KotlinAnnotationFilter implements AnnotationFilter { - - @Override - public boolean isIgnored(Annotation annotation) { - return "kotlin.Metadata".equals(annotation.annotationType().getName()) - || isInKotlinAnnotationPackage(annotation); - } - - private boolean isInKotlinAnnotationPackage(Annotation annotation) { - return annotation.annotationType().getName().startsWith("kotlin.annotation."); - } - - } - - /** - * {@link AnnotationFilter} for Spock annotations. - */ - private static final class SpockAnnotationFilter implements AnnotationFilter { - - @Override - public boolean isIgnored(Annotation annotation) { - return annotation.annotationType().getName().startsWith("org.spockframework.") - || annotation.annotationType().getName().startsWith("spock."); - } - - } - - /** - * {@link AnnotationFilter} for JUnit annotations. - */ - private static final class JUnitAnnotationFilter implements AnnotationFilter { - - @Override - public boolean isIgnored(Annotation annotation) { - return annotation.annotationType().getName().startsWith("org.junit."); - } - - } - } diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java index 663f56fa818e..c58f1bc4da2e 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootContextLoader.java @@ -188,7 +188,7 @@ private void configure(MergedContextConfiguration mergedConfig, SpringApplicatio if (mergedConfig instanceof WebMergedContextConfiguration) { application.setWebApplicationType(WebApplicationType.SERVLET); if (!isEmbeddedWebEnvironment(mergedConfig)) { - new WebConfigurer().configure(mergedConfig, application, initializers); + new WebConfigurer().configure(mergedConfig, initializers); } } else if (mergedConfig instanceof ReactiveWebMergedContextConfiguration) { @@ -197,8 +197,7 @@ else if (mergedConfig instanceof ReactiveWebMergedContextConfiguration) { else { application.setWebApplicationType(WebApplicationType.NONE); } - application.setApplicationContextFactory( - (webApplicationType) -> getApplicationContextFactory(mergedConfig, webApplicationType)); + application.setApplicationContextFactory(getApplicationContextFactory(mergedConfig)); if (mergedConfig.getParent() != null) { application.setBannerMode(Banner.Mode.OFF); } @@ -213,17 +212,26 @@ else if (mergedConfig instanceof ReactiveWebMergedContextConfiguration) { } } - private ConfigurableApplicationContext getApplicationContextFactory(MergedContextConfiguration mergedConfig, - WebApplicationType webApplicationType) { - if (webApplicationType != WebApplicationType.NONE && !isEmbeddedWebEnvironment(mergedConfig)) { - if (webApplicationType == WebApplicationType.REACTIVE) { - return new GenericReactiveWebApplicationContext(); - } - if (webApplicationType == WebApplicationType.SERVLET) { - return new GenericWebApplicationContext(); + /** + * Return the {@link ApplicationContextFactory} that should be used for the test. By + * default this method will return a factory that will create an appropriate + * {@link ApplicationContext} for the {@link WebApplicationType}. + * @param mergedConfig the merged context configuration + * @return the application context factory to use + * @since 3.2.0 + */ + protected ApplicationContextFactory getApplicationContextFactory(MergedContextConfiguration mergedConfig) { + return (webApplicationType) -> { + if (webApplicationType != WebApplicationType.NONE && !isEmbeddedWebEnvironment(mergedConfig)) { + if (webApplicationType == WebApplicationType.REACTIVE) { + return new GenericReactiveWebApplicationContext(); + } + if (webApplicationType == WebApplicationType.SERVLET) { + return new GenericWebApplicationContext(); + } } - } - return ApplicationContextFactory.DEFAULT.create(webApplicationType); + return ApplicationContextFactory.DEFAULT.create(webApplicationType); + }; } private void prepareEnvironment(MergedContextConfiguration mergedConfig, SpringApplication application, @@ -231,8 +239,8 @@ private void prepareEnvironment(MergedContextConfiguration mergedConfig, SpringA setActiveProfiles(environment, mergedConfig.getActiveProfiles(), applicationEnvironment); ResourceLoader resourceLoader = (application.getResourceLoader() != null) ? application.getResourceLoader() : new DefaultResourceLoader(null); - TestPropertySourceUtils.addPropertiesFilesToEnvironment(environment, resourceLoader, - mergedConfig.getPropertySourceLocations()); + TestPropertySourceUtils.addPropertySourcesToEnvironment(environment, resourceLoader, + mergedConfig.getPropertySourceDescriptors()); TestPropertySourceUtils.addInlinedPropertiesToEnvironment(environment, getInlinedProperties(mergedConfig)); } @@ -375,8 +383,7 @@ private enum Mode { */ private static final class WebConfigurer { - void configure(MergedContextConfiguration mergedConfig, SpringApplication application, - List> initializers) { + void configure(MergedContextConfiguration mergedConfig, List> initializers) { WebMergedContextConfiguration webMergedConfig = (WebMergedContextConfiguration) mergedConfig; addMockServletContext(initializers, webMergedConfig); } diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTest.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTest.java index 95a053363f41..41cb55b1557f 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTest.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -211,7 +211,7 @@ enum UseMainMethod { * that class does not have a main method, a test-specific * {@link SpringApplication} will be used. */ - WHEN_AVAILABLE; + WHEN_AVAILABLE } diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java index 0f812a30ebba..40970888546f 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/context/SpringBootTestContextBootstrapper.java @@ -38,7 +38,6 @@ import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; import org.springframework.core.env.Environment; -import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.ContextConfigurationAttributes; import org.springframework.test.context.ContextCustomizer; @@ -48,7 +47,6 @@ import org.springframework.test.context.TestContext; import org.springframework.test.context.TestContextAnnotationUtils; import org.springframework.test.context.TestContextBootstrapper; -import org.springframework.test.context.TestExecutionListener; import org.springframework.test.context.aot.AotTestAttributes; import org.springframework.test.context.support.DefaultTestContextBootstrapper; import org.springframework.test.context.support.TestPropertySourceUtils; @@ -122,18 +120,6 @@ else if (webEnvironment != null && webEnvironment.isEmbedded()) { return context; } - @Override - @SuppressWarnings("removal") - protected List getDefaultTestExecutionListeners() { - List listeners = new ArrayList<>(super.getDefaultTestExecutionListeners()); - List postProcessors = SpringFactoriesLoader - .loadFactories(DefaultTestExecutionListenersPostProcessor.class, getClass().getClassLoader()); - for (DefaultTestExecutionListenersPostProcessor postProcessor : postProcessors) { - listeners = postProcessor.postProcessDefaultTestExecutionListeners(listeners); - } - return listeners; - } - @Override protected ContextLoader resolveContextLoader(Class testClass, List configAttributesList) { @@ -390,7 +376,7 @@ protected final MergedContextConfiguration createModifiedConfig(MergedContextCon contextCustomizers.add(new SpringBootTestAnnotation(mergedConfig.getTestClass())); return new MergedContextConfiguration(mergedConfig.getTestClass(), mergedConfig.getLocations(), classes, mergedConfig.getContextInitializerClasses(), mergedConfig.getActiveProfiles(), - mergedConfig.getPropertySourceLocations(), propertySourceProperties, contextCustomizers, + mergedConfig.getPropertySourceDescriptors(), propertySourceProperties, contextCustomizers, mergedConfig.getContextLoader(), getCacheAwareContextLoaderDelegate(), mergedConfig.getParent()); } diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessor.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessor.java index dfc97c91eaf1..334d90dcfb1a 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessor.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessor.java @@ -252,18 +252,16 @@ private Set getExistingBeans(ConfigurableListableBeanFactory beanFactory Set beans = new LinkedHashSet<>( Arrays.asList(beanFactory.getBeanNamesForType(resolvableType, true, false))); Class type = resolvableType.resolve(Object.class); - String typeName = type.getName(); for (String beanName : beanFactory.getBeanNamesForType(FactoryBean.class, true, false)) { beanName = BeanFactoryUtils.transformedBeanName(beanName); BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); Object attribute = beanDefinition.getAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE); - if (resolvableType.equals(attribute) || type.equals(attribute) || typeName.equals(attribute)) { + if (resolvableType.equals(attribute) || type.equals(attribute)) { beans.add(beanName); } } beans.removeIf(this::isScopedTarget); return beans; - } private boolean isScopedTarget(String beanName) { diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/ResetMocksTestExecutionListener.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/ResetMocksTestExecutionListener.java index b353cd41c6cf..4aedbd3e48d4 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/ResetMocksTestExecutionListener.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/mockito/ResetMocksTestExecutionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -119,9 +119,7 @@ private boolean isStandardBeanOrSingletonFactoryBean(ConfigurableListableBeanFac String factoryBeanName = BeanFactory.FACTORY_BEAN_PREFIX + name; if (beanFactory.containsBean(factoryBeanName)) { FactoryBean factoryBean = (FactoryBean) beanFactory.getBean(factoryBeanName); - if (!factoryBean.isSingleton()) { - return false; - } + return factoryBean.isSingleton(); } return true; } diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/web/SpringBootMockServletContext.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/web/SpringBootMockServletContext.java index ec810bac3fd5..d1c005c78d46 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/web/SpringBootMockServletContext.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/mock/web/SpringBootMockServletContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizer.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizer.java new file mode 100644 index 000000000000..29b8345140b8 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizer.java @@ -0,0 +1,139 @@ +/* + * Copyright 2012-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.boot.test.web.client; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import org.springframework.beans.BeanUtils; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.MockRestServiceServer.MockRestServiceServerBuilder; +import org.springframework.test.web.client.RequestExpectationManager; +import org.springframework.test.web.client.SimpleRequestExpectationManager; +import org.springframework.util.Assert; +import org.springframework.web.client.RestClient; + +/** + * {@link RestClientCustomizer} that can be applied to {@link RestClient.Builder} + * instances to add {@link MockRestServiceServer} support. + *

+ * Typically applied to an existing builder before it is used, for example: + *

+ * MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer();
+ * RestClient.Builder builder = RestClient.builder();
+ * customizer.customize(builder);
+ * MyBean bean = new MyBean(client.build());
+ * customizer.getServer().expect(requestTo("/hello")).andRespond(withSuccess());
+ * bean.makeRestCall();
+ * 
+ *

+ * If the customizer is only used once, the {@link #getServer()} method can be used to + * obtain the mock server. If the customizer has been used more than once the + * {@link #getServer(RestClient.Builder)} or {@link #getServers()} method must be used to + * access the related server. + * + * @author Scott Frederick + * @since 3.2.0 + * @see #getServer() + * @see #getServer(RestClient.Builder) + */ +public class MockServerRestClientCustomizer implements RestClientCustomizer { + + private final Map expectationManagers = new ConcurrentHashMap<>(); + + private final Map servers = new ConcurrentHashMap<>(); + + private final Supplier expectationManagerSupplier; + + private boolean bufferContent = false; + + public MockServerRestClientCustomizer() { + this(SimpleRequestExpectationManager::new); + } + + /** + * Crate a new {@link MockServerRestClientCustomizer} instance. + * @param expectationManager the expectation manager class to use + */ + public MockServerRestClientCustomizer(Class expectationManager) { + this(() -> BeanUtils.instantiateClass(expectationManager)); + Assert.notNull(expectationManager, "ExpectationManager must not be null"); + } + + /** + * Crate a new {@link MockServerRestClientCustomizer} instance. + * @param expectationManagerSupplier a supplier that provides the + * {@link RequestExpectationManager} to use + * @since 3.0.0 + */ + public MockServerRestClientCustomizer(Supplier expectationManagerSupplier) { + Assert.notNull(expectationManagerSupplier, "ExpectationManagerSupplier must not be null"); + this.expectationManagerSupplier = expectationManagerSupplier; + } + + /** + * Set if the {@link BufferingClientHttpRequestFactory} wrapper should be used to + * buffer the input and output streams, and for example, allow multiple reads of the + * response body. + * @param bufferContent if request and response content should be buffered + * @since 3.1.0 + */ + public void setBufferContent(boolean bufferContent) { + this.bufferContent = bufferContent; + } + + @Override + public void customize(RestClient.Builder restClientBuilder) { + RequestExpectationManager expectationManager = createExpectationManager(); + MockRestServiceServerBuilder serverBuilder = MockRestServiceServer.bindTo(restClientBuilder); + if (this.bufferContent) { + serverBuilder.bufferContent(); + } + MockRestServiceServer server = serverBuilder.build(expectationManager); + this.expectationManagers.put(restClientBuilder, expectationManager); + this.servers.put(restClientBuilder, server); + } + + protected RequestExpectationManager createExpectationManager() { + return this.expectationManagerSupplier.get(); + } + + public MockRestServiceServer getServer() { + Assert.state(!this.servers.isEmpty(), "Unable to return a single MockRestServiceServer since " + + "MockServerRestClientCustomizer has not been bound to a RestClient"); + Assert.state(this.servers.size() == 1, "Unable to return a single MockRestServiceServer since " + + "MockServerRestClientCustomizer has been bound to more than one RestClient"); + return this.servers.values().iterator().next(); + } + + public Map getExpectationManagers() { + return this.expectationManagers; + } + + public MockRestServiceServer getServer(RestClient.Builder restClientBuilder) { + return this.servers.get(restClientBuilder); + } + + public Map getServers() { + return Collections.unmodifiableMap(this.servers); + } + +} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/TestRestTemplate.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/TestRestTemplate.java index 02e9215de897..901b999bbf37 100644 --- a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/TestRestTemplate.java +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/web/client/TestRestTemplate.java @@ -1021,9 +1021,6 @@ public CustomHttpComponentsClientHttpRequestFactory(HttpClientOption[] httpClien if (settings.connectTimeout() != null) { setConnectTimeout((int) settings.connectTimeout().toMillis()); } - if (settings.bufferRequestBody() != null) { - setBufferRequestBody(settings.bufferRequestBody()); - } } private HttpClient createHttpClient(Duration readTimeout, boolean ssl) { diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java index e314e15d4389..f9cc7f6e69ca 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/SpringBootContextLoaderTests.java @@ -26,12 +26,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.BeanCreationException; +import org.springframework.boot.ApplicationContextFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.SpringBootTest.UseMainMethod; import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.boot.web.reactive.context.GenericReactiveWebApplicationContext; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.ConfigurableEnvironment; @@ -246,6 +248,13 @@ void whenUseMainMethodWithContextHierarchyThrowsException() { .withMessage("UseMainMethod.ALWAYS cannot be used with @ContextHierarchy tests"); } + @Test + void whenSubclassProvidesCustomApplicationContextFactory() { + TestContext testContext = new ExposedTestContextManager(CustomApplicationContextTest.class) + .getExposedTestContext(); + assertThat(testContext.getApplicationContext()).isInstanceOf(CustomAnnotationConfigApplicationContext.class); + } + private String[] getActiveProfiles(Class testClass) { TestContext testContext = new ExposedTestContextManager(testClass).getExposedTestContext(); ApplicationContext applicationContext = testContext.getApplicationContext(); @@ -255,7 +264,7 @@ private String[] getActiveProfiles(Class testClass) { private Map getMergedContextConfigurationProperties(Class testClass) { TestContext context = new ExposedTestContextManager(testClass).getExposedTestContext(); MergedContextConfiguration config = (MergedContextConfiguration) ReflectionTestUtils.getField(context, - "mergedContextConfiguration"); + "mergedConfig"); return TestPropertySourceUtils.convertInlinedPropertiesToMap(config.getPropertySourceProperties()); } @@ -370,6 +379,25 @@ static class UseMainMethodWithContextHierarchy { } + @SpringBootTest + @ContextConfiguration(classes = Config.class, loader = CustomApplicationContextSpringBootContextLoader.class) + static class CustomApplicationContextTest { + + } + + static class CustomApplicationContextSpringBootContextLoader extends SpringBootContextLoader { + + @Override + protected ApplicationContextFactory getApplicationContextFactory(MergedContextConfiguration mergedConfig) { + return (webApplicationType) -> new CustomAnnotationConfigApplicationContext(); + } + + } + + static class CustomAnnotationConfigApplicationContext extends AnnotationConfigApplicationContext { + + } + @Configuration(proxyBeanMethods = false) static class Config { diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperIntegrationTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperIntegrationTests.java index 38edac15537b..69f87d827ff0 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperIntegrationTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -45,8 +45,6 @@ class SpringBootTestContextBootstrapperIntegrationTests { @Autowired private SpringBootTestContextBootstrapperExampleConfig config; - boolean defaultTestExecutionListenersPostProcessorCalled = false; - @Test void findConfigAutomatically() { assertThat(this.config).isNotNull(); @@ -62,11 +60,6 @@ void testConfigurationWasApplied() { assertThat(this.context.getBean(ExampleBean.class)).isNotNull(); } - @Test - void defaultTestExecutionListenersPostProcessorShouldBeCalled() { - assertThat(this.defaultTestExecutionListenersPostProcessorCalled).isTrue(); - } - @TestConfiguration(proxyBeanMethods = false) static class TestConfig { diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperTests.java index db724a374a1e..c38c81fc8121 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/SpringBootTestContextBootstrapperTests.java @@ -110,7 +110,7 @@ private TestContext buildTestContext(Class testClass) { } private MergedContextConfiguration getMergedContextConfiguration(TestContext context) { - return (MergedContextConfiguration) ReflectionTestUtils.getField(context, "mergedContextConfiguration"); + return (MergedContextConfiguration) ReflectionTestUtils.getField(context, "mergedConfig"); } @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/TestDefaultTestExecutionListenersPostProcessor.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/TestDefaultTestExecutionListenersPostProcessor.java deleted file mode 100644 index 7587c23f93f5..000000000000 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/context/bootstrap/TestDefaultTestExecutionListenersPostProcessor.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2012-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.boot.test.context.bootstrap; - -import java.util.List; - -import org.springframework.boot.test.context.DefaultTestExecutionListenersPostProcessor; -import org.springframework.test.context.TestContext; -import org.springframework.test.context.TestExecutionListener; -import org.springframework.test.context.support.AbstractTestExecutionListener; - -/** - * Test {@link DefaultTestExecutionListenersPostProcessor}. - * - * @author Phillip Webb - */ -@SuppressWarnings("removal") -public class TestDefaultTestExecutionListenersPostProcessor implements DefaultTestExecutionListenersPostProcessor { - - @Override - public List postProcessDefaultTestExecutionListeners(List listeners) { - listeners.add(new ExampleTestExecutionListener()); - return listeners; - } - - static class ExampleTestExecutionListener extends AbstractTestExecutionListener { - - @Override - public void prepareTestInstance(TestContext testContext) throws Exception { - Object testInstance = testContext.getTestInstance(); - if (testInstance instanceof SpringBootTestContextBootstrapperIntegrationTests test) { - test.defaultTestExecutionListenersPostProcessorCalled = true; - } - } - - } - -} diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/AbstractJsonMarshalTesterTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/AbstractJsonMarshalTesterTests.java index 406ff1cc1c98..f680b5f78a33 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/AbstractJsonMarshalTesterTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/AbstractJsonMarshalTesterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -152,7 +153,7 @@ void readReaderShouldReturnObject() throws Exception { void parseListShouldReturnContent() throws Exception { ResolvableType type = ResolvableTypes.get("listOfExampleObject"); AbstractJsonMarshalTester tester = createTester(type); - assertThat(tester.parse(ARRAY_JSON)).asList().containsOnly(OBJECT); + assertThat(tester.parse(ARRAY_JSON)).asInstanceOf(InstanceOfAssertFactories.LIST).containsOnly(OBJECT); } @Test diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/GsonTesterIntegrationTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/GsonTesterIntegrationTests.java index ed1aca17d886..b2c02051d59b 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/GsonTesterIntegrationTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/GsonTesterIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import java.util.Map; import com.google.gson.Gson; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -61,7 +62,7 @@ void typicalTest() throws Exception { @Test void typicalListTest() throws Exception { String example = "[" + JSON + "]"; - assertThat(this.listJson.parse(example)).asList().hasSize(1); + assertThat(this.listJson.parse(example)).asInstanceOf(InstanceOfAssertFactories.LIST).hasSize(1); assertThat(this.listJson.parse(example).getObject().get(0).getName()).isEqualTo("Spring"); } diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/JacksonTesterIntegrationTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/JacksonTesterIntegrationTests.java index 5a97f9f7c056..7d33b235b7ea 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/JacksonTesterIntegrationTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/json/JacksonTesterIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.springframework.core.io.ByteArrayResource; @@ -63,7 +64,7 @@ void typicalTest() throws Exception { void typicalListTest() throws Exception { JacksonTester.initFields(this, new ObjectMapper()); String example = "[" + JSON + "]"; - assertThat(this.listJson.parse(example)).asList().hasSize(1); + assertThat(this.listJson.parse(example)).asInstanceOf(InstanceOfAssertFactories.LIST).hasSize(1); assertThat(this.listJson.parse(example).getObject().get(0).getName()).isEqualTo("Spring"); } diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessorTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessorTests.java index 99969dadd9c9..5bb4bace7047 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessorTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoPostProcessorTests.java @@ -73,18 +73,6 @@ void cannotMockMultipleQualifiedBeans() { + " expected a single matching bean to replace but found [example1, example3]"); } - @Test - void canMockBeanProducedByFactoryBeanWithStringObjectTypeAttribute() { - AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); - MockitoPostProcessor.register(context); - RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(TestFactoryBean.class); - factoryBeanDefinition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, SomeInterface.class.getName()); - context.registerBeanDefinition("beanToBeMocked", factoryBeanDefinition); - context.register(MockedFactoryBean.class); - context.refresh(); - assertThat(Mockito.mockingDetails(context.getBean("beanToBeMocked")).isMock()).isTrue(); - } - @Test void canMockBeanProducedByFactoryBeanWithClassObjectTypeAttribute() { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizerTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizerTests.java new file mode 100644 index 000000000000..0dffb76514a8 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/MockServerRestClientCustomizerTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2012-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.boot.test.web.client; + +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.client.RequestExpectationManager; +import org.springframework.test.web.client.SimpleRequestExpectationManager; +import org.springframework.test.web.client.UnorderedRequestExpectationManager; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClient.Builder; + +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.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link MockServerRestClientCustomizer}. + * + * @author Scott Frederick + */ +class MockServerRestClientCustomizerTests { + + private MockServerRestClientCustomizer customizer; + + @BeforeEach + void setup() { + this.customizer = new MockServerRestClientCustomizer(); + } + + @Test + void createShouldUseSimpleRequestExpectationManager() { + MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer(); + customizer.customize(RestClient.builder()); + assertThat(customizer.getServer()).extracting("expectationManager") + .isInstanceOf(SimpleRequestExpectationManager.class); + } + + @Test + void createWhenExpectationManagerClassIsNullShouldThrowException() { + Class expectationManager = null; + assertThatIllegalArgumentException().isThrownBy(() -> new MockServerRestClientCustomizer(expectationManager)) + .withMessageContaining("ExpectationManager must not be null"); + } + + @Test + void createWhenExpectationManagerSupplierIsNullShouldThrowException() { + Supplier expectationManagerSupplier = null; + assertThatIllegalArgumentException() + .isThrownBy(() -> new MockServerRestClientCustomizer(expectationManagerSupplier)) + .withMessageContaining("ExpectationManagerSupplier must not be null"); + } + + @Test + void createShouldUseExpectationManagerClass() { + MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer( + UnorderedRequestExpectationManager.class); + customizer.customize(RestClient.builder()); + assertThat(customizer.getServer()).extracting("expectationManager") + .isInstanceOf(UnorderedRequestExpectationManager.class); + } + + @Test + void createShouldUseSupplier() { + MockServerRestClientCustomizer customizer = new MockServerRestClientCustomizer( + UnorderedRequestExpectationManager::new); + customizer.customize(RestClient.builder()); + assertThat(customizer.getServer()).extracting("expectationManager") + .isInstanceOf(UnorderedRequestExpectationManager.class); + } + + @Test + void customizeShouldBindServer() { + Builder builder = RestClient.builder(); + this.customizer.customize(builder); + this.customizer.getServer().expect(requestTo("/test")).andRespond(withSuccess()); + builder.build().get().uri("/test").retrieve().toEntity(String.class); + this.customizer.getServer().verify(); + } + + @Test + void getServerWhenNoServersAreBoundShouldThrowException() { + assertThatIllegalStateException().isThrownBy(this.customizer::getServer) + .withMessageContaining("Unable to return a single MockRestServiceServer since " + + "MockServerRestClientCustomizer has not been bound to a RestClient"); + } + + @Test + void getServerWhenMultipleServersAreBoundShouldThrowException() { + this.customizer.customize(RestClient.builder()); + this.customizer.customize(RestClient.builder()); + assertThatIllegalStateException().isThrownBy(this.customizer::getServer) + .withMessageContaining("Unable to return a single MockRestServiceServer since " + + "MockServerRestClientCustomizer has been bound to more than one RestClient"); + } + + @Test + void getServerWhenSingleServerIsBoundShouldReturnServer() { + Builder builder = RestClient.builder(); + this.customizer.customize(builder); + assertThat(this.customizer.getServer()).isEqualTo(this.customizer.getServer(builder)); + } + + @Test + void getServerWhenRestClientBuilderIsFoundShouldReturnServer() { + Builder builder1 = RestClient.builder(); + Builder builder2 = RestClient.builder(); + this.customizer.customize(builder1); + this.customizer.customize(builder2); + assertThat(this.customizer.getServer(builder1)).isNotNull(); + assertThat(this.customizer.getServer(builder2)).isNotNull().isNotSameAs(this.customizer.getServer(builder1)); + } + + @Test + void getServerWhenRestClientBuilderIsNotFoundShouldReturnNull() { + Builder builder1 = RestClient.builder(); + Builder builder2 = RestClient.builder(); + this.customizer.customize(builder1); + assertThat(this.customizer.getServer(builder1)).isNotNull(); + assertThat(this.customizer.getServer(builder2)).isNull(); + } + + @Test + void getServersShouldReturnServers() { + Builder builder1 = RestClient.builder(); + Builder builder2 = RestClient.builder(); + this.customizer.customize(builder1); + this.customizer.customize(builder2); + assertThat(this.customizer.getServers()).containsOnlyKeys(builder1, builder2); + } + + @Test + void getExpectationManagersShouldReturnExpectationManagers() { + Builder builder1 = RestClient.builder(); + Builder builder2 = RestClient.builder(); + this.customizer.customize(builder1); + this.customizer.customize(builder2); + RequestExpectationManager manager1 = this.customizer.getExpectationManagers().get(builder1); + RequestExpectationManager manager2 = this.customizer.getExpectationManagers().get(builder2); + assertThat(this.customizer.getServer(builder1)).extracting("expectationManager").isEqualTo(manager1); + assertThat(this.customizer.getServer(builder2)).extracting("expectationManager").isEqualTo(manager2); + } + +} diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/TestRestTemplateTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/TestRestTemplateTests.java index 53cb7b836672..11b9b881af7c 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/TestRestTemplateTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/web/client/TestRestTemplateTests.java @@ -38,7 +38,7 @@ import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; +import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.mock.env.MockEnvironment; import org.springframework.mock.http.client.MockClientHttpRequest; @@ -86,15 +86,16 @@ void simple() { @Test void doNotReplaceCustomRequestFactory() { - RestTemplateBuilder builder = new RestTemplateBuilder().requestFactory(OkHttp3ClientHttpRequestFactory.class); + RestTemplateBuilder builder = new RestTemplateBuilder() + .requestFactory(HttpComponentsClientHttpRequestFactory.class); TestRestTemplate testRestTemplate = new TestRestTemplate(builder); assertThat(testRestTemplate.getRestTemplate().getRequestFactory()) - .isInstanceOf(OkHttp3ClientHttpRequestFactory.class); + .isInstanceOf(HttpComponentsClientHttpRequestFactory.class); } @Test void useTheSameRequestFactoryClassWithBasicAuth() { - OkHttp3ClientHttpRequestFactory customFactory = new OkHttp3ClientHttpRequestFactory(); + JettyClientHttpRequestFactory customFactory = new JettyClientHttpRequestFactory(); RestTemplateBuilder builder = new RestTemplateBuilder().requestFactory(() -> customFactory); TestRestTemplate testRestTemplate = new TestRestTemplate(builder).withBasicAuth("test", "test"); RestTemplate restTemplate = testRestTemplate.getRestTemplate(); diff --git a/spring-boot-project/spring-boot-testcontainers/build.gradle b/spring-boot-project/spring-boot-testcontainers/build.gradle index 9cfb9709582e..28b62213219a 100644 --- a/spring-boot-project/spring-boot-testcontainers/build.gradle +++ b/spring-boot-project/spring-boot-testcontainers/build.gradle @@ -1,6 +1,7 @@ plugins { id "java-library" id "org.springframework.boot.auto-configuration" + id "org.springframework.boot.configuration-properties" id "org.springframework.boot.conventions" id "org.springframework.boot.deployed" id "org.springframework.boot.optional-dependencies" @@ -28,7 +29,9 @@ dependencies { optional("org.testcontainers:mysql") optional("org.testcontainers:neo4j") optional("org.testcontainers:oracle-xe") + optional("org.testcontainers:oracle-free") optional("org.testcontainers:postgresql") + optional("org.testcontainers:pulsar") optional("org.testcontainers:rabbitmq") optional("org.testcontainers:redpanda") optional("org.testcontainers:r2dbc") @@ -36,6 +39,11 @@ dependencies { testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) testImplementation(project(":spring-boot-project:spring-boot-test")) testImplementation("ch.qos.logback:logback-classic") + testImplementation("io.micrometer:micrometer-registry-otlp") + testImplementation("io.rest-assured:rest-assured") { + exclude group: "commons-logging", module: "commons-logging" + } + testImplementation("org.apache.activemq:activemq-client-jakarta") testImplementation("org.assertj:assertj-core") testImplementation("org.awaitility:awaitility") testImplementation("org.influxdb:influxdb-java") @@ -45,11 +53,13 @@ dependencies { testImplementation("org.mockito:mockito-core") testImplementation("org.mockito:mockito-junit-jupiter") testImplementation("org.springframework:spring-core-test") + testImplementation("org.springframework:spring-jms") testImplementation("org.springframework:spring-r2dbc") testImplementation("org.springframework.amqp:spring-rabbit") testImplementation("org.springframework.kafka:spring-kafka") + testImplementation("org.springframework.ldap:spring-ldap-core") + testImplementation("org.springframework.pulsar:spring-pulsar") testImplementation("org.testcontainers:junit-jupiter") testRuntimeOnly("com.oracle.database.r2dbc:oracle-r2dbc") } - diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ContainerFieldsImporter.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ContainerFieldsImporter.java index ed12fd8de10b..95a8fce48ff3 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ContainerFieldsImporter.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ContainerFieldsImporter.java @@ -22,7 +22,6 @@ import java.util.List; import org.testcontainers.containers.Container; -import org.testcontainers.lifecycle.Startable; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.util.Assert; @@ -39,9 +38,6 @@ void registerBeanDefinitions(BeanDefinitionRegistry registry, Class definitio for (Field field : getContainerFields(definitionClass)) { assertValid(field); Container container = getContainer(field); - if (container instanceof Startable startable) { - startable.start(); - } registerBeanDefinition(registry, field, container); } } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java index 35d72d7c7206..d680f7504c81 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/DynamicPropertySourceMethodsImporter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import java.lang.reflect.Modifier; import java.util.Set; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.boot.testcontainers.properties.TestcontainersPropertySource; import org.springframework.core.MethodIntrospector; import org.springframework.core.annotation.MergedAnnotations; @@ -43,16 +44,17 @@ class DynamicPropertySourceMethodsImporter { this.environment = environment; } - void registerDynamicPropertySources(Class definitionClass) { + void registerDynamicPropertySources(BeanDefinitionRegistry beanDefinitionRegistry, Class definitionClass) { Set methods = MethodIntrospector.selectMethods(definitionClass, this::isAnnotated); if (methods.isEmpty()) { return; } - DynamicPropertyRegistry registry = TestcontainersPropertySource.attach(this.environment); + DynamicPropertyRegistry dynamicPropertyRegistry = TestcontainersPropertySource.attach(this.environment, + beanDefinitionRegistry); methods.forEach((method) -> { assertValid(method); ReflectionUtils.makeAccessible(method); - ReflectionUtils.invokeMethod(method, null, registry); + ReflectionUtils.invokeMethod(method, null, dynamicPropertyRegistry); }); } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainersRegistrar.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainersRegistrar.java index 9e4dc4c97dfb..1c2cf49725c3 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainersRegistrar.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/context/ImportTestcontainersRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ private void registerBeanDefinitions(BeanDefinitionRegistry registry, Class[] for (Class definitionClass : definitionClasses) { this.containerFieldsImporter.registerBeanDefinitions(registry, definitionClass); if (this.dynamicPropertySourceMethodsImporter != null) { - this.dynamicPropertySourceMethodsImporter.registerDynamicPropertySources(definitionClass); + this.dynamicPropertySourceMethodsImporter.registerDynamicPropertySources(registry, definitionClass); } } } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java index 087c4d71e605..662be02a7f47 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,11 @@ public void initialize(ConfigurableApplicationContext applicationContext) { } ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); applicationContext.addBeanFactoryPostProcessor(new TestcontainersLifecycleBeanFactoryPostProcessor()); - beanFactory.addBeanPostProcessor(new TestcontainersLifecycleBeanPostProcessor(beanFactory)); + TestcontainersStartup startup = TestcontainersStartup.get(applicationContext.getEnvironment()); + TestcontainersLifecycleBeanPostProcessor beanPostProcessor = new TestcontainersLifecycleBeanPostProcessor( + beanFactory, startup); + beanFactory.addBeanPostProcessor(beanPostProcessor); + applicationContext.addApplicationListener(beanPostProcessor); } } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java index 9a5ec1d10aff..519d5de0d6a2 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleBeanPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.testcontainers.lifecycle; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -34,6 +38,8 @@ import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.config.DestructionAwareBeanPostProcessor; +import org.springframework.boot.testcontainers.properties.BeforeTestcontainersPropertySuppliedEvent; +import org.springframework.context.ApplicationListener; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.log.LogMessage; @@ -52,52 +58,105 @@ * @see TestcontainersLifecycleApplicationContextInitializer */ @Order(Ordered.LOWEST_PRECEDENCE) -class TestcontainersLifecycleBeanPostProcessor implements DestructionAwareBeanPostProcessor { +class TestcontainersLifecycleBeanPostProcessor + implements DestructionAwareBeanPostProcessor, ApplicationListener { private static final Log logger = LogFactory.getLog(TestcontainersLifecycleBeanPostProcessor.class); private final ConfigurableListableBeanFactory beanFactory; - private volatile boolean containersInitialized = false; + private final TestcontainersStartup startup; - TestcontainersLifecycleBeanPostProcessor(ConfigurableListableBeanFactory beanFactory) { + private final AtomicReference startables = new AtomicReference<>(Startables.UNSTARTED); + + private final AtomicBoolean containersInitialized = new AtomicBoolean(); + + TestcontainersLifecycleBeanPostProcessor(ConfigurableListableBeanFactory beanFactory, + TestcontainersStartup startup) { this.beanFactory = beanFactory; + this.startup = startup; + } + + @Override + public void onApplicationEvent(BeforeTestcontainersPropertySuppliedEvent event) { + initializeContainers(); } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (bean instanceof Startable startable) { - startable.start(); - } if (this.beanFactory.isConfigurationFrozen()) { initializeContainers(); } + if (bean instanceof Startable startableBean) { + if (this.startables.compareAndExchange(Startables.UNSTARTED, Startables.STARTING) == Startables.UNSTARTED) { + initializeStartables(startableBean, beanName); + } + else if (this.startables.get() == Startables.STARTED) { + logger.trace(LogMessage.format("Starting container %s", beanName)); + startableBean.start(); + } + } return bean; } - private void initializeContainers() { - if (this.containersInitialized) { + private void initializeStartables(Startable startableBean, String startableBeanName) { + logger.trace(LogMessage.format("Initializing startables")); + List beanNames = new ArrayList<>( + List.of(this.beanFactory.getBeanNamesForType(Startable.class, false, false))); + beanNames.remove(startableBeanName); + List beans = getBeans(beanNames); + if (beans == null) { + logger.trace(LogMessage.format("Failed to obtain startables %s", beanNames)); + this.startables.set(Startables.UNSTARTED); return; } - this.containersInitialized = true; - Set beanNames = new LinkedHashSet<>(); - beanNames.addAll(List.of(this.beanFactory.getBeanNamesForType(ContainerState.class, false, false))); - beanNames.addAll(List.of(this.beanFactory.getBeanNamesForType(Startable.class, false, false))); + beanNames.add(startableBeanName); + beans.add(startableBean); + logger.trace(LogMessage.format("Starting startables %s", beanNames)); + start(beans); + this.startables.set(Startables.STARTED); + if (!beanNames.isEmpty()) { + logger.debug(LogMessage.format("Initialized and started startable beans '%s'", beanNames)); + } + } + + private void start(List beans) { + Set startables = beans.stream() + .filter(Startable.class::isInstance) + .map(Startable.class::cast) + .collect(Collectors.toCollection(LinkedHashSet::new)); + this.startup.start(startables); + } + + private void initializeContainers() { + if (this.containersInitialized.compareAndSet(false, true)) { + logger.trace("Initializing containers"); + List beanNames = List.of(this.beanFactory.getBeanNamesForType(ContainerState.class, false, false)); + List beans = getBeans(beanNames); + if (beans != null) { + logger.trace(LogMessage.format("Initialized containers %s", beanNames)); + } + else { + logger.trace(LogMessage.format("Failed to initialize containers %s", beanNames)); + this.containersInitialized.set(false); + } + } + } + + private List getBeans(List beanNames) { + List beans = new ArrayList<>(beanNames.size()); for (String beanName : beanNames) { try { - this.beanFactory.getBean(beanName); + beans.add(this.beanFactory.getBean(beanName)); } catch (BeanCreationException ex) { if (ex.contains(BeanCurrentlyInCreationException.class)) { - this.containersInitialized = false; - return; + return null; } throw ex; } } - if (!beanNames.isEmpty()) { - logger.debug(LogMessage.format("Initialized container beans '%s'", beanNames)); - } + return beans; } @Override @@ -127,4 +186,10 @@ private boolean isReusedContainer(Object bean) { return (bean instanceof GenericContainer container) && container.isShouldBeReused(); } + enum Startables { + + UNSTARTED, STARTING, STARTED + + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java new file mode 100644 index 000000000000..00009a07fa6c --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartup.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-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.boot.testcontainers.lifecycle; + +import java.util.Collection; + +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.lifecycle.Startables; + +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; + +/** + * Testcontainers startup strategies. The strategy to use can be configured in the Spring + * {@link Environment} with a {@value #PROPERTY} property. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public enum TestcontainersStartup { + + /** + * Startup containers sequentially. + */ + SEQUENTIAL { + + @Override + void start(Collection startables) { + startables.forEach(Startable::start); + } + + }, + + /** + * Startup containers in parallel. + */ + PARALLEL { + + @Override + void start(Collection startables) { + Startables.deepStart(startables).join(); + } + + }; + + /** + * The {@link Environment} property used to change the {@link TestcontainersStartup} + * strategy. + */ + public static final String PROPERTY = "spring.testcontainers.beans.startup"; + + abstract void start(Collection startables); + + static TestcontainersStartup get(ConfigurableEnvironment environment) { + return get((environment != null) ? environment.getProperty(PROPERTY) : null); + } + + private static TestcontainersStartup get(String value) { + if (value == null) { + return SEQUENTIAL; + } + String canonicalName = getCanonicalName(value); + for (TestcontainersStartup candidate : values()) { + if (candidate.name().equalsIgnoreCase(canonicalName)) { + return candidate; + } + } + throw new IllegalArgumentException("Unknown '%s' property value '%s'".formatted(PROPERTY, value)); + } + + private static String getCanonicalName(String name) { + StringBuilder canonicalName = new StringBuilder(name.length()); + name.chars() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .forEach((c) -> canonicalName.append((char) c)); + return canonicalName.toString(); + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/BeforeTestcontainersPropertySuppliedEvent.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/BeforeTestcontainersPropertySuppliedEvent.java new file mode 100644 index 000000000000..4efe59902974 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/BeforeTestcontainersPropertySuppliedEvent.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.testcontainers.properties; + +import java.util.function.Supplier; + +import org.springframework.context.ApplicationEvent; + +/** + * Event published just before the {@link Supplier value supplier} of a + * {@link TestcontainersPropertySource} property is called. + * + * @author Phillip Webb + * @since 3.2.2 + */ +public class BeforeTestcontainersPropertySuppliedEvent extends ApplicationEvent { + + private final String propertyName; + + BeforeTestcontainersPropertySuppliedEvent(TestcontainersPropertySource source, String propertyName) { + super(source); + this.propertyName = propertyName; + } + + /** + * Return the name of the property about to be supplied. + * @return the propertyName the property name + */ + public String getPropertyName() { + return this.propertyName; + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceAutoConfiguration.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceAutoConfiguration.java index 5c016c964e5f..e6219f2d2d4e 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceAutoConfiguration.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.testcontainers.properties; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; -import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.test.context.DynamicPropertyRegistry; /** @@ -28,6 +31,8 @@ * @author Phillip Webb * @since 3.1.0 */ +@AutoConfiguration +@Order(Ordered.HIGHEST_PRECEDENCE) @ConditionalOnClass(DynamicPropertyRegistry.class) public class TestcontainersPropertySourceAutoConfiguration { @@ -35,8 +40,8 @@ public class TestcontainersPropertySourceAutoConfiguration { } @Bean - DynamicPropertyRegistry dynamicPropertyRegistry(ConfigurableEnvironment environment) { - return TestcontainersPropertySource.attach(environment); + static DynamicPropertyRegistry dynamicPropertyRegistry(ConfigurableApplicationContext applicationContext) { + return TestcontainersPropertySource.attach(applicationContext); } } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactory.java new file mode 100644 index 000000000000..82324da487ee --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactory.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.activemq; + +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; + +import org.springframework.boot.autoconfigure.jms.activemq.ActiveMQConnectionDetails; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; + +/** + * {@link ContainerConnectionDetailsFactory} to create {@link ActiveMQConnectionDetails} + * from a {@link ServiceConnection @ServiceConnection}-annotated {@link GenericContainer} + * using the {@code "symptoma/activemq"} image. + * + * @author Eddú Meléndez + */ +class ActiveMQContainerConnectionDetailsFactory + extends ContainerConnectionDetailsFactory, ActiveMQConnectionDetails> { + + ActiveMQContainerConnectionDetailsFactory() { + super("symptoma/activemq"); + } + + @Override + protected ActiveMQConnectionDetails getContainerConnectionDetails(ContainerConnectionSource> source) { + return new ActiveMQContainerConnectionDetails(source); + } + + private static final class ActiveMQContainerConnectionDetails extends ContainerConnectionDetails> + implements ActiveMQConnectionDetails { + + private ActiveMQContainerConnectionDetails(ContainerConnectionSource> source) { + super(source); + } + + @Override + public String getBrokerUrl() { + return "tcp://" + getContainer().getHost() + ":" + getContainer().getFirstMappedPort(); + } + + @Override + public String getUser() { + return getContainer().getEnvMap().get("ACTIVEMQ_USERNAME"); + } + + @Override + public String getPassword() { + return getContainer().getEnvMap().get("ACTIVEMQ_PASSWORD"); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/package-info.java new file mode 100644 index 000000000000..0981f1bf9b21 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/activemq/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Support for testcontainers ActiveMQ service connections. + */ +package org.springframework.boot.testcontainers.service.connection.activemq; diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ldap/OpenLdapContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ldap/OpenLdapContainerConnectionDetailsFactory.java new file mode 100644 index 000000000000..fd752811cdde --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ldap/OpenLdapContainerConnectionDetailsFactory.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.testcontainers.service.connection.ldap; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; + +import org.springframework.boot.autoconfigure.ldap.LdapConnectionDetails; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; + +/** + * {@link ContainerConnectionDetailsFactory} to create {@link LdapConnectionDetails} from + * a {@link ServiceConnection @ServiceConnection}-annotated {@link GenericContainer} using + * the {@code "osixia/openldap"} image. + * + * @author Philipp Kessler + */ +class OpenLdapContainerConnectionDetailsFactory + extends ContainerConnectionDetailsFactory, LdapConnectionDetails> { + + OpenLdapContainerConnectionDetailsFactory() { + super("osixia/openldap"); + } + + @Override + protected LdapConnectionDetails getContainerConnectionDetails(ContainerConnectionSource> source) { + return new OpenLdapContainerConnectionDetails(source); + } + + private static final class OpenLdapContainerConnectionDetails extends ContainerConnectionDetails> + implements LdapConnectionDetails { + + private OpenLdapContainerConnectionDetails(ContainerConnectionSource> source) { + super(source); + } + + @Override + public String[] getUrls() { + Map env = getContainer().getEnvMap(); + boolean usesTls = Boolean.parseBoolean(env.getOrDefault("LDAP_TLS", "true")); + String ldapPort = usesTls ? env.getOrDefault("LDAPS_PORT", "636") : env.getOrDefault("LDAP_PORT", "389"); + return new String[] { "%s://%s:%d".formatted(usesTls ? "ldaps" : "ldap", getContainer().getHost(), + getContainer().getMappedPort(Integer.parseInt(ldapPort))) }; + } + + @Override + public String getBase() { + Map env = getContainer().getEnvMap(); + if (env.containsKey("LDAP_BASE_DN")) { + return env.get("LDAP_BASE_DN"); + } + return Arrays.stream(env.getOrDefault("LDAP_DOMAIN", "example.org").split("\\.")) + .map("dc=%s"::formatted) + .collect(Collectors.joining(",")); + } + + @Override + public String getUsername() { + return "cn=admin,%s".formatted(getBase()); + } + + @Override + public String getPassword() { + return getContainer().getEnvMap().getOrDefault("LDAP_ADMIN_PASSWORD", "admin"); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ldap/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ldap/package-info.java new file mode 100644 index 000000000000..0de36b546985 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/ldap/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Support for testcontainers Ldap service connections. + */ +package org.springframework.boot.testcontainers.service.connection.ldap; diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactory.java new file mode 100644 index 000000000000..8dd417ccd1b7 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactory.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.otlp; + +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; + +import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsConnectionDetails; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; + +/** + * {@link ContainerConnectionDetailsFactory} to create + * {@link OtlpMetricsConnectionDetails} from a + * {@link ServiceConnection @ServiceConnection}-annotated {@link GenericContainer} using + * the {@code "otel/opentelemetry-collector-contrib"} image. + * + * @author Eddú Meléndez + */ +class OpenTelemetryMetricsContainerConnectionDetailsFactory + extends ContainerConnectionDetailsFactory, OtlpMetricsConnectionDetails> { + + OpenTelemetryMetricsContainerConnectionDetailsFactory() { + super("otel/opentelemetry-collector-contrib", + "org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration"); + } + + @Override + protected OtlpMetricsConnectionDetails getContainerConnectionDetails( + ContainerConnectionSource> source) { + return new OpenTelemetryMetricsContainerConnectionDetails(source); + } + + private static final class OpenTelemetryMetricsContainerConnectionDetails + extends ContainerConnectionDetails> implements OtlpMetricsConnectionDetails { + + private OpenTelemetryMetricsContainerConnectionDetails(ContainerConnectionSource> source) { + super(source); + } + + @Override + public String getUrl() { + return "http://%s:%d/v1/metrics".formatted(getContainer().getHost(), getContainer().getMappedPort(4318)); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactory.java new file mode 100644 index 000000000000..6c3e72ac797c --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactory.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.otlp; + +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; + +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConnectionDetails; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; + +/** + * {@link ContainerConnectionDetailsFactory} to create + * {@link OtlpTracingConnectionDetails} from a + * {@link ServiceConnection @ServiceConnection}-annotated {@link GenericContainer} using + * the {@code "otel/opentelemetry-collector-contrib"} image. + * + * @author Eddú Meléndez + */ +class OpenTelemetryTracingContainerConnectionDetailsFactory + extends ContainerConnectionDetailsFactory, OtlpTracingConnectionDetails> { + + OpenTelemetryTracingContainerConnectionDetailsFactory() { + super("otel/opentelemetry-collector-contrib", + "org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration"); + } + + @Override + protected OtlpTracingConnectionDetails getContainerConnectionDetails( + ContainerConnectionSource> source) { + return new OpenTelemetryTracingContainerConnectionDetails(source); + } + + private static final class OpenTelemetryTracingContainerConnectionDetails + extends ContainerConnectionDetails> implements OtlpTracingConnectionDetails { + + private OpenTelemetryTracingContainerConnectionDetails(ContainerConnectionSource> source) { + super(source); + } + + @Override + public String getUrl() { + return "http://%s:%d/v1/traces".formatted(getContainer().getHost(), getContainer().getMappedPort(4318)); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/package-info.java new file mode 100644 index 000000000000..59b4a9dce0a2 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/otlp/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Support for testcontainers OpenTelemetry service connections. + */ +package org.springframework.boot.testcontainers.service.connection.otlp; diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactory.java new file mode 100644 index 000000000000..836c1a127d23 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactory.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.pulsar; + +import org.testcontainers.containers.PulsarContainer; + +import org.springframework.boot.autoconfigure.pulsar.PulsarConnectionDetails; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; + +/** + * {@link ContainerConnectionDetailsFactory} to create {@link PulsarConnectionDetails} + * from a {@link ServiceConnection @ServiceConnection}-annotated {@link PulsarContainer}. + * + * @author Chris Bono + */ +class PulsarContainerConnectionDetailsFactory + extends ContainerConnectionDetailsFactory { + + @Override + protected PulsarConnectionDetails getContainerConnectionDetails(ContainerConnectionSource source) { + return new PulsarContainerConnectionDetails(source); + } + + /** + * {@link PulsarConnectionDetails} backed by a {@link ContainerConnectionSource}. + */ + private static final class PulsarContainerConnectionDetails extends ContainerConnectionDetails + implements PulsarConnectionDetails { + + private PulsarContainerConnectionDetails(ContainerConnectionSource source) { + super(source); + } + + @Override + public String getBrokerUrl() { + return getContainer().getPulsarBrokerUrl(); + } + + @Override + public String getAdminUrl() { + return getContainer().getHttpServiceUrl(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/package-info.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/package-info.java new file mode 100644 index 000000000000..4938ad863134 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/pulsar/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Support for testcontainers Pulsar service connections. + */ +package org.springframework.boot.testcontainers.service.connection.pulsar; diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactory.java new file mode 100644 index 000000000000..09e381faa0fd --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactory.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.r2dbc; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.oracle.OracleContainer; +import org.testcontainers.oracle.OracleR2DBCDatabaseContainer; + +import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; + +/** + * {@link ContainerConnectionDetailsFactory} to create {@link R2dbcConnectionDetails} from + * a {@link ServiceConnection @ServiceConnection}-annotated {@link OracleContainer}. + * + * @author Eddú Meléndez + */ +class OracleFreeR2dbcContainerConnectionDetailsFactory + extends ContainerConnectionDetailsFactory { + + OracleFreeR2dbcContainerConnectionDetailsFactory() { + super(ANY_CONNECTION_NAME, "io.r2dbc.spi.ConnectionFactoryOptions"); + } + + @Override + public R2dbcConnectionDetails getContainerConnectionDetails(ContainerConnectionSource source) { + return new R2dbcDatabaseContainerConnectionDetails(source); + } + + /** + * {@link R2dbcConnectionDetails} backed by a {@link ContainerConnectionSource}. + */ + private static final class R2dbcDatabaseContainerConnectionDetails + extends ContainerConnectionDetails implements R2dbcConnectionDetails { + + private R2dbcDatabaseContainerConnectionDetails(ContainerConnectionSource source) { + super(source); + } + + @Override + public ConnectionFactoryOptions getConnectionFactoryOptions() { + return OracleR2DBCDatabaseContainer.getOptions(getContainer()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactory.java b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactory.java similarity index 95% rename from spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactory.java rename to spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactory.java index 783e83a77115..a5f21c796a46 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactory.java +++ b/spring-boot-project/spring-boot-testcontainers/src/main/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactory.java @@ -31,10 +31,10 @@ * * @author Eddú Meléndez */ -class OracleR2dbcContainerConnectionDetailsFactory +class OracleXeR2dbcContainerConnectionDetailsFactory extends ContainerConnectionDetailsFactory { - OracleR2dbcContainerConnectionDetailsFactory() { + OracleXeR2dbcContainerConnectionDetailsFactory() { super(ANY_CONNECTION_NAME, "io.r2dbc.spi.ConnectionFactoryOptions"); } diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000000..ca82e22875ba --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,10 @@ +{ + "properties": [ + { + "name": "spring.testcontainers.beans.startup", + "type": "org.springframework.boot.testcontainers.lifecycle.TestcontainersStartup", + "description": "Testcontainers startup modes.", + "defaultValue": "sequential" + } + ] +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring.factories index 2cfe37359c38..5b8bda347fcb 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-testcontainers/src/main/resources/META-INF/spring.factories @@ -8,6 +8,7 @@ org.springframework.boot.testcontainers.service.connection.ServiceConnectionCont # Connection Details Factories org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\ +org.springframework.boot.testcontainers.service.connection.activemq.ActiveMQContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.amqp.RabbitContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.cassandra.CassandraContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.couchbase.CouchbaseContainerConnectionDetailsFactory,\ @@ -15,12 +16,17 @@ org.springframework.boot.testcontainers.service.connection.flyway.FlywayContaine org.springframework.boot.testcontainers.service.connection.elasticsearch.ElasticsearchContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.jdbc.JdbcContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.kafka.KafkaContainerConnectionDetailsFactory,\ +org.springframework.boot.testcontainers.service.connection.ldap.OpenLdapContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.liquibase.LiquibaseContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.mongo.MongoContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.neo4j.Neo4jContainerConnectionDetailsFactory,\ +org.springframework.boot.testcontainers.service.connection.otlp.OpenTelemetryMetricsContainerConnectionDetailsFactory,\ +org.springframework.boot.testcontainers.service.connection.otlp.OpenTelemetryTracingContainerConnectionDetailsFactory,\ +org.springframework.boot.testcontainers.service.connection.pulsar.PulsarContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.r2dbc.MariaDbR2dbcContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.r2dbc.MySqlR2dbcContainerConnectionDetailsFactory,\ -org.springframework.boot.testcontainers.service.connection.r2dbc.OracleR2dbcContainerConnectionDetailsFactory,\ +org.springframework.boot.testcontainers.service.connection.r2dbc.OracleFreeR2dbcContainerConnectionDetailsFactory,\ +org.springframework.boot.testcontainers.service.connection.r2dbc.OracleXeR2dbcContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.r2dbc.PostgresR2dbcContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.r2dbc.SqlServerR2dbcContainerConnectionDetailsFactory,\ org.springframework.boot.testcontainers.service.connection.redis.RedisContainerConnectionDetailsFactory,\ diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupIntegrationTests.java new file mode 100644 index 000000000000..3b6ed3da25b0 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupIntegrationTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-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.boot.testcontainers.lifecycle; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.PostgreSQLContainer; + +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.testcontainers.lifecycle.TestContainersParallelStartupIntegrationTests.ContainerConfig; +import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for parallel startup. + * + * @author Phillip Webb + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ContainerConfig.class) +@TestPropertySource(properties = "spring.testcontainers.beans.startup=parallel") +@DirtiesContext +@DisabledIfDockerUnavailable +@ExtendWith(OutputCaptureExtension.class) +public class TestContainersParallelStartupIntegrationTests { + + @Test + void startsInParallel(CapturedOutput out) { + assertThat(out).contains("-lifecycle-0").contains("-lifecycle-1").contains("-lifecycle-2"); + } + + @Configuration(proxyBeanMethods = false) + static class ContainerConfig { + + @Bean + static PostgreSQLContainer container1() { + return new PostgreSQLContainer<>(DockerImageNames.postgresql()); + } + + @Bean + static PostgreSQLContainer container2() { + return new PostgreSQLContainer<>(DockerImageNames.postgresql()); + } + + @Bean + static PostgreSQLContainer container3() { + return new PostgreSQLContainer<>(DockerImageNames.postgresql()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupWithImportTestcontainersIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupWithImportTestcontainersIntegrationTests.java new file mode 100644 index 000000000000..7159f470d4f8 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestContainersParallelStartupWithImportTestcontainersIntegrationTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2012-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.boot.testcontainers.lifecycle; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; + +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.boot.testcontainers.lifecycle.TestContainersParallelStartupWithImportTestcontainersIntegrationTests.Containers; +import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for parallel startup. + * + * @author Phillip Webb + */ +@ExtendWith(SpringExtension.class) +@TestPropertySource(properties = "spring.testcontainers.beans.startup=parallel") +@DirtiesContext +@DisabledIfDockerUnavailable +@ExtendWith(OutputCaptureExtension.class) +@ImportTestcontainers(Containers.class) +public class TestContainersParallelStartupWithImportTestcontainersIntegrationTests { + + @Test + void startsInParallel(CapturedOutput out) { + assertThat(out).contains("-lifecycle-0").contains("-lifecycle-1").contains("-lifecycle-2"); + } + + static class Containers { + + @Container + static PostgreSQLContainer container1 = new PostgreSQLContainer<>(DockerImageNames.postgresql()); + + @Container + static PostgreSQLContainer container2 = new PostgreSQLContainer<>(DockerImageNames.postgresql()); + + @Container + static PostgreSQLContainer container3 = new PostgreSQLContainer<>(DockerImageNames.postgresql()); + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersImportWithPropertiesInjectedIntoLoadTimeWeaverAwareBeanIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersImportWithPropertiesInjectedIntoLoadTimeWeaverAwareBeanIntegrationTests.java new file mode 100644 index 000000000000..cab98edd1509 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersImportWithPropertiesInjectedIntoLoadTimeWeaverAwareBeanIntegrationTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.testcontainers.lifecycle; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.boot.testcontainers.lifecycle.TestcontainersImportWithPropertiesInjectedIntoLoadTimeWeaverAwareBeanIntegrationTests.Containers; +import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Bean; +import org.springframework.context.weaving.LoadTimeWeaverAware; +import org.springframework.instrument.classloading.LoadTimeWeaver; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * @author Phillip Webb + */ +@ExtendWith(SpringExtension.class) +@DirtiesContext +@DisabledIfDockerUnavailable +@ImportTestcontainers(Containers.class) +class TestcontainersImportWithPropertiesInjectedIntoLoadTimeWeaverAwareBeanIntegrationTests { + + // gh-38913 + + @Test + void starts() { + } + + @TestConfiguration + @EnableConfigurationProperties(MockDataSourceProperties.class) + static class Config { + + @Bean + MockEntityManager mockEntityManager(MockDataSourceProperties properties) { + return new MockEntityManager(); + } + + } + + static class MockEntityManager implements LoadTimeWeaverAware { + + @Override + public void setLoadTimeWeaver(LoadTimeWeaver loadTimeWeaver) { + } + + } + + @ConfigurationProperties("spring.datasource") + public static class MockDataSourceProperties { + + private String url; + + public String getUrl() { + return this.url; + } + + public void setUrl(String url) { + this.url = url; + } + + } + + static class Containers { + + @Container + static PostgreSQLContainer container = new PostgreSQLContainer<>(DockerImageNames.postgresql()); + + @DynamicPropertySource + static void setConnectionProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", container::getJdbcUrl); + registry.add("spring.datasource.password", container::getPassword); + registry.add("spring.datasource.username", container::getUsername); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java index ee27dae34f70..a00c0da51de9 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersLifecycleApplicationContextInitializerTests.java @@ -16,13 +16,18 @@ package org.springframework.boot.testcontainers.lifecycle; +import java.util.Map; + import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; import org.testcontainers.lifecycle.Startable; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.support.AbstractBeanFactory; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.MapPropertySource; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; @@ -104,6 +109,22 @@ void dealsWithBeanCurrentlyInCreationException() { applicationContext.refresh(); } + @Test + void setupStartupBasedOnEnvironmentProperty() { + AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + applicationContext.getEnvironment() + .getPropertySources() + .addLast(new MapPropertySource("test", Map.of("spring.testcontainers.beans.startup", "parallel"))); + new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext); + AbstractBeanFactory beanFactory = (AbstractBeanFactory) applicationContext.getBeanFactory(); + BeanPostProcessor beanPostProcessor = beanFactory.getBeanPostProcessors() + .stream() + .filter(TestcontainersLifecycleBeanPostProcessor.class::isInstance) + .findFirst() + .get(); + assertThat(beanPostProcessor).extracting("startup").isEqualTo(TestcontainersStartup.PARALLEL); + } + private AnnotationConfigApplicationContext createApplicationContext(Startable container) { AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); new TestcontainersLifecycleApplicationContextInitializer().initialize(applicationContext); diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java new file mode 100644 index 000000000000..b50e6ef63a58 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/lifecycle/TestcontainersStartupTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-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.boot.testcontainers.lifecycle; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.testcontainers.lifecycle.Startable; + +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link TestcontainersStartup}. + * + * @author Phillip Webb + */ +class TestcontainersStartupTests { + + private static final String PROPERTY = TestcontainersStartup.PROPERTY; + + private final AtomicInteger counter = new AtomicInteger(); + + @Test + void startWhenSquentialStartsSequentially() { + List startables = createTestStartables(100); + TestcontainersStartup.SEQUENTIAL.start(startables); + for (int i = 0; i < startables.size(); i++) { + assertThat(startables.get(i).getIndex()).isEqualTo(i); + assertThat(startables.get(i).getThreadName()).isEqualTo(Thread.currentThread().getName()); + } + } + + @Test + void startWhenParallelStartsInParallel() { + List startables = createTestStartables(100); + TestcontainersStartup.PARALLEL.start(startables); + assertThat(startables.stream().map(TestStartable::getThreadName)).hasSizeGreaterThan(1); + } + + @Test + void getWhenNoPropertyReturnsDefault() { + MockEnvironment environment = new MockEnvironment(); + assertThat(TestcontainersStartup.get(environment)).isEqualTo(TestcontainersStartup.SEQUENTIAL); + } + + @Test + void getWhenPropertyReturnsBasedOnValue() { + MockEnvironment environment = new MockEnvironment(); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "SEQUENTIAL"))) + .isEqualTo(TestcontainersStartup.SEQUENTIAL); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "sequential"))) + .isEqualTo(TestcontainersStartup.SEQUENTIAL); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "SEQuenTIaL"))) + .isEqualTo(TestcontainersStartup.SEQUENTIAL); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "S-E-Q-U-E-N-T-I-A-L"))) + .isEqualTo(TestcontainersStartup.SEQUENTIAL); + assertThat(TestcontainersStartup.get(environment.withProperty(PROPERTY, "parallel"))) + .isEqualTo(TestcontainersStartup.PARALLEL); + } + + @Test + void getWhenUnknownPropertyThrowsException() { + MockEnvironment environment = new MockEnvironment(); + assertThatIllegalArgumentException() + .isThrownBy(() -> TestcontainersStartup.get(environment.withProperty(PROPERTY, "bad"))) + .withMessage("Unknown 'spring.testcontainers.beans.startup' property value 'bad'"); + } + + private List createTestStartables(int size) { + List testStartables = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + testStartables.add(new TestStartable()); + } + return testStartables; + } + + private final class TestStartable implements Startable { + + private int index; + + private String threadName; + + @Override + public void start() { + this.index = TestcontainersStartupTests.this.counter.getAndIncrement(); + this.threadName = Thread.currentThread().getName(); + } + + @Override + public void stop() { + } + + int getIndex() { + return this.index; + } + + String getThreadName() { + return this.threadName; + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceAutoConfigurationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceAutoConfigurationTests.java index 723a4203f0b8..90103b24d78e 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.testcontainers.properties; +import java.util.ArrayList; +import java.util.List; + import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -25,6 +28,7 @@ import org.springframework.boot.testcontainers.lifecycle.TestcontainersLifecycleApplicationContextInitializer; import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; import org.springframework.boot.testsupport.testcontainers.RedisContainer; +import org.springframework.context.ApplicationEvent; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -46,11 +50,16 @@ class TestcontainersPropertySourceAutoConfigurationTests { @Test void containerBeanMethodContributesProperties() { - this.contextRunner.withUserConfiguration(ContainerAndPropertiesConfiguration.class).run((context) -> { - TestBean testBean = context.getBean(TestBean.class); - RedisContainer redisContainer = context.getBean(RedisContainer.class); - assertThat(testBean.getUsingPort()).isEqualTo(redisContainer.getFirstMappedPort()); - }); + List events = new ArrayList<>(); + this.contextRunner.withUserConfiguration(ContainerAndPropertiesConfiguration.class) + .withInitializer((context) -> context.addApplicationListener(events::add)) + .run((context) -> { + TestBean testBean = context.getBean(TestBean.class); + RedisContainer redisContainer = context.getBean(RedisContainer.class); + assertThat(testBean.getUsingPort()).isEqualTo(redisContainer.getFirstMappedPort()); + assertThat(events.stream().filter(BeforeTestcontainersPropertySuppliedEvent.class::isInstance)) + .hasSize(1); + }); } @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceTests.java index 7958666992da..4dce61ffc942 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/properties/TestcontainersPropertySourceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,15 @@ package org.springframework.boot.testcontainers.properties; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.env.EnumerablePropertySource; import org.springframework.core.env.PropertySource; import org.springframework.mock.env.MockEnvironment; @@ -101,4 +106,20 @@ void attachWhenAlreadyAttachedReturnsExisting() { assertThat(p1).isSameAs(p2); } + @Test + void getPropertyPublishesEvent() { + try (GenericApplicationContext applicationContext = new GenericApplicationContext()) { + List events = new ArrayList<>(); + applicationContext.addApplicationListener(events::add); + DynamicPropertyRegistry registry = TestcontainersPropertySource.attach(applicationContext.getEnvironment(), + (BeanDefinitionRegistry) applicationContext.getBeanFactory()); + applicationContext.refresh(); + registry.add("test", () -> "spring"); + assertThat(applicationContext.getEnvironment().containsProperty("test")).isTrue(); + assertThat(events.isEmpty()); + assertThat(applicationContext.getEnvironment().getProperty("test")).isEqualTo("spring"); + assertThat(events.stream().filter(BeforeTestcontainersPropertySuppliedEvent.class::isInstance)).hasSize(1); + } + } + } diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..647b4861d086 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/activemq/ActiveMQContainerConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.activemq; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jms.JmsAutoConfiguration; +import org.springframework.boot.autoconfigure.jms.activemq.ActiveMQAutoConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.testcontainers.ActiveMQContainer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jms.annotation.JmsListener; +import org.springframework.jms.core.JmsMessagingTemplate; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ActiveMQContainerConnectionDetailsFactory}. + * + * @author Eddú Meléndez + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +class ActiveMQContainerConnectionDetailsFactoryIntegrationTests { + + @Container + @ServiceConnection + static final ActiveMQContainer activemq = new ActiveMQContainer(); + + @Autowired + private JmsMessagingTemplate jmsTemplate; + + @Autowired + private TestListener listener; + + @Test + void connectionCanBeMadeToActiveMQContainer() { + this.jmsTemplate.convertAndSend("sample.queue", "message"); + Awaitility.waitAtMost(Duration.ofMinutes(1)) + .untilAsserted(() -> assertThat(this.listener.messages).containsExactly("message")); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration({ ActiveMQAutoConfiguration.class, JmsAutoConfiguration.class }) + static class TestConfiguration { + + @Bean + TestListener testListener() { + return new TestListener(); + } + + } + + static class TestListener { + + private final List messages = new ArrayList<>(); + + @JmsListener(destination = "sample.queue") + void processMessage(String message) { + this.messages.add(message); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ldap/OpenLdapContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ldap/OpenLdapContainerConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..4bae83e11bc9 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/ldap/OpenLdapContainerConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.testcontainers.service.connection.ldap; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.testcontainers.OpenLdapContainer; +import org.springframework.context.annotation.Configuration; +import org.springframework.ldap.core.AttributesMapper; +import org.springframework.ldap.core.LdapTemplate; +import org.springframework.ldap.query.LdapQueryBuilder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OpenLdapContainerConnectionDetailsFactory}. + * + * @author Philipp Kessler + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +class OpenLdapContainerConnectionDetailsFactoryIntegrationTests { + + @Container + @ServiceConnection + static final OpenLdapContainer openLdap = new OpenLdapContainer().withEnv("LDAP_TLS", "false"); + + @Autowired + private LdapTemplate ldapTemplate; + + @Test + void connectionCanBeMadeToLdapContainer() { + List cn = this.ldapTemplate.search(LdapQueryBuilder.query().where("objectclass").is("dcObject"), + (AttributesMapper) (attributes) -> attributes.get("dc").get().toString()); + assertThat(cn).hasSize(1); + assertThat(cn.get(0)).isEqualTo("example"); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration({ LdapAutoConfiguration.class }) + static class TestConfiguration { + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..b3167fb8035c --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryMetricsContainerConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,120 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.testcontainers.service.connection.otlp; + +import java.time.Duration; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.restassured.RestAssured; +import io.restassured.response.Response; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.metrics.export.otlp.OtlpMetricsExportAutoConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.matchesPattern; + +/** + * Tests for {@link OpenTelemetryMetricsContainerConnectionDetailsFactory}. + * + * @author Eddú Meléndez + * @author Jonatan Ivanov + */ +@SpringJUnitConfig +@TestPropertySource(properties = { "management.otlp.metrics.export.resource-attributes.service.name=test", + "management.otlp.metrics.export.step=1s" }) +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext +class OpenTelemetryMetricsContainerConnectionDetailsFactoryIntegrationTests { + + private static final String OPENMETRICS_001 = "application/openmetrics-text; version=0.0.1; charset=utf-8"; + + private static final String CONFIG_FILE_NAME = "collector-config.yml"; + + @Container + @ServiceConnection + static final GenericContainer container = new GenericContainer<>(DockerImageNames.opentelemetry()) + .withCommand("--config=/etc/" + CONFIG_FILE_NAME) + .withCopyToContainer(MountableFile.forClasspathResource(CONFIG_FILE_NAME), "/etc/" + CONFIG_FILE_NAME) + .withExposedPorts(4318, 9090); + + @Autowired + private MeterRegistry meterRegistry; + + @Test + void connectionCanBeMadeToOpenTelemetryCollectorContainer() { + Counter.builder("test.counter").register(this.meterRegistry).increment(42); + Gauge.builder("test.gauge", () -> 12).register(this.meterRegistry); + Timer.builder("test.timer").register(this.meterRegistry).record(Duration.ofMillis(123)); + DistributionSummary.builder("test.distributionsummary").register(this.meterRegistry).record(24); + Awaitility.await() + .atMost(Duration.ofSeconds(5)) + .pollDelay(Duration.ofMillis(100)) + .pollInterval(Duration.ofMillis(100)) + .untilAsserted(() -> whenPrometheusScraped().then() + .statusCode(200) + .contentType(OPENMETRICS_001) + .body(endsWith("# EOF\n"), containsString("service_name"))); + whenPrometheusScraped().then() + .body(containsString( + "{job=\"test\",service_name=\"test\",telemetry_sdk_language=\"java\",telemetry_sdk_name=\"io.micrometer\""), + matchesPattern("(?s)^.*test_counter\\{.+} 42\\.0\\n.*$"), + matchesPattern("(?s)^.*test_gauge\\{.+} 12\\.0\\n.*$"), + matchesPattern("(?s)^.*test_timer_count\\{.+} 1\\n.*$"), + matchesPattern("(?s)^.*test_timer_sum\\{.+} 123\\.0\\n.*$"), + matchesPattern("(?s)^.*test_timer_bucket\\{.+,le=\"\\+Inf\"} 1\\n.*$"), + matchesPattern("(?s)^.*test_distributionsummary_count\\{.+} 1\\n.*$"), + matchesPattern("(?s)^.*test_distributionsummary_sum\\{.+} 24\\.0\\n.*$"), + matchesPattern("(?s)^.*test_distributionsummary_bucket\\{.+,le=\"\\+Inf\"} 1\\n.*$")); + } + + private Response whenPrometheusScraped() { + return RestAssured.given().port(container.getMappedPort(9090)).accept(OPENMETRICS_001).when().get("/metrics"); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(OtlpMetricsExportAutoConfiguration.class) + static class TestConfiguration { + + @Bean + Clock customClock() { + return Clock.SYSTEM; + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..ab41e680c555 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/otlp/OpenTelemetryTracingContainerConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.otlp; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpTracingConnectionDetails; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OpenTelemetryTracingContainerConnectionDetailsFactory}. + * + * @author Eddú Meléndez + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +class OpenTelemetryTracingContainerConnectionDetailsFactoryIntegrationTests { + + @Container + @ServiceConnection + static final GenericContainer container = new GenericContainer<>(DockerImageNames.opentelemetry()) + .withExposedPorts(4318); + + @Autowired + private OtlpTracingConnectionDetails connectionDetails; + + @Test + void connectionCanBeMadeToOpenTelemetryContainer() { + assertThat(this.connectionDetails.getUrl()) + .isEqualTo("http://" + container.getHost() + ":" + container.getMappedPort(4318) + "/v1/traces"); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(OtlpAutoConfiguration.class) + static class TestConfiguration { + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactoryIntegrationTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactoryIntegrationTests.java new file mode 100644 index 000000000000..51f5ec2a1364 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/pulsar/PulsarContainerConnectionDetailsFactoryIntegrationTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pulsar.client.api.PulsarClientException; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PulsarContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.pulsar.PulsarAutoConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PulsarContainerConnectionDetailsFactory}. + * + * @author Chris Bono + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +@TestPropertySource(properties = { "spring.pulsar.consumer.subscription.initial-position=earliest" }) +class PulsarContainerConnectionDetailsFactoryIntegrationTests { + + @Container + @ServiceConnection + @SuppressWarnings("unused") + static final PulsarContainer PULSAR = new PulsarContainer(DockerImageNames.pulsar()) + .withStartupTimeout(Duration.ofMinutes(3)); + + @Autowired + private PulsarTemplate pulsarTemplate; + + @Autowired + private TestListener listener; + + @Test + void connectionCanBeMadeToPulsarContainer() throws PulsarClientException { + this.pulsarTemplate.send("test-topic", "test-data"); + Awaitility.waitAtMost(Duration.ofSeconds(30)) + .untilAsserted(() -> assertThat(this.listener.messages).containsExactly("test-data")); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(PulsarAutoConfiguration.class) + static class TestConfiguration { + + @Bean + TestListener testListener() { + return new TestListener(); + } + + } + + static class TestListener { + + private final List messages = new ArrayList<>(); + + @PulsarListener(topics = "test-topic") + void processMessage(String message) { + this.messages.add(message); + } + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactoryTests.java new file mode 100644 index 000000000000..500910648a54 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleFreeR2dbcContainerConnectionDetailsFactoryTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-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.boot.testcontainers.service.connection.r2dbc; + +import java.time.Duration; + +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.OS; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.oracle.OracleContainer; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration; +import org.springframework.boot.jdbc.DatabaseDriver; +import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryHints; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.junit.DisabledOnOs; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Configuration; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OracleFreeR2dbcContainerConnectionDetailsFactory}. + * + * @author Andy Wilkinson + */ +@SpringJUnitConfig +@Testcontainers(disabledWithoutDocker = true) +@DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64", + disabledReason = "The Oracle image has no ARM support") +class OracleFreeR2dbcContainerConnectionDetailsFactoryTests { + + @Container + @ServiceConnection + static final OracleContainer oracle = new OracleContainer(DockerImageNames.oracleFree()) + .withStartupTimeout(Duration.ofMinutes(2)); + + @Autowired + ConnectionFactory connectionFactory; + + @Test + void connectionCanBeMadeToOracleContainer() { + Object result = DatabaseClient.create(this.connectionFactory) + .sql(DatabaseDriver.ORACLE.getValidationQuery()) + .map((row, metadata) -> row.get(0)) + .first() + .block(Duration.ofSeconds(30)); + assertThat(result).isEqualTo("Hello"); + } + + @Test + void shouldRegisterHints() { + RuntimeHints hints = ContainerConnectionDetailsFactoryHints.getRegisteredHints(getClass().getClassLoader()); + assertThat(RuntimeHintsPredicates.reflection().onType(ConnectionFactoryOptions.class)).accepts(hints); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration(R2dbcAutoConfiguration.class) + static class TestConfiguration { + + } + +} diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactoryTests.java b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactoryTests.java similarity index 96% rename from spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactoryTests.java rename to spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactoryTests.java index 5aff194a1bfd..aa40d6204e70 100644 --- a/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleR2dbcContainerConnectionDetailsFactoryTests.java +++ b/spring-boot-project/spring-boot-testcontainers/src/test/java/org/springframework/boot/testcontainers/service/connection/r2dbc/OracleXeR2dbcContainerConnectionDetailsFactoryTests.java @@ -43,7 +43,7 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link OracleR2dbcContainerConnectionDetailsFactory}. + * Tests for {@link OracleXeR2dbcContainerConnectionDetailsFactory}. * * @author Andy Wilkinson */ @@ -51,7 +51,7 @@ @Testcontainers(disabledWithoutDocker = true) @DisabledOnOs(os = { OS.LINUX, OS.MAC }, architecture = "aarch64", disabledReason = "The Oracle image has no ARM support") -class OracleR2dbcContainerConnectionDetailsFactoryTests { +class OracleXeR2dbcContainerConnectionDetailsFactoryTests { @Container @ServiceConnection diff --git a/spring-boot-project/spring-boot-testcontainers/src/test/resources/collector-config.yml b/spring-boot-project/spring-boot-testcontainers/src/test/resources/collector-config.yml new file mode 100644 index 000000000000..c17a371d66c2 --- /dev/null +++ b/spring-boot-project/spring-boot-testcontainers/src/test/resources/collector-config.yml @@ -0,0 +1,20 @@ +receivers: + otlp: + protocols: + grpc: + http: + +exporters: + # https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/prometheusexporter + prometheus: + endpoint: '0.0.0.0:9090' + metric_expiration: 1m + enable_open_metrics: true + resource_to_telemetry_conversion: + enabled: true + +service: + pipelines: + metrics: + receivers: [otlp] + exporters: [prometheus] \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-antlib/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-antlib/build.gradle index e00c34aeaf8e..750604b01944 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-antlib/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-antlib/build.gradle @@ -19,7 +19,7 @@ dependencies { antUnit "org.apache.ant:ant-antunit:1.3" antIvy "org.apache.ivy:ivy:2.5.0" - compileOnly(project(":spring-boot-project:spring-boot-tools:spring-boot-loader")) + compileOnly(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-classic")) compileOnly("org.apache.ant:ant:${antVersion}") implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools")) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-antlib/src/main/resources/org/springframework/boot/ant/antlib.xml b/spring-boot-project/spring-boot-tools/spring-boot-antlib/src/main/resources/org/springframework/boot/ant/antlib.xml index bf2f7307866d..980049c0cd2d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-antlib/src/main/resources/org/springframework/boot/ant/antlib.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-antlib/src/main/resources/org/springframework/boot/ant/antlib.xml @@ -42,7 +42,7 @@ Extracting spring-boot-loader to ${destdir}/dependency - @@ -58,10 +58,10 @@ - + + value="org.springframework.boot.loader.launch.JarLauncher" /> diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/build.gradle index d6b393c6819e..b99a4c61c3e4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/build.gradle @@ -14,6 +14,11 @@ configurations.all { if (dependency.requested.group.startsWith("com.fasterxml.jackson")) { dependency.useVersion("2.14.2") } + // Downgrade Spring Framework as Gradle cannot cope with 6.1.0-M1's + // multi-version jar files with bytecode in META-INF/versions/21 + if (dependency.requested.group.equals("org.springframework")) { + dependency.useVersion("6.0.10") + } } } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java index 7154d4ad43bf..b870d30f9f64 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/AbstractBuildLog.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -68,6 +68,12 @@ public void executingLifecycle(BuildRequest request, LifecycleVersion version, V log(" > Using build cache volume '" + buildCacheVolume + "'"); } + @Override + public void executingLifecycle(BuildRequest request, LifecycleVersion version, Cache buildCache) { + log(" > Executing lifecycle version " + version); + log(" > Using build cache " + buildCache); + } + @Override public Consumer runningPhase(BuildRequest request, String name) { log(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java index 10ceba196dba..2b1f474c06f7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java @@ -31,7 +31,7 @@ final class ApiVersions { /** * The platform API versions supported by this release. */ - static final ApiVersions SUPPORTED_PLATFORMS = ApiVersions.of(0, IntStream.rangeClosed(3, 11)); + static final ApiVersions SUPPORTED_PLATFORMS = ApiVersions.of(0, IntStream.rangeClosed(3, 12)); private final ApiVersion[] apiVersions; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java index 0acbbabd224f..e84054773f40 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildLog.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -79,6 +79,14 @@ public interface BuildLog { */ void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume); + /** + * Log that the lifecycle is executing. + * @param request the build request + * @param version the lifecycle version + * @param buildCache the build cache in use + */ + void executingLifecycle(BuildRequest request, LifecycleVersion version, Cache buildCache); + /** * Log that a specific phase is running. * @param request the build request diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java index f409fcc875ff..476f0a8917e2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildRequest.java @@ -77,6 +77,8 @@ public class BuildRequest { private final List tags; + private final Cache buildWorkspace; + private final Cache buildCache; private final Cache launchCache; @@ -85,6 +87,8 @@ public class BuildRequest { private final String applicationDirectory; + private final List securityOptions; + BuildRequest(ImageReference name, Function applicationContent) { Assert.notNull(name, "Name must not be null"); Assert.notNull(applicationContent, "ApplicationContent must not be null"); @@ -102,17 +106,19 @@ public class BuildRequest { this.bindings = Collections.emptyList(); this.network = null; this.tags = Collections.emptyList(); + this.buildWorkspace = null; this.buildCache = null; this.launchCache = null; this.createdDate = null; this.applicationDirectory = null; + this.securityOptions = null; } BuildRequest(ImageReference name, Function applicationContent, ImageReference builder, ImageReference runImage, Creator creator, Map env, boolean cleanCache, boolean verboseLogging, PullPolicy pullPolicy, boolean publish, List buildpacks, - List bindings, String network, List tags, Cache buildCache, Cache launchCache, - Instant createdDate, String applicationDirectory) { + List bindings, String network, List tags, Cache buildWorkspace, Cache buildCache, + Cache launchCache, Instant createdDate, String applicationDirectory, List securityOptions) { this.name = name; this.applicationContent = applicationContent; this.builder = builder; @@ -127,10 +133,12 @@ public class BuildRequest { this.bindings = bindings; this.network = network; this.tags = tags; + this.buildWorkspace = buildWorkspace; this.buildCache = buildCache; this.launchCache = launchCache; this.createdDate = createdDate; this.applicationDirectory = applicationDirectory; + this.securityOptions = securityOptions; } /** @@ -142,8 +150,8 @@ public BuildRequest withBuilder(ImageReference builder) { Assert.notNull(builder, "Builder must not be null"); return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, - this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache, - this.createdDate, this.applicationDirectory); + this.buildpacks, this.bindings, this.network, this.tags, this.buildWorkspace, this.buildCache, + this.launchCache, this.createdDate, this.applicationDirectory, this.securityOptions); } /** @@ -154,8 +162,8 @@ public BuildRequest withBuilder(ImageReference builder) { public BuildRequest withRunImage(ImageReference runImageName) { return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(), this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, - this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache, - this.createdDate, this.applicationDirectory); + this.buildpacks, this.bindings, this.network, this.tags, this.buildWorkspace, this.buildCache, + this.launchCache, this.createdDate, this.applicationDirectory, this.securityOptions); } /** @@ -167,8 +175,8 @@ public BuildRequest withCreator(Creator creator) { Assert.notNull(creator, "Creator must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, - this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -184,8 +192,8 @@ public BuildRequest withEnv(String name, String value) { env.put(name, value); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, - this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, this.launchCache, - this.createdDate, this.applicationDirectory); + this.buildpacks, this.bindings, this.network, this.tags, this.buildWorkspace, this.buildCache, + this.launchCache, this.createdDate, this.applicationDirectory, this.securityOptions); } /** @@ -199,8 +207,8 @@ public BuildRequest withEnv(Map env) { updatedEnv.putAll(env); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging, this.pullPolicy, - this.publish, this.buildpacks, this.bindings, this.network, this.tags, this.buildCache, - this.launchCache, this.createdDate, this.applicationDirectory); + this.publish, this.buildpacks, this.bindings, this.network, this.tags, this.buildWorkspace, + this.buildCache, this.launchCache, this.createdDate, this.applicationDirectory, this.securityOptions); } /** @@ -211,8 +219,8 @@ public BuildRequest withEnv(Map env) { public BuildRequest withCleanCache(boolean cleanCache) { return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, - this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -223,8 +231,8 @@ public BuildRequest withCleanCache(boolean cleanCache) { public BuildRequest withVerboseLogging(boolean verboseLogging) { return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, - this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -235,8 +243,8 @@ public BuildRequest withVerboseLogging(boolean verboseLogging) { public BuildRequest withPullPolicy(PullPolicy pullPolicy) { return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, - this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -247,8 +255,8 @@ public BuildRequest withPullPolicy(PullPolicy pullPolicy) { public BuildRequest withPublish(boolean publish) { return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, publish, this.buildpacks, this.bindings, - this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, - this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -272,8 +280,8 @@ public BuildRequest withBuildpacks(List buildpacks) { Assert.notNull(buildpacks, "Buildpacks must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, buildpacks, this.bindings, - this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, - this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -297,8 +305,8 @@ public BuildRequest withBindings(List bindings) { Assert.notNull(bindings, "Bindings must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, bindings, - this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, - this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -310,7 +318,8 @@ public BuildRequest withBindings(List bindings) { public BuildRequest withNetwork(String network) { return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - network, this.tags, this.buildCache, this.launchCache, this.createdDate, this.applicationDirectory); + network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -332,7 +341,22 @@ public BuildRequest withTags(List tags) { Assert.notNull(tags, "Tags must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, tags, this.buildCache, this.launchCache, this.createdDate, this.applicationDirectory); + this.network, tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); + } + + /** + * Return a new {@link BuildRequest} with an updated build workspace. + * @param buildWorkspace the build workspace + * @return an updated build request + * @since 3.2.0 + */ + public BuildRequest withBuildWorkspace(Cache buildWorkspace) { + Assert.notNull(buildWorkspace, "BuildWorkspace must not be null"); + return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, + this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, + this.network, this.tags, buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -344,7 +368,8 @@ public BuildRequest withBuildCache(Cache buildCache) { Assert.notNull(buildCache, "BuildCache must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, this.tags, buildCache, this.launchCache, this.createdDate, this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -356,7 +381,8 @@ public BuildRequest withLaunchCache(Cache launchCache) { Assert.notNull(launchCache, "LaunchCache must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, this.tags, this.buildCache, launchCache, this.createdDate, this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, launchCache, this.createdDate, + this.applicationDirectory, this.securityOptions); } /** @@ -368,8 +394,8 @@ public BuildRequest withCreatedDate(String createdDate) { Assert.notNull(createdDate, "CreatedDate must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, this.tags, this.buildCache, this.launchCache, parseCreatedDate(createdDate), - this.applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, + parseCreatedDate(createdDate), this.applicationDirectory, this.securityOptions); } private Instant parseCreatedDate(String createdDate) { @@ -393,7 +419,22 @@ public BuildRequest withApplicationDirectory(String applicationDirectory) { Assert.notNull(applicationDirectory, "ApplicationDirectory must not be null"); return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, - this.network, this.tags, this.buildCache, this.launchCache, this.createdDate, applicationDirectory); + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + applicationDirectory, this.securityOptions); + } + + /** + * Return a new {@link BuildRequest} with an updated security options. + * @param securityOptions the security options + * @return an updated build request + * @since 3.2.0 + */ + public BuildRequest withSecurityOptions(List securityOptions) { + Assert.notNull(securityOptions, "SecurityOption must not be null"); + return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env, + this.cleanCache, this.verboseLogging, this.pullPolicy, this.publish, this.buildpacks, this.bindings, + this.network, this.tags, this.buildWorkspace, this.buildCache, this.launchCache, this.createdDate, + this.applicationDirectory, securityOptions); } /** @@ -513,6 +554,15 @@ public List getTags() { return this.tags; } + /** + * Return the build workspace that should be used by the lifecycle. + * @return the build workspace or {@code null} + * @since 3.2.0 + */ + public Cache getBuildWorkspace() { + return this.buildWorkspace; + } + /** * Return the custom build cache that should be used by the lifecycle. * @return the build cache @@ -545,6 +595,15 @@ public String getApplicationDirectory() { return this.applicationDirectory; } + /** + * Return the security options that should be used by the lifecycle. + * @return the security options or {@code null} + * @since 3.2.0 + */ + public List getSecurityOptions() { + return this.securityOptions; + } + /** * Factory method to create a new {@link BuildRequest} from a JAR file. * @param jarFile the source jar file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java index 9f3087f94c31..704a3418d397 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Cache.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -18,6 +18,7 @@ import java.util.Objects; +import org.springframework.boot.buildpack.platform.docker.type.VolumeName; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -37,7 +38,22 @@ public enum Format { /** * A cache stored as a volume in the Docker daemon. */ - VOLUME; + VOLUME("volume"), + + /** + * A cache stored as a bind mount. + */ + BIND("bind mount"); + + private final String description; + + Format(String description) { + this.description = description; + } + + public String getDescription() { + return this.description; + } } @@ -55,16 +71,44 @@ public Volume getVolume() { return (this.format.equals(Format.VOLUME)) ? (Volume) this : null; } + /** + * Return the details of the cache if it is a bind cache. + * @return the cache, or {@code null} if it is not a bind cache + */ + public Bind getBind() { + return (this.format.equals(Format.BIND)) ? (Bind) this : null; + } + /** * Create a new {@code Cache} that uses a volume with the provided name. * @param name the cache volume name * @return a new cache instance */ public static Cache volume(String name) { + Assert.notNull(name, "Name must not be null"); + return new Volume(VolumeName.of(name)); + } + + /** + * Create a new {@code Cache} that uses a volume with the provided name. + * @param name the cache volume name + * @return a new cache instance + */ + public static Cache volume(VolumeName name) { Assert.notNull(name, "Name must not be null"); return new Volume(name); } + /** + * Create a new {@code Cache} that uses a bind mount with the provided source. + * @param source the cache bind mount source + * @return a new cache instance + */ + public static Cache bind(String source) { + Assert.notNull(source, "Source must not be null"); + return new Bind(source); + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -87,14 +131,18 @@ public int hashCode() { */ public static class Volume extends Cache { - private final String name; + private final VolumeName name; - Volume(String name) { + Volume(VolumeName name) { super(Format.VOLUME); this.name = name; } public String getName() { + return this.name.toString(); + } + + public VolumeName getVolumeName() { return this.name; } @@ -120,6 +168,56 @@ public int hashCode() { return result; } + @Override + public String toString() { + return this.format.getDescription() + " '" + this.name + "'"; + } + + } + + /** + * Details of a cache stored in a bind mount. + */ + public static class Bind extends Cache { + + private final String source; + + Bind(String source) { + super(Format.BIND); + this.source = source; + } + + public String getSource() { + return this.source; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + if (!super.equals(obj)) { + return false; + } + Bind other = (Bind) obj; + return Objects.equals(this.source, other.source); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + ObjectUtils.nullSafeHashCode(this.source); + return result; + } + + @Override + public String toString() { + return this.format.getDescription() + " '" + this.source + "'"; + } + } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java index 133f8e765b18..781d0aa77821 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Lifecycle.java @@ -18,6 +18,10 @@ import java.io.Closeable; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; import java.util.function.Consumer; import com.sun.jna.Platform; @@ -34,6 +38,7 @@ import org.springframework.boot.buildpack.platform.docker.type.VolumeName; import org.springframework.boot.buildpack.platform.io.TarArchive; import org.springframework.util.Assert; +import org.springframework.util.FileSystemUtils; /** * A buildpack lifecycle used to run the build {@link Phase phases} needed to package an @@ -54,6 +59,8 @@ class Lifecycle implements Closeable { private static final String DOMAIN_SOCKET_PATH = "/var/run/docker.sock"; + private static final List DEFAULT_SECURITY_OPTIONS = List.of("label=disable"); + private final BuildLog log; private final DockerApi docker; @@ -68,16 +75,18 @@ class Lifecycle implements Closeable { private final ApiVersion platformVersion; - private final VolumeName layersVolume; + private final Cache layers; - private final VolumeName applicationVolume; + private final Cache application; - private final VolumeName buildCacheVolume; + private final Cache buildCache; - private final VolumeName launchCacheVolume; + private final Cache launchCache; private final String applicationDirectory; + private final List securityOptions; + private boolean executed; private boolean applicationVolumePopulated; @@ -99,44 +108,37 @@ class Lifecycle implements Closeable { this.builder = builder; this.lifecycleVersion = LifecycleVersion.parse(builder.getBuilderMetadata().getLifecycle().getVersion()); this.platformVersion = getPlatformVersion(builder.getBuilderMetadata().getLifecycle()); - this.layersVolume = createRandomVolumeName("pack-layers-"); - this.applicationVolume = createRandomVolumeName("pack-app-"); - this.buildCacheVolume = getBuildCacheVolumeName(request); - this.launchCacheVolume = getLaunchCacheVolumeName(request); + this.layers = getLayersBindingSource(request); + this.application = getApplicationBindingSource(request); + this.buildCache = getBuildCache(request); + this.launchCache = getLaunchCache(request); this.applicationDirectory = getApplicationDirectory(request); + this.securityOptions = getSecurityOptions(request); } - protected VolumeName createRandomVolumeName(String prefix) { - return VolumeName.random(prefix); - } - - private VolumeName getBuildCacheVolumeName(BuildRequest request) { + private Cache getBuildCache(BuildRequest request) { if (request.getBuildCache() != null) { - return getVolumeName(request.getBuildCache()); + return request.getBuildCache(); } - return createCacheVolumeName(request, "build"); + return createVolumeCache(request, "build"); } - private VolumeName getLaunchCacheVolumeName(BuildRequest request) { + private Cache getLaunchCache(BuildRequest request) { if (request.getLaunchCache() != null) { - return getVolumeName(request.getLaunchCache()); - } - return createCacheVolumeName(request, "launch"); - } - - private VolumeName getVolumeName(Cache cache) { - if (cache.getVolume() != null) { - return VolumeName.of(cache.getVolume().getName()); + return request.getLaunchCache(); } - return null; + return createVolumeCache(request, "launch"); } private String getApplicationDirectory(BuildRequest request) { return (request.getApplicationDirectory() != null) ? request.getApplicationDirectory() : Directory.APPLICATION; } - private VolumeName createCacheVolumeName(BuildRequest request, String suffix) { - return VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", "." + suffix, 6); + private List getSecurityOptions(BuildRequest request) { + if (request.getSecurityOptions() != null) { + return request.getSecurityOptions(); + } + return (Platform.isWindows()) ? Collections.emptyList() : DEFAULT_SECURITY_OPTIONS; } private ApiVersion getPlatformVersion(BuilderMetadata.Lifecycle lifecycle) { @@ -155,9 +157,9 @@ private ApiVersion getPlatformVersion(BuilderMetadata.Lifecycle lifecycle) { void execute() throws IOException { Assert.state(!this.executed, "Lifecycle has already been executed"); this.executed = true; - this.log.executingLifecycle(this.request, this.lifecycleVersion, this.buildCacheVolume); + this.log.executingLifecycle(this.request, this.lifecycleVersion, this.buildCache); if (this.request.isCleanCache()) { - deleteVolume(this.buildCacheVolume); + deleteCache(this.buildCache); } run(createPhase()); this.log.executedLifecycle(this.request); @@ -182,10 +184,10 @@ private Phase createPhase() { phase.withArgs("-process-type=web"); } phase.withArgs(this.request.getName()); - phase.withBinding(Binding.from(this.layersVolume, Directory.LAYERS)); - phase.withBinding(Binding.from(this.applicationVolume, this.applicationDirectory)); - phase.withBinding(Binding.from(this.buildCacheVolume, Directory.CACHE)); - phase.withBinding(Binding.from(this.launchCacheVolume, Directory.LAUNCH_CACHE)); + phase.withBinding(Binding.from(getCacheBindingSource(this.layers), Directory.LAYERS)); + phase.withBinding(Binding.from(getCacheBindingSource(this.application), this.applicationDirectory)); + phase.withBinding(Binding.from(getCacheBindingSource(this.buildCache), Directory.CACHE)); + phase.withBinding(Binding.from(getCacheBindingSource(this.launchCache), Directory.LAUNCH_CACHE)); if (this.request.getBindings() != null) { this.request.getBindings().forEach(phase::withBinding); } @@ -199,6 +201,42 @@ private Phase createPhase() { return phase; } + private Cache getLayersBindingSource(BuildRequest request) { + if (request.getBuildWorkspace() != null) { + return getBuildWorkspaceBindingSource(request.getBuildWorkspace(), "layers"); + } + return createVolumeCache("pack-layers-"); + } + + private Cache getApplicationBindingSource(BuildRequest request) { + if (request.getBuildWorkspace() != null) { + return getBuildWorkspaceBindingSource(request.getBuildWorkspace(), "app"); + } + return createVolumeCache("pack-app-"); + } + + private Cache getBuildWorkspaceBindingSource(Cache buildWorkspace, String suffix) { + return (buildWorkspace.getVolume() != null) ? Cache.volume(buildWorkspace.getVolume().getName() + "-" + suffix) + : Cache.bind(buildWorkspace.getBind().getSource() + "-" + suffix); + } + + private String getCacheBindingSource(Cache cache) { + return (cache.getVolume() != null) ? cache.getVolume().getName() : cache.getBind().getSource(); + } + + private Cache createVolumeCache(String prefix) { + return Cache.volume(createRandomVolumeName(prefix)); + } + + private Cache createVolumeCache(BuildRequest request, String suffix) { + return Cache.volume( + VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", "." + suffix, 6)); + } + + protected VolumeName createRandomVolumeName(String prefix) { + return VolumeName.random(prefix); + } + private void configureDaemonAccess(Phase phase) { if (this.dockerHost != null) { if (this.dockerHost.isRemote()) { @@ -215,8 +253,8 @@ private void configureDaemonAccess(Phase phase) { else { phase.withBinding(Binding.from(DOMAIN_SOCKET_PATH, DOMAIN_SOCKET_PATH)); } - if (!Platform.isWindows()) { - phase.withSecurityOption("label=disable"); + if (this.securityOptions != null) { + this.securityOptions.forEach(phase::withSecurityOption); } } @@ -250,6 +288,9 @@ private ContainerReference createContainer(ContainerConfig config) throws IOExce return this.docker.container().create(config); } try { + if (this.application.getBind() != null) { + Files.createDirectories(Path.of(this.application.getBind().getSource())); + } TarArchive applicationContent = this.request.getApplicationContent(this.builder.getBuildOwner()); return this.docker.container() .create(config, ContainerContent.of(applicationContent, this.applicationDirectory)); @@ -261,14 +302,32 @@ private ContainerReference createContainer(ContainerConfig config) throws IOExce @Override public void close() throws IOException { - deleteVolume(this.layersVolume); - deleteVolume(this.applicationVolume); + deleteCache(this.layers); + deleteCache(this.application); + } + + private void deleteCache(Cache cache) throws IOException { + if (cache.getVolume() != null) { + deleteVolume(cache.getVolume().getVolumeName()); + } + if (cache.getBind() != null) { + deleteBind(cache.getBind().getSource()); + } } private void deleteVolume(VolumeName name) throws IOException { this.docker.volume().delete(name, true); } + private void deleteBind(String source) { + try { + FileSystemUtils.deleteRecursively(Path.of(source)); + } + catch (IOException ex) { + throw new IllegalStateException("Error cleaning bind mount directory '" + source + "'", ex); + } + } + /** * Common directories used by the various phases. */ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java index 4efc4a0c7cb2..06bddbdf0839 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java @@ -39,7 +39,7 @@ import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.hc.core5.net.URIBuilder; -import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; import org.springframework.boot.buildpack.platform.docker.transport.HttpTransport; import org.springframework.boot.buildpack.platform.docker.transport.HttpTransport.Response; import org.springframework.boot.buildpack.platform.docker.type.ContainerConfig; @@ -96,7 +96,7 @@ public DockerApi() { * @param dockerHost the Docker daemon host information * @since 2.4.0 */ - public DockerApi(DockerHost dockerHost) { + public DockerApi(DockerHostConfiguration dockerHost) { this(HttpTransport.create(dockerHost)); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java index b82a6b28ac22..fa47c349fbd1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -27,7 +27,7 @@ */ public final class DockerConfiguration { - private final DockerHost host; + private final DockerHostConfiguration host; private final DockerRegistryAuthentication builderAuthentication; @@ -39,7 +39,7 @@ public DockerConfiguration() { this(null, null, null, false); } - private DockerConfiguration(DockerHost host, DockerRegistryAuthentication builderAuthentication, + private DockerConfiguration(DockerHostConfiguration host, DockerRegistryAuthentication builderAuthentication, DockerRegistryAuthentication publishAuthentication, boolean bindHostToBuilder) { this.host = host; this.builderAuthentication = builderAuthentication; @@ -47,7 +47,7 @@ private DockerConfiguration(DockerHost host, DockerRegistryAuthentication builde this.bindHostToBuilder = bindHostToBuilder; } - public DockerHost getHost() { + public DockerHostConfiguration getHost() { return this.host; } @@ -65,7 +65,13 @@ public DockerRegistryAuthentication getPublishRegistryAuthentication() { public DockerConfiguration withHost(String address, boolean secure, String certificatePath) { Assert.notNull(address, "Address must not be null"); - return new DockerConfiguration(new DockerHost(address, secure, certificatePath), this.builderAuthentication, + return new DockerConfiguration(DockerHostConfiguration.forAddress(address, secure, certificatePath), + this.builderAuthentication, this.publishAuthentication, this.bindHostToBuilder); + } + + public DockerConfiguration withContext(String context) { + Assert.notNull(context, "Context must not be null"); + return new DockerConfiguration(DockerHostConfiguration.forContext(context), this.builderAuthentication, this.publishAuthentication, this.bindHostToBuilder); } @@ -107,4 +113,51 @@ public DockerConfiguration withEmptyPublishRegistryAuthentication() { new DockerRegistryUserAuthentication("", "", "", ""), this.bindHostToBuilder); } + public static class DockerHostConfiguration { + + private final String address; + + private final String context; + + private final boolean secure; + + private final String certificatePath; + + public DockerHostConfiguration(String address, String context, boolean secure, String certificatePath) { + this.address = address; + this.context = context; + this.secure = secure; + this.certificatePath = certificatePath; + } + + public String getAddress() { + return this.address; + } + + public String getContext() { + return this.context; + } + + public boolean isSecure() { + return this.secure; + } + + public String getCertificatePath() { + return this.certificatePath; + } + + public static DockerHostConfiguration forAddress(String address) { + return new DockerHostConfiguration(address, null, false, null); + } + + public static DockerHostConfiguration forAddress(String address, boolean secure, String certificatePath) { + return new DockerHostConfiguration(address, null, secure, certificatePath); + } + + static DockerHostConfiguration forContext(String context) { + return new DockerHostConfiguration(null, context, false, null); + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java new file mode 100644 index 000000000000..9ab0fef20192 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadata.java @@ -0,0 +1,211 @@ +/* + * Copyright 2012-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.boot.buildpack.platform.docker.configuration; + +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.NullNode; + +import org.springframework.boot.buildpack.platform.json.MappedObject; +import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; +import org.springframework.boot.buildpack.platform.system.Environment; + +/** + * Docker configuration stored in metadata files managed by the Docker CLI. + * + * @author Scott Frederick + */ +final class DockerConfigurationMetadata { + + private static final String DOCKER_CONFIG = "DOCKER_CONFIG"; + + private static final String DEFAULT_CONTEXT = "default"; + + private static final String CONFIG_DIR = ".docker"; + + private static final String CONTEXTS_DIR = "contexts"; + + private static final String META_DIR = "meta"; + + private static final String TLS_DIR = "tls"; + + private static final String DOCKER_ENDPOINT = "docker"; + + private static final String CONFIG_FILE_NAME = "config.json"; + + private static final String CONTEXT_FILE_NAME = "meta.json"; + + private final String configLocation; + + private final DockerConfig config; + + private final DockerContext context; + + private DockerConfigurationMetadata(String configLocation, DockerConfig config, DockerContext context) { + this.configLocation = configLocation; + this.config = config; + this.context = context; + } + + DockerConfig getConfiguration() { + return this.config; + } + + DockerContext getContext() { + return this.context; + } + + DockerContext forContext(String context) { + return createDockerContext(this.configLocation, context); + } + + static DockerConfigurationMetadata from(Environment environment) { + String configLocation = (environment.get(DOCKER_CONFIG) != null) ? environment.get(DOCKER_CONFIG) + : Path.of(System.getProperty("user.home"), CONFIG_DIR).toString(); + DockerConfig dockerConfig = createDockerConfig(configLocation); + DockerContext dockerContext = createDockerContext(configLocation, dockerConfig.getCurrentContext()); + return new DockerConfigurationMetadata(configLocation, dockerConfig, dockerContext); + } + + private static DockerConfig createDockerConfig(String configLocation) { + Path path = Path.of(configLocation, CONFIG_FILE_NAME); + if (!path.toFile().exists()) { + return DockerConfig.empty(); + } + try { + return DockerConfig.fromJson(readPathContent(path)); + } + catch (JsonProcessingException ex) { + throw new IllegalStateException("Error parsing Docker configuration file '" + path + "'", ex); + } + } + + private static DockerContext createDockerContext(String configLocation, String currentContext) { + if (currentContext == null || DEFAULT_CONTEXT.equals(currentContext)) { + return DockerContext.empty(); + } + Path metaPath = Path.of(configLocation, CONTEXTS_DIR, META_DIR, asHash(currentContext), CONTEXT_FILE_NAME); + Path tlsPath = Path.of(configLocation, CONTEXTS_DIR, TLS_DIR, asHash(currentContext), DOCKER_ENDPOINT); + if (!metaPath.toFile().exists()) { + throw new IllegalArgumentException("Docker context '" + currentContext + "' does not exist"); + } + try { + DockerContext context = DockerContext.fromJson(readPathContent(metaPath)); + if (tlsPath.toFile().isDirectory()) { + return context.withTlsPath(tlsPath.toString()); + } + return context; + } + catch (JsonProcessingException ex) { + throw new IllegalStateException("Error parsing Docker context metadata file '" + metaPath + "'", ex); + } + } + + private static String asHash(String currentContext) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(currentContext.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } + catch (NoSuchAlgorithmException ex) { + return null; + } + } + + private static String readPathContent(Path path) { + try { + return Files.readString(path); + } + catch (IOException ex) { + throw new IllegalStateException("Error reading Docker configuration file '" + path + "'", ex); + } + } + + static final class DockerConfig extends MappedObject { + + private final String currentContext; + + private DockerConfig(JsonNode node) { + super(node, MethodHandles.lookup()); + this.currentContext = valueAt("/currentContext", String.class); + } + + String getCurrentContext() { + return this.currentContext; + } + + static DockerConfig fromJson(String json) throws JsonProcessingException { + return new DockerConfig(SharedObjectMapper.get().readTree(json)); + } + + static DockerConfig empty() { + return new DockerConfig(NullNode.instance); + } + + } + + static final class DockerContext extends MappedObject { + + private final String dockerHost; + + private final Boolean skipTlsVerify; + + private final String tlsPath; + + private DockerContext(JsonNode node, String tlsPath) { + super(node, MethodHandles.lookup()); + this.dockerHost = valueAt("/Endpoints/" + DOCKER_ENDPOINT + "/Host", String.class); + this.skipTlsVerify = valueAt("/Endpoints/" + DOCKER_ENDPOINT + "/SkipTLSVerify", Boolean.class); + this.tlsPath = tlsPath; + } + + String getDockerHost() { + return this.dockerHost; + } + + Boolean isTlsVerify() { + return this.skipTlsVerify != null && !this.skipTlsVerify; + } + + String getTlsPath() { + return this.tlsPath; + } + + DockerContext withTlsPath(String tlsPath) { + return new DockerContext(this.getNode(), tlsPath); + } + + static DockerContext fromJson(String json) throws JsonProcessingException { + return new DockerContext(SharedObjectMapper.get().readTree(json), null); + } + + static DockerContext empty() { + return new DockerContext(NullNode.instance, null); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java index 3db5f5541d57..8d6d381feba4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerHost.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHost.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHost.java index 95272b19d0d3..e19d592df08d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHost.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHost.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -21,6 +21,8 @@ import com.sun.jna.Platform; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.DockerContext; import org.springframework.boot.buildpack.platform.system.Environment; /** @@ -43,6 +45,12 @@ public class ResolvedDockerHost extends DockerHost { private static final String DOCKER_CERT_PATH = "DOCKER_CERT_PATH"; + private static final String DOCKER_CONTEXT = "DOCKER_CONTEXT"; + + ResolvedDockerHost(String address) { + super(address); + } + ResolvedDockerHost(String address, boolean secure, String certificatePath) { super(address, secure, certificatePath); } @@ -66,11 +74,20 @@ public boolean isLocalFileReference() { } } - public static ResolvedDockerHost from(DockerHost dockerHost) { + public static ResolvedDockerHost from(DockerHostConfiguration dockerHost) { return from(Environment.SYSTEM, dockerHost); } - static ResolvedDockerHost from(Environment environment, DockerHost dockerHost) { + static ResolvedDockerHost from(Environment environment, DockerHostConfiguration dockerHost) { + DockerConfigurationMetadata config = DockerConfigurationMetadata.from(environment); + if (environment.get(DOCKER_CONTEXT) != null) { + DockerContext context = config.forContext(environment.get(DOCKER_CONTEXT)); + return new ResolvedDockerHost(context.getDockerHost(), context.isTlsVerify(), context.getTlsPath()); + } + if (dockerHost != null && dockerHost.getContext() != null) { + DockerContext context = config.forContext(dockerHost.getContext()); + return new ResolvedDockerHost(context.getDockerHost(), context.isTlsVerify(), context.getTlsPath()); + } if (environment.get(DOCKER_HOST) != null) { return new ResolvedDockerHost(environment.get(DOCKER_HOST), isTrue(environment.get(DOCKER_TLS_VERIFY)), environment.get(DOCKER_CERT_PATH)); @@ -79,7 +96,11 @@ static ResolvedDockerHost from(Environment environment, DockerHost dockerHost) { return new ResolvedDockerHost(dockerHost.getAddress(), dockerHost.isSecure(), dockerHost.getCertificatePath()); } - return new ResolvedDockerHost(Platform.isWindows() ? WINDOWS_NAMED_PIPE_PATH : DOMAIN_SOCKET_PATH, false, null); + if (config.getContext().getDockerHost() != null) { + DockerContext context = config.getContext(); + return new ResolvedDockerHost(context.getDockerHost(), context.isTlsVerify(), context.getTlsPath()); + } + return new ResolvedDockerHost(Platform.isWindows() ? WINDOWS_NAMED_PIPE_PATH : DOMAIN_SOCKET_PATH); } private static boolean isTrue(String value) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java index 4a8461fa0907..c428155142d8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransport.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -22,6 +22,7 @@ import java.io.OutputStream; import java.net.URI; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost; import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost; import org.springframework.boot.buildpack.platform.io.IOConsumer; @@ -93,7 +94,7 @@ public interface HttpTransport { * @param dockerHost the Docker host information * @return a {@link HttpTransport} instance */ - static HttpTransport create(DockerHost dockerHost) { + static HttpTransport create(DockerHostConfiguration dockerHost) { ResolvedDockerHost host = ResolvedDockerHost.from(dockerHost); HttpTransport remote = RemoteHttpClientTransport.createIfPossible(host); return (remote != null) ? remote : LocalHttpClientTransport.create(host); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java index 81e7c12e9389..cdafa07e40be 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransport.java @@ -20,23 +20,22 @@ import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; -import java.net.URISyntaxException; import java.net.UnknownHostException; import com.sun.jna.Platform; import org.apache.hc.client5.http.DnsResolver; -import org.apache.hc.client5.http.SchemePortResolver; +import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.routing.HttpRoutePlanner; import org.apache.hc.client5.http.socket.ConnectionSocketFactory; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.config.Registry; import org.apache.hc.core5.http.config.RegistryBuilder; import org.apache.hc.core5.http.protocol.HttpContext; -import org.apache.hc.core5.util.Args; import org.apache.hc.core5.util.TimeValue; import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost; @@ -51,26 +50,22 @@ */ final class LocalHttpClientTransport extends HttpClientTransport { - private static final HttpHost LOCAL_DOCKER_HOST; + private static final String DOCKER_SCHEME = "docker"; - static { - try { - LOCAL_DOCKER_HOST = HttpHost.create("docker://localhost"); - } - catch (URISyntaxException ex) { - throw new RuntimeException("Error creating local Docker host address", ex); - } - } + private static final int DEFAULT_DOCKER_PORT = 2376; - private LocalHttpClientTransport(HttpClient client) { - super(client, LOCAL_DOCKER_HOST); + private static final HttpHost LOCAL_DOCKER_HOST = new HttpHost(DOCKER_SCHEME, "localhost", DEFAULT_DOCKER_PORT); + + private LocalHttpClientTransport(HttpClient client, HttpHost host) { + super(client, host); } static LocalHttpClientTransport create(ResolvedDockerHost dockerHost) { HttpClientBuilder builder = HttpClients.custom(); builder.setConnectionManager(new LocalConnectionManager(dockerHost.getAddress())); - builder.setSchemePortResolver(new LocalSchemePortResolver()); - return new LocalHttpClientTransport(builder.build()); + builder.setRoutePlanner(new LocalRoutePlanner()); + HttpHost host = new HttpHost(DOCKER_SCHEME, dockerHost.getAddress()); + return new LocalHttpClientTransport(builder.build(), host); } /** @@ -84,7 +79,7 @@ private static class LocalConnectionManager extends BasicHttpClientConnectionMan private static Registry getRegistry(String host) { RegistryBuilder builder = RegistryBuilder.create(); - builder.register("docker", new LocalConnectionSocketFactory(host)); + builder.register(DOCKER_SCHEME, new LocalConnectionSocketFactory(host)); return builder.build(); } @@ -139,20 +134,13 @@ public Socket connectSocket(TimeValue connectTimeout, Socket socket, HttpHost ho } /** - * {@link SchemePortResolver} for local Docker. + * {@link HttpRoutePlanner} for local Docker. */ - private static final class LocalSchemePortResolver implements SchemePortResolver { - - private static final int DEFAULT_DOCKER_PORT = 2376; + private static final class LocalRoutePlanner implements HttpRoutePlanner { @Override - public int resolve(HttpHost host) { - Args.notNull(host, "HTTP host"); - String name = host.getSchemeName(); - if ("docker".equals(name)) { - return DEFAULT_DOCKER_PORT; - } - return -1; + public HttpRoute determineRoute(HttpHost target, HttpContext context) { + return new HttpRoute(LOCAL_DOCKER_HOST); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/BsdDomainSocket.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/BsdDomainSocket.java index 4fdfeea64200..37b597250d29 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/BsdDomainSocket.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/BsdDomainSocket.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ private SockaddrUn(byte sunFamily, byte[] path) { @Override protected List getFieldOrder() { - return Arrays.asList(new String[] { "sunLen", "sunFamily", "sunPath" }); + return Arrays.asList("sunLen", "sunFamily", "sunPath"); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/LinuxDomainSocket.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/LinuxDomainSocket.java index 24950d6c9fc1..13490f17fa60 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/LinuxDomainSocket.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/socket/LinuxDomainSocket.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ private SockaddrUn(byte sunFamily, byte[] path) { @Override protected List getFieldOrder() { - return Arrays.asList(new String[] { "sunFamily", "sunPath" }); + return Arrays.asList("sunFamily", "sunPath"); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java index e74302a1636f..6e6fce7f50a8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java @@ -233,6 +233,22 @@ void withTagsWhenTagsIsNullThrowsException() throws IOException { .withMessage("Tags must not be null"); } + @Test + void withBuildWorkspaceVolumeAddsWorkspace() throws IOException { + BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); + BuildRequest withWorkspace = request.withBuildWorkspace(Cache.volume("build-workspace")); + assertThat(request.getBuildWorkspace()).isNull(); + assertThat(withWorkspace.getBuildWorkspace()).isEqualTo(Cache.volume("build-workspace")); + } + + @Test + void withBuildWorkspaceBindAddsWorkspace() throws IOException { + BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); + BuildRequest withWorkspace = request.withBuildWorkspace(Cache.bind("/tmp/build-workspace")); + assertThat(request.getBuildWorkspace()).isNull(); + assertThat(withWorkspace.getBuildWorkspace()).isEqualTo(Cache.bind("/tmp/build-workspace")); + } + @Test void withBuildVolumeCacheAddsCache() throws IOException { BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); @@ -241,6 +257,14 @@ void withBuildVolumeCacheAddsCache() throws IOException { assertThat(withCache.getBuildCache()).isEqualTo(Cache.volume("build-volume")); } + @Test + void withBuildBindCacheAddsCache() throws IOException { + BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); + BuildRequest withCache = request.withBuildCache(Cache.bind("/tmp/build-cache")); + assertThat(request.getBuildCache()).isNull(); + assertThat(withCache.getBuildCache()).isEqualTo(Cache.bind("/tmp/build-cache")); + } + @Test void withBuildVolumeCacheWhenCacheIsNullThrowsException() throws IOException { BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); @@ -256,6 +280,14 @@ void withLaunchVolumeCacheAddsCache() throws IOException { assertThat(withCache.getLaunchCache()).isEqualTo(Cache.volume("launch-volume")); } + @Test + void withLaunchBindCacheAddsCache() throws IOException { + BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); + BuildRequest withCache = request.withLaunchCache(Cache.bind("/tmp/launch-cache")); + assertThat(request.getLaunchCache()).isNull(); + assertThat(withCache.getLaunchCache()).isEqualTo(Cache.bind("/tmp/launch-cache")); + } + @Test void withLaunchVolumeCacheWhenCacheIsNullThrowsException() throws IOException { BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); @@ -301,6 +333,13 @@ void withApplicationDirectorySetsApplicationDirectory() throws Exception { assertThat(withAppDir.getApplicationDirectory()).isEqualTo("/application"); } + @Test + void withSecurityOptionsSetsSecurityOptions() throws Exception { + BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")); + BuildRequest withAppDir = request.withSecurityOptions(List.of("label=user:USER", "label=role:ROLE")); + assertThat(withAppDir.getSecurityOptions()).containsExactly("label=user:USER", "label=role:ROLE"); + } + private void hasExpectedJarContent(TarArchive archive) { try { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java index 78ee54874bb0..6f898d1a9817 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/LifecycleTests.java @@ -23,6 +23,7 @@ import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import com.fasterxml.jackson.core.JsonProcessingException; @@ -40,7 +41,7 @@ import org.springframework.boot.buildpack.platform.docker.DockerApi.ContainerApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.ImageApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi; -import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost; import org.springframework.boot.buildpack.platform.docker.type.Binding; import org.springframework.boot.buildpack.platform.docker.type.ContainerConfig; @@ -211,13 +212,27 @@ void executeWithCacheVolumeNamesExecutesPhases() throws Exception { given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId()); given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId()); given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null)); - BuildRequest request = getTestRequest().withBuildCache(Cache.volume("build-volume")) + BuildRequest request = getTestRequest().withBuildWorkspace(Cache.volume("work-volume")) + .withBuildCache(Cache.volume("build-volume")) .withLaunchCache(Cache.volume("launch-volume")); createLifecycle(request).execute(); assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-cache-volumes.json")); assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); } + @Test + void executeWithCacheBindMountsExecutesPhases() throws Exception { + given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId()); + given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId()); + given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null)); + BuildRequest request = getTestRequest().withBuildWorkspace(Cache.bind("/tmp/work")) + .withBuildCache(Cache.bind("/tmp/build-cache")) + .withLaunchCache(Cache.bind("/tmp/launch-cache")); + createLifecycle(request).execute(); + assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-cache-bind-mounts.json")); + assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); + } + @Test void executeWithCreatedDateExecutesPhases() throws Exception { given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId()); @@ -240,13 +255,25 @@ void executeWithApplicationDirectoryExecutesPhases() throws Exception { assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); } + @Test + void executeWithSecurityOptionsExecutesPhases() throws Exception { + given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId()); + given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId()); + given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null)); + BuildRequest request = getTestRequest().withSecurityOptions(List.of("label=user:USER", "label=role:ROLE")); + createLifecycle(request).execute(); + assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-security-opts.json", true)); + assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); + } + @Test void executeWithDockerHostAndRemoteAddressExecutesPhases() throws Exception { given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId()); given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId()); given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null)); BuildRequest request = getTestRequest(); - createLifecycle(request, ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376"))).execute(); + createLifecycle(request, ResolvedDockerHost.from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376"))) + .execute(); assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-inherit-remote.json")); assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); } @@ -257,7 +284,8 @@ void executeWithDockerHostAndLocalAddressExecutesPhases() throws Exception { given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId()); given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null)); BuildRequest request = getTestRequest(); - createLifecycle(request, ResolvedDockerHost.from(new DockerHost("/var/alt.sock"))).execute(); + createLifecycle(request, ResolvedDockerHost.from(DockerHostConfiguration.forAddress("/var/alt.sock"))) + .execute(); assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-inherit-local.json")); assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'"); } @@ -342,12 +370,16 @@ private void assertPhaseWasRun(String name, IOConsumer configCo } private IOConsumer withExpectedConfig(String name) { + return withExpectedConfig(name, false); + } + + private IOConsumer withExpectedConfig(String name, boolean expectSecurityOptAlways) { return (config) -> { try { InputStream in = getClass().getResourceAsStream(name); String jsonString = FileCopyUtils.copyToString(new InputStreamReader(in, StandardCharsets.UTF_8)); JSONObject json = new JSONObject(jsonString); - if (Platform.isWindows()) { + if (!expectSecurityOptAlways && Platform.isWindows()) { JSONObject hostConfig = json.getJSONObject("HostConfig"); hostConfig.remove("SecurityOpt"); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java index 74cefced82ae..1e25ed10a998 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/PrintStreamBuildLogTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -67,7 +67,7 @@ void printsExpectedOutput() throws Exception { Consumer pullRunImageConsumer = log.pullingImage(runImageReference, ImageType.RUNNER); pullRunImageConsumer.accept(new TotalProgressEvent(100)); log.pulledImage(runImage, ImageType.RUNNER); - log.executingLifecycle(request, LifecycleVersion.parse("0.5"), VolumeName.of("pack-abc.cache")); + log.executingLifecycle(request, LifecycleVersion.parse("0.5"), Cache.volume(VolumeName.of("pack-abc.cache"))); Consumer phase1Consumer = log.runningPhase(request, "alphabet"); phase1Consumer.accept(mockLogEvent("one")); phase1Consumer.accept(mockLogEvent("two")); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java new file mode 100644 index 000000000000..b47bbaa3d80c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/DockerConfigurationMetadataTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2012-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.boot.buildpack.platform.docker.configuration; + +import java.io.File; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfigurationMetadata.DockerContext; +import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link DockerConfigurationMetadata}. + * + * @author Scott Frederick + */ +class DockerConfigurationMetadataTests extends AbstractJsonTests { + + private final Map environment = new LinkedHashMap<>(); + + @Test + void configWithContextIsRead() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-context/config.json")); + DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); + assertThat(config.getConfiguration().getCurrentContext()).isEqualTo("test-context"); + assertThat(config.getContext().getDockerHost()).isEqualTo("unix:///home/user/.docker/docker.sock"); + assertThat(config.getContext().isTlsVerify()).isFalse(); + assertThat(config.getContext().getTlsPath()).isNull(); + } + + @Test + void configWithoutContextIsRead() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("without-context/config.json")); + DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); + assertThat(config.getConfiguration().getCurrentContext()).isNull(); + assertThat(config.getContext().getDockerHost()).isNull(); + assertThat(config.getContext().isTlsVerify()).isFalse(); + assertThat(config.getContext().getTlsPath()).isNull(); + } + + @Test + void configWithDefaultContextIsRead() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json")); + DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); + assertThat(config.getConfiguration().getCurrentContext()).isEqualTo("default"); + assertThat(config.getContext().getDockerHost()).isNull(); + assertThat(config.getContext().isTlsVerify()).isFalse(); + assertThat(config.getContext().getTlsPath()).isNull(); + } + + @Test + void configIsReadWithProvidedContext() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json")); + DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); + DockerContext context = config.forContext("test-context"); + assertThat(context.getDockerHost()).isEqualTo("unix:///home/user/.docker/docker.sock"); + assertThat(context.isTlsVerify()).isTrue(); + assertThat(context.getTlsPath()).matches(String.join(Pattern.quote(File.separator), "^.*", + "with-default-context", "contexts", "tls", "[a-zA-z0-9]*", "docker$")); + } + + @Test + void invalidContextThrowsException() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json")); + assertThatIllegalArgumentException() + .isThrownBy(() -> DockerConfigurationMetadata.from(this.environment::get).forContext("invalid-context")) + .withMessageContaining("Docker context 'invalid-context' does not exist"); + } + + @Test + void configIsEmptyWhenConfigFileDoesNotExist() { + this.environment.put("DOCKER_CONFIG", "docker-config-dummy-path"); + DockerConfigurationMetadata config = DockerConfigurationMetadata.from(this.environment::get); + assertThat(config.getConfiguration().getCurrentContext()).isNull(); + assertThat(config.getContext().getDockerHost()).isNull(); + assertThat(config.getContext().isTlsVerify()).isFalse(); + } + + private String pathToResource(String resource) throws URISyntaxException { + URL url = getClass().getResource(resource); + return Paths.get(url.toURI()).getParent().toAbsolutePath().toString(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHostTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHostTests.java index 30a1c358304b..131299849788 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHostTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/configuration/ResolvedDockerHostTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -17,8 +17,11 @@ package org.springframework.boot.buildpack.platform.docker.configuration; import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.LinkedHashMap; import java.util.Map; @@ -28,6 +31,8 @@ import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -41,7 +46,8 @@ class ResolvedDockerHostTests { @Test @DisabledOnOs(OS.WINDOWS) - void resolveWhenDockerHostIsNullReturnsLinuxDefault() { + void resolveWhenDockerHostIsNullReturnsLinuxDefault() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json")); ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, null); assertThat(dockerHost.getAddress()).isEqualTo("/var/run/docker.sock"); assertThat(dockerHost.isSecure()).isFalse(); @@ -50,7 +56,8 @@ void resolveWhenDockerHostIsNullReturnsLinuxDefault() { @Test @EnabledOnOs(OS.WINDOWS) - void resolveWhenDockerHostIsNullReturnsWindowsDefault() { + void resolveWhenDockerHostIsNullReturnsWindowsDefault() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json")); ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, null); assertThat(dockerHost.getAddress()).isEqualTo("//./pipe/docker_engine"); assertThat(dockerHost.isSecure()).isFalse(); @@ -59,8 +66,10 @@ void resolveWhenDockerHostIsNullReturnsWindowsDefault() { @Test @DisabledOnOs(OS.WINDOWS) - void resolveWhenDockerHostAddressIsNullReturnsLinuxDefault() { - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, new DockerHost(null)); + void resolveWhenDockerHostAddressIsNullReturnsLinuxDefault() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json")); + ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, + DockerHostConfiguration.forAddress(null)); assertThat(dockerHost.getAddress()).isEqualTo("/var/run/docker.sock"); assertThat(dockerHost.isSecure()).isFalse(); assertThat(dockerHost.getCertificatePath()).isNull(); @@ -70,7 +79,7 @@ void resolveWhenDockerHostAddressIsNullReturnsLinuxDefault() { void resolveWhenDockerHostAddressIsLocalReturnsAddress(@TempDir Path tempDir) throws IOException { String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString(); ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, - new DockerHost(socketFilePath, false, null)); + DockerHostConfiguration.forAddress(socketFilePath)); assertThat(dockerHost.isLocalFileReference()).isTrue(); assertThat(dockerHost.isRemote()).isFalse(); assertThat(dockerHost.getAddress()).isEqualTo(socketFilePath); @@ -82,7 +91,7 @@ void resolveWhenDockerHostAddressIsLocalReturnsAddress(@TempDir Path tempDir) th void resolveWhenDockerHostAddressIsLocalWithSchemeReturnsAddress(@TempDir Path tempDir) throws IOException { String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString(); ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, - new DockerHost("unix://" + socketFilePath, false, null)); + DockerHostConfiguration.forAddress("unix://" + socketFilePath)); assertThat(dockerHost.isLocalFileReference()).isTrue(); assertThat(dockerHost.isRemote()).isFalse(); assertThat(dockerHost.getAddress()).isEqualTo(socketFilePath); @@ -93,7 +102,7 @@ void resolveWhenDockerHostAddressIsLocalWithSchemeReturnsAddress(@TempDir Path t @Test void resolveWhenDockerHostAddressIsHttpReturnsAddress() { ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, - new DockerHost("http://docker.example.com", false, null)); + DockerHostConfiguration.forAddress("http://docker.example.com")); assertThat(dockerHost.isLocalFileReference()).isFalse(); assertThat(dockerHost.isRemote()).isTrue(); assertThat(dockerHost.getAddress()).isEqualTo("http://docker.example.com"); @@ -104,7 +113,7 @@ void resolveWhenDockerHostAddressIsHttpReturnsAddress() { @Test void resolveWhenDockerHostAddressIsHttpsReturnsAddress() { ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, - new DockerHost("https://docker.example.com", true, "/cert-path")); + DockerHostConfiguration.forAddress("https://docker.example.com", true, "/cert-path")); assertThat(dockerHost.isLocalFileReference()).isFalse(); assertThat(dockerHost.isRemote()).isTrue(); assertThat(dockerHost.getAddress()).isEqualTo("https://docker.example.com"); @@ -115,7 +124,7 @@ void resolveWhenDockerHostAddressIsHttpsReturnsAddress() { @Test void resolveWhenDockerHostAddressIsTcpReturnsAddress() { ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, - new DockerHost("tcp://192.168.99.100:2376", true, "/cert-path")); + DockerHostConfiguration.forAddress("tcp://192.168.99.100:2376", true, "/cert-path")); assertThat(dockerHost.isLocalFileReference()).isFalse(); assertThat(dockerHost.isRemote()).isTrue(); assertThat(dockerHost.getAddress()).isEqualTo("tcp://192.168.99.100:2376"); @@ -128,7 +137,7 @@ void resolveWhenEnvironmentAddressIsLocalReturnsAddress(@TempDir Path tempDir) t String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString(); this.environment.put("DOCKER_HOST", socketFilePath); ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, - new DockerHost("/unused", true, "/unused")); + DockerHostConfiguration.forAddress("/unused")); assertThat(dockerHost.isLocalFileReference()).isTrue(); assertThat(dockerHost.isRemote()).isFalse(); assertThat(dockerHost.getAddress()).isEqualTo(socketFilePath); @@ -141,7 +150,7 @@ void resolveWhenEnvironmentAddressIsLocalWithSchemeReturnsAddress(@TempDir Path String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString(); this.environment.put("DOCKER_HOST", "unix://" + socketFilePath); ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, - new DockerHost("/unused", true, "/unused")); + DockerHostConfiguration.forAddress("/unused")); assertThat(dockerHost.isLocalFileReference()).isTrue(); assertThat(dockerHost.isRemote()).isFalse(); assertThat(dockerHost.getAddress()).isEqualTo(socketFilePath); @@ -155,7 +164,7 @@ void resolveWhenEnvironmentAddressIsTcpReturnsAddress() { this.environment.put("DOCKER_TLS_VERIFY", "1"); this.environment.put("DOCKER_CERT_PATH", "/cert-path"); ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, - new DockerHost("tcp://1.1.1.1", false, "/unused")); + DockerHostConfiguration.forAddress("tcp://1.1.1.1")); assertThat(dockerHost.isLocalFileReference()).isFalse(); assertThat(dockerHost.isRemote()).isTrue(); assertThat(dockerHost.getAddress()).isEqualTo("tcp://192.168.99.100:2376"); @@ -163,4 +172,39 @@ void resolveWhenEnvironmentAddressIsTcpReturnsAddress() { assertThat(dockerHost.getCertificatePath()).isEqualTo("/cert-path"); } + @Test + void resolveWithDockerHostContextReturnsAddress() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-default-context/config.json")); + ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, + DockerHostConfiguration.forContext("test-context")); + assertThat(dockerHost.getAddress()).isEqualTo("/home/user/.docker/docker.sock"); + assertThat(dockerHost.isSecure()).isTrue(); + assertThat(dockerHost.getCertificatePath()).isNotNull(); + } + + @Test + void resolveWithDockerConfigMetadataContextReturnsAddress() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-context/config.json")); + ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, null); + assertThat(dockerHost.getAddress()).isEqualTo("/home/user/.docker/docker.sock"); + assertThat(dockerHost.isSecure()).isFalse(); + assertThat(dockerHost.getCertificatePath()).isNull(); + } + + @Test + void resolveWhenEnvironmentHasAddressAndContextPrefersContext() throws Exception { + this.environment.put("DOCKER_CONFIG", pathToResource("with-context/config.json")); + this.environment.put("DOCKER_CONTEXT", "test-context"); + this.environment.put("DOCKER_HOST", "notused"); + ResolvedDockerHost dockerHost = ResolvedDockerHost.from(this.environment::get, null); + assertThat(dockerHost.getAddress()).isEqualTo("/home/user/.docker/docker.sock"); + assertThat(dockerHost.isSecure()).isFalse(); + assertThat(dockerHost.getCertificatePath()).isNull(); + } + + private String pathToResource(String resource) throws URISyntaxException { + URL url = getClass().getResource(resource); + return Paths.get(url.toURI()).getParent().toAbsolutePath().toString(); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransportTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransportTests.java index c04cd5e719db..a383d0240ec9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransportTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpTransportTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -23,7 +23,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; import static org.assertj.core.api.Assertions.assertThat; @@ -37,21 +37,21 @@ class HttpTransportTests { @Test void createWhenDockerHostVariableIsAddressReturnsRemote() { - HttpTransport transport = HttpTransport.create(new DockerHost("tcp://192.168.1.0")); + HttpTransport transport = HttpTransport.create(DockerHostConfiguration.forAddress("tcp://192.168.1.0")); assertThat(transport).isInstanceOf(RemoteHttpClientTransport.class); } @Test void createWhenDockerHostVariableIsFileReturnsLocal(@TempDir Path tempDir) throws IOException { String dummySocketFilePath = Files.createTempFile(tempDir, "http-transport", null).toAbsolutePath().toString(); - HttpTransport transport = HttpTransport.create(new DockerHost(dummySocketFilePath)); + HttpTransport transport = HttpTransport.create(DockerHostConfiguration.forAddress(dummySocketFilePath)); assertThat(transport).isInstanceOf(LocalHttpClientTransport.class); } @Test void createWhenDockerHostVariableIsUnixSchemePrefixedFileReturnsLocal(@TempDir Path tempDir) throws IOException { String dummySocketFilePath = "unix://" + Files.createTempFile(tempDir, "http-transport", null).toAbsolutePath(); - HttpTransport transport = HttpTransport.create(new DockerHost(dummySocketFilePath)); + HttpTransport transport = HttpTransport.create(DockerHostConfiguration.forAddress(dummySocketFilePath)); assertThat(transport).isInstanceOf(LocalHttpClientTransport.class); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransportTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransportTests.java index 78ff1d0c71fe..81cd780c5b04 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransportTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/LocalHttpClientTransportTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -24,7 +24,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost; import static org.assertj.core.api.Assertions.assertThat; @@ -39,24 +39,28 @@ class LocalHttpClientTransportTests { @Test void createWhenDockerHostIsFileReturnsTransport(@TempDir Path tempDir) throws IOException { String socketFilePath = Files.createTempFile(tempDir, "remote-transport", null).toAbsolutePath().toString(); - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost(socketFilePath)); + ResolvedDockerHost dockerHost = ResolvedDockerHost.from(DockerHostConfiguration.forAddress(socketFilePath)); LocalHttpClientTransport transport = LocalHttpClientTransport.create(dockerHost); assertThat(transport).isNotNull(); + assertThat(transport.getHost().toHostString()).isEqualTo(socketFilePath); } @Test void createWhenDockerHostIsFileThatDoesNotExistReturnsTransport(@TempDir Path tempDir) { String socketFilePath = Paths.get(tempDir.toString(), "dummy").toAbsolutePath().toString(); - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost(socketFilePath)); + ResolvedDockerHost dockerHost = ResolvedDockerHost.from(DockerHostConfiguration.forAddress(socketFilePath)); LocalHttpClientTransport transport = LocalHttpClientTransport.create(dockerHost); assertThat(transport).isNotNull(); + assertThat(transport.getHost().toHostString()).isEqualTo(socketFilePath); } @Test void createWhenDockerHostIsAddressReturnsTransport() { - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376")); + ResolvedDockerHost dockerHost = ResolvedDockerHost + .from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376")); LocalHttpClientTransport transport = LocalHttpClientTransport.create(dockerHost); assertThat(transport).isNotNull(); + assertThat(transport.getHost().toHostString()).isEqualTo("tcp://192.168.1.2:2376"); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java index a56373709eff..529709d5cc38 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/RemoteHttpClientTransportTests.java @@ -23,7 +23,7 @@ import org.apache.hc.core5.http.HttpHost; import org.junit.jupiter.api.Test; -import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; import org.springframework.boot.buildpack.platform.docker.configuration.ResolvedDockerHost; import org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory; @@ -49,28 +49,31 @@ void createIfPossibleWhenDockerHostIsNotSetReturnsNull() { @Test void createIfPossibleWhenDockerHostIsDefaultReturnsNull() { - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost(null)); + ResolvedDockerHost dockerHost = ResolvedDockerHost.from(DockerHostConfiguration.forAddress(null)); RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost); assertThat(transport).isNull(); } @Test void createIfPossibleWhenDockerHostIsFileReturnsNull() { - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("unix:///var/run/socket.sock")); + ResolvedDockerHost dockerHost = ResolvedDockerHost + .from(DockerHostConfiguration.forAddress("unix:///var/run/socket.sock")); RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost); assertThat(transport).isNull(); } @Test void createIfPossibleWhenDockerHostIsAddressReturnsTransport() { - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376")); + ResolvedDockerHost dockerHost = ResolvedDockerHost + .from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376")); RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost); assertThat(transport).isNotNull(); } @Test void createIfPossibleWhenNoTlsVerifyUsesHttp() { - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376")); + ResolvedDockerHost dockerHost = ResolvedDockerHost + .from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376")); RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost); assertThat(transport.getHost()).satisfies(hostOf("http", "192.168.1.2", 2376)); } @@ -80,14 +83,15 @@ void createIfPossibleWhenTlsVerifyUsesHttps() throws Exception { SslContextFactory sslContextFactory = mock(SslContextFactory.class); given(sslContextFactory.forDirectory("/test-cert-path")).willReturn(SSLContext.getDefault()); ResolvedDockerHost dockerHost = ResolvedDockerHost - .from(new DockerHost("tcp://192.168.1.2:2376", true, "/test-cert-path")); + .from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376", true, "/test-cert-path")); RemoteHttpClientTransport transport = RemoteHttpClientTransport.createIfPossible(dockerHost, sslContextFactory); assertThat(transport.getHost()).satisfies(hostOf("https", "192.168.1.2", 2376)); } @Test void createIfPossibleWhenTlsVerifyWithMissingCertPathThrowsException() { - ResolvedDockerHost dockerHost = ResolvedDockerHost.from(new DockerHost("tcp://192.168.1.2:2376", true, null)); + ResolvedDockerHost dockerHost = ResolvedDockerHost + .from(DockerHostConfiguration.forAddress("tcp://192.168.1.2:2376", true, null)); assertThatIllegalArgumentException().isThrownBy(() -> RemoteHttpClientTransport.createIfPossible(dockerHost)) .withMessageContaining("Docker host TLS verification requires trust material"); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java index 31cd8a768cdf..8f0eaccd8453 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java @@ -52,7 +52,7 @@ void getLayersWithNoLayersReturnsEmptyList() throws Exception { String content = "[{\"Layers\": []}]"; ImageArchiveManifest manifest = new ImageArchiveManifest(getObjectMapper().readTree(content)); assertThat(manifest.getEntries()).hasSize(1); - assertThat(manifest.getEntries().get(0).getLayers()).hasSize(0); + assertThat(manifest.getEntries().get(0).getLayers()).isEmpty(); } @Test diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-bind-mounts.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-bind-mounts.json new file mode 100644 index 000000000000..7259fc11af77 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-bind-mounts.json @@ -0,0 +1,39 @@ +{ + "User": "root", + "Image": "pack.local/ephemeral-builder", + "Cmd": [ + "/cnb/lifecycle/creator", + "-app", + "/workspace", + "-platform", + "/platform", + "-run-image", + "docker.io/cloudfoundry/run:latest", + "-layers", + "/layers", + "-cache-dir", + "/cache", + "-launch-cache", + "/launch-cache", + "-daemon", + "docker.io/library/my-application:latest" + ], + "Env": [ + "CNB_PLATFORM_API=0.8" + ], + "Labels": { + "author": "spring-boot" + }, + "HostConfig": { + "Binds": [ + "/var/run/docker.sock:/var/run/docker.sock", + "/tmp/work-layers:/layers", + "/tmp/work-app:/workspace", + "/tmp/build-cache:/cache", + "/tmp/launch-cache:/launch-cache" + ], + "SecurityOpt" : [ + "label=disable" + ] + } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json index 7bd3d9a24ca0..0f611d5d059c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-cache-volumes.json @@ -27,8 +27,8 @@ "HostConfig": { "Binds": [ "/var/run/docker.sock:/var/run/docker.sock", - "pack-layers-aaaaaaaaaa:/layers", - "pack-app-aaaaaaaaaa:/workspace", + "work-volume-layers:/layers", + "work-volume-app:/workspace", "build-volume:/cache", "launch-volume:/launch-cache" ], diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-security-opts.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-security-opts.json new file mode 100644 index 000000000000..c47bd7f9ffd7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/build/lifecycle-creator-security-opts.json @@ -0,0 +1,40 @@ +{ + "User": "root", + "Image": "pack.local/ephemeral-builder", + "Cmd": [ + "/cnb/lifecycle/creator", + "-app", + "/workspace", + "-platform", + "/platform", + "-run-image", + "docker.io/cloudfoundry/run:latest", + "-layers", + "/layers", + "-cache-dir", + "/cache", + "-launch-cache", + "/launch-cache", + "-daemon", + "docker.io/library/my-application:latest" + ], + "Env": [ + "CNB_PLATFORM_API=0.8" + ], + "Labels": { + "author": "spring-boot" + }, + "HostConfig": { + "Binds": [ + "/var/run/docker.sock:/var/run/docker.sock", + "pack-layers-aaaaaaaaaa:/layers", + "pack-app-aaaaaaaaaa:/workspace", + "pack-cache-b35197ac41ea.build:/cache", + "pack-cache-b35197ac41ea.launch:/launch-cache" + ], + "SecurityOpt" : [ + "label=user:USER", + "label=role:ROLE" + ] + } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/config.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/config.json new file mode 100644 index 000000000000..7e3fa77f5bfe --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/config.json @@ -0,0 +1,3 @@ +{ + "currentContext": "test-context" +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json new file mode 100644 index 000000000000..fa4655b1a026 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json @@ -0,0 +1,12 @@ +{ + "Name": "test-context", + "Metadata": { + "Description": "A context for testing" + }, + "Endpoints": { + "docker": { + "Host": "unix:///home/user/.docker/docker.sock", + "SkipTLSVerify": true + } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/config.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/config.json new file mode 100644 index 000000000000..6eaf50253da3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/config.json @@ -0,0 +1,3 @@ +{ + "currentContext": "default" +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json new file mode 100644 index 000000000000..f072aa2647e2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/meta/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/meta.json @@ -0,0 +1,12 @@ +{ + "Name": "test-context", + "Metadata": { + "Description": "A context for testing" + }, + "Endpoints": { + "docker": { + "Host": "unix:///home/user/.docker/docker.sock", + "SkipTLSVerify": false + } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/tls/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/docker/cert.pem b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/tls/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/docker/cert.pem new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/tls/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/docker/key.pem b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/with-default-context/contexts/tls/ea1b2003cc8155cb8af43960c89a4c1e28777d6fd848ff3422cf375329c2626d/docker/key.pem new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/without-context/config.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/without-context/config.json new file mode 100644 index 000000000000..2c63c0851048 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/configuration/without-context/config.json @@ -0,0 +1,2 @@ +{ +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-cli/build.gradle index 35714e676f53..bf42c6816213 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-cli/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/build.gradle @@ -35,7 +35,7 @@ dependencies { intTestImplementation("org.junit.jupiter:junit-jupiter") intTestImplementation("org.springframework:spring-core") - loader(project(":spring-boot-project:spring-boot-tools:spring-boot-loader")) + loader(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-classic")) testImplementation(project(":spring-boot-project:spring-boot")) testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) @@ -66,7 +66,7 @@ task fullJar(type: Jar) { } manifest { attributes( - "Main-Class": "org.springframework.boot.loader.JarLauncher", + "Main-Class": "org.springframework.boot.loader.launch.JarLauncher", "Start-Class": "org.springframework.boot.cli.SpringCli" ) } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/content/bin/spring.bat b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/content/bin/spring.bat index c9c0081c06f7..3bec92853213 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/content/bin/spring.bat +++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/content/bin/spring.bat @@ -59,7 +59,7 @@ set CMD_LINE_ARGS=%$ @rem Setup the command line set CLASSPATH=%SPRING_HOME%\lib\* -"%JAVA_EXE%" %JAVA_OPTS% -cp "%CLASSPATH%" org.springframework.boot.loader.JarLauncher %CMD_LINE_ARGS% +"%JAVA_EXE%" %JAVA_OPTS% -cp "%CLASSPATH%" org.springframework.boot.loader.launch.JarLauncher %CMD_LINE_ARGS% :end @rem End local scope for the variables with windows NT shell diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/executablecontent/bin/spring b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/executablecontent/bin/spring index 0e025b27d6f7..dda4e9b2819b 100755 --- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/executablecontent/bin/spring +++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/executablecontent/bin/spring @@ -115,4 +115,4 @@ if $cygwin; then fi IFS=" " read -r -a javaOpts <<< "$JAVA_OPTS" -exec "${JAVA_HOME}/bin/java" "${javaOpts[@]}" -cp "$CLASSPATH" org.springframework.boot.loader.JarLauncher "$@" +exec "${JAVA_HOME}/bin/java" "${javaOpts[@]}" -cp "$CLASSPATH" org.springframework.boot.loader.launch.JarLauncher "$@" diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/core/HelpCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/core/HelpCommand.java index 63291a6b7b92..6cae752f3f55 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/core/HelpCommand.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/core/HelpCommand.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -81,10 +81,7 @@ public String getUsageHelp() { } private boolean isHelpShown(Command command) { - if (command instanceof HelpCommand || command instanceof HintCommand) { - return false; - } - return true; + return !(command instanceof HelpCommand) && !(command instanceof HintCommand); } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/shell/ForkProcessCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/shell/ForkProcessCommand.java index a3c282faf429..eb2336d75ca8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/shell/ForkProcessCommand.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/shell/ForkProcessCommand.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -33,7 +33,7 @@ */ class ForkProcessCommand extends RunProcessCommand { - private static final String MAIN_CLASS = "org.springframework.boot.loader.JarLauncher"; + private static final String MAIN_CLASS = "org.springframework.boot.loader.launch.JarLauncher"; private final Command command; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle new file mode 100644 index 000000000000..186a2cff85a1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle @@ -0,0 +1,58 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" +} + +description = "Spring Boot Configuration Metadata Changelog Generator" + +configurations { + oldMetadata + newMetadata +} + +dependencies { + implementation(enforcedPlatform(project(":spring-boot-project:spring-boot-dependencies"))) + implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata")) + + testImplementation(enforcedPlatform(project(":spring-boot-project:spring-boot-dependencies"))) + testImplementation("org.assertj:assertj-core") + testImplementation("org.junit.jupiter:junit-jupiter") +} + +if (project.hasProperty("oldVersion") && project.hasProperty("newVersion")) { + dependencies { + ["spring-boot", + "spring-boot-actuator", + "spring-boot-actuator-autoconfigure", + "spring-boot-autoconfigure", + "spring-boot-devtools", + "spring-boot-test-autoconfigure"].each { + oldMetadata("org.springframework.boot:$it:$oldVersion") + newMetadata("org.springframework.boot:$it:$newVersion") + } + } + + def prepareOldMetadata = tasks.register("prepareOldMetadata", Sync) { + from(configurations.oldMetadata) + if (project.hasProperty("oldVersion")) { + destinationDir = project.file("build/configuration-metadata-diff/$oldVersion") + } + } + + def prepareNewMetadata = tasks.register("prepareNewMetadata", Sync) { + from(configurations.newMetadata) + if (project.hasProperty("newVersion")) { + destinationDir = project.file("build/configuration-metadata-diff/$newVersion") + } + } + + tasks.register("generate", JavaExec) { + inputs.files(prepareOldMetadata, prepareNewMetadata) + outputs.file(project.file("build/configuration-metadata-changelog.adoc")) + classpath = sourceSets.main.runtimeClasspath + mainClass = 'org.springframework.boot.configurationmetadata.changelog.ChangelogGenerator' + if (project.hasProperty("oldVersion") && project.hasProperty("newVersion")) { + args = [project.file("build/configuration-metadata-diff/$oldVersion"), project.file("build/configuration-metadata-diff/$newVersion"), project.file("build/configuration-metadata-changelog.adoc")] + } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Changelog.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Changelog.java new file mode 100644 index 000000000000..964298fe567c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Changelog.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty; +import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository; + +/** + * A changelog containing differences computed from two repositories of configuration + * metadata. + * + * @param oldVersionNumber the name of the old version + * @param newVersionNumber the name of the new version + * @param differences the differences + * @author Stephane Nicoll + * @author Andy Wilkinson + * @author Phillip Webb + */ +record Changelog(String oldVersionNumber, String newVersionNumber, List differences) { + + static Changelog of(String oldVersionNumber, ConfigurationMetadataRepository oldMetadata, String newVersionNumber, + ConfigurationMetadataRepository newMetadata) { + return new Changelog(oldVersionNumber, newVersionNumber, computeDifferences(oldMetadata, newMetadata)); + } + + static List computeDifferences(ConfigurationMetadataRepository oldMetadata, + ConfigurationMetadataRepository newMetadata) { + List seenIds = new ArrayList<>(); + List differences = new ArrayList<>(); + for (ConfigurationMetadataProperty oldProperty : oldMetadata.getAllProperties().values()) { + String id = oldProperty.getId(); + seenIds.add(id); + ConfigurationMetadataProperty newProperty = newMetadata.getAllProperties().get(id); + Difference difference = Difference.compute(oldProperty, newProperty); + if (difference != null) { + differences.add(difference); + } + } + for (ConfigurationMetadataProperty newProperty : newMetadata.getAllProperties().values()) { + if ((!seenIds.contains(newProperty.getId())) && (!newProperty.isDeprecated())) { + differences.add(new Difference(DifferenceType.ADDED, null, newProperty)); + } + } + return List.copyOf(differences); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGenerator.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGenerator.java new file mode 100644 index 000000000000..9d1ee1d62282 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGenerator.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +import java.io.File; +import java.io.IOException; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository; +import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepositoryJsonBuilder; + +/** + * Generates a configuration metadata changelog. Requires three arguments: + * + *
    + *
  1. The path of a directory containing jar files of the old version + *
  2. The path of a directory containing jar files of the new version + *
  3. The path of a file to which the asciidoc changelog will be written + *
+ * + * The name of each directory will be used as version numbers in generated changelog. + * + * @author Andy Wilkinson + * @author Phillip Webb + * @since 3.2.0 + */ +public final class ChangelogGenerator { + + private ChangelogGenerator() { + } + + public static void main(String[] args) throws IOException { + generate(new File(args[0]), new File(args[1]), new File(args[2])); + } + + private static void generate(File oldDir, File newDir, File out) throws IOException { + String oldVersionNumber = oldDir.getName(); + ConfigurationMetadataRepository oldMetadata = buildRepository(oldDir); + String newVersionNumber = newDir.getName(); + ConfigurationMetadataRepository newMetadata = buildRepository(newDir); + Changelog changelog = Changelog.of(oldVersionNumber, oldMetadata, newVersionNumber, newMetadata); + try (ChangelogWriter writer = new ChangelogWriter(out)) { + writer.write(changelog); + } + System.out.println("%nConfiguration metadata changelog written to '%s'".formatted(out)); + } + + static ConfigurationMetadataRepository buildRepository(File directory) { + ConfigurationMetadataRepositoryJsonBuilder builder = ConfigurationMetadataRepositoryJsonBuilder.create(); + for (File file : directory.listFiles()) { + try (JarFile jarFile = new JarFile(file)) { + JarEntry metadataEntry = jarFile.getJarEntry("META-INF/spring-configuration-metadata.json"); + if (metadataEntry != null) { + builder.withJsonResource(jarFile.getInputStream(metadataEntry)); + } + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + } + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriter.java new file mode 100644 index 000000000000..92ffcfada218 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriter.java @@ -0,0 +1,235 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Writer; +import java.text.BreakIterator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty; +import org.springframework.boot.configurationmetadata.Deprecation; + +/** + * Writes a {@link Changelog} using asciidoc markup. + * + * @author Stephane Nicoll + * @author Andy Wilkinson + * @author Phillip Webb + * @author Moritz Halbritter + */ +class ChangelogWriter implements AutoCloseable { + + private static final Comparator COMPARING_ID = Comparator + .comparing(ConfigurationMetadataProperty::getId); + + private final PrintWriter out; + + ChangelogWriter(File out) throws IOException { + this(new FileWriter(out)); + } + + ChangelogWriter(Writer out) { + this.out = new PrintWriter(out); + } + + void write(Changelog changelog) { + String oldVersionNumber = changelog.oldVersionNumber(); + String newVersionNumber = changelog.newVersionNumber(); + Map> differencesByType = collateByType(changelog); + write("Configuration property changes between `%s` and `%s`%n", oldVersionNumber, newVersionNumber); + write("%n%n%n== Deprecated in %s%n", newVersionNumber); + writeDeprecated(differencesByType.get(DifferenceType.DEPRECATED)); + write("%n%n%n== Added in %s%n", newVersionNumber); + writeAdded(differencesByType.get(DifferenceType.ADDED)); + write("%n%n%n== Removed in %s%n", newVersionNumber); + writeRemoved(differencesByType.get(DifferenceType.DELETED), differencesByType.get(DifferenceType.DEPRECATED)); + } + + private Map> collateByType(Changelog differences) { + Map> byType = new HashMap<>(); + for (DifferenceType type : DifferenceType.values()) { + byType.put(type, new ArrayList<>()); + } + for (Difference difference : differences.differences()) { + byType.get(difference.type()).add(difference); + } + return byType; + } + + private void writeDeprecated(List differences) { + List rows = sortProperties(differences, Difference::newProperty).stream() + .filter(this::isDeprecatedInRelease) + .toList(); + writeTable("| Key | Replacement | Reason", rows, this::writeDeprecated); + } + + private void writeDeprecated(Difference difference) { + writeDeprecatedPropertyRow(difference.newProperty()); + } + + private void writeAdded(List differences) { + List rows = sortProperties(differences, Difference::newProperty); + writeTable("| Key | Default value | Description", rows, this::writeAdded); + } + + private void writeAdded(Difference difference) { + writeRegularPropertyRow(difference.newProperty()); + } + + private void writeRemoved(List deleted, List deprecated) { + List rows = getRemoved(deleted, deprecated); + writeTable("| Key | Replacement | Reason", rows, this::writeRemoved); + } + + private List getRemoved(List deleted, List deprecated) { + List result = new ArrayList<>(deleted); + deprecated.stream().filter(Predicate.not(this::isDeprecatedInRelease)).forEach(result::remove); + return sortProperties(result, + (difference) -> getFirstNonNull(difference, Difference::oldProperty, Difference::newProperty)); + } + + private void writeRemoved(Difference difference) { + writeDeprecatedPropertyRow(getFirstNonNull(difference, Difference::newProperty, Difference::oldProperty)); + } + + private List sortProperties(List differences, + Function extractor) { + return differences.stream().sorted(Comparator.comparing(extractor, COMPARING_ID)).toList(); + } + + @SafeVarargs + @SuppressWarnings("varargs") + private P getFirstNonNull(T t, Function... extractors) { + return Stream.of(extractors) + .map((extractor) -> extractor.apply(t)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private void writeTable(String header, List rows, Consumer action) { + if (rows.isEmpty()) { + write("_None_.%n"); + } + else { + writeTableBreak(); + write(header + "%n%n"); + for (Iterator iterator = rows.iterator(); iterator.hasNext();) { + action.accept(iterator.next()); + write((!iterator.hasNext()) ? null : "%n"); + } + writeTableBreak(); + } + } + + private void writeTableBreak() { + write("|======================%n"); + } + + private void writeRegularPropertyRow(ConfigurationMetadataProperty property) { + writeCell(monospace(property.getId())); + writeCell(monospace(asString(property.getDefaultValue()))); + writeCell(property.getShortDescription()); + } + + private void writeDeprecatedPropertyRow(ConfigurationMetadataProperty property) { + Deprecation deprecation = (property.getDeprecation() != null) ? property.getDeprecation() : new Deprecation(); + writeCell(monospace(property.getId())); + writeCell(monospace(deprecation.getReplacement())); + writeCell(getFirstSentence(deprecation.getReason())); + } + + private String getFirstSentence(String text) { + if (text == null) { + return null; + } + int dot = text.indexOf('.'); + if (dot != -1) { + BreakIterator breakIterator = BreakIterator.getSentenceInstance(Locale.US); + breakIterator.setText(text); + String sentence = text.substring(breakIterator.first(), breakIterator.next()).trim(); + return removeSpaceBetweenLine(sentence); + } + String[] lines = text.split(System.lineSeparator()); + return lines[0].trim(); + } + + private String removeSpaceBetweenLine(String text) { + String[] lines = text.split(System.lineSeparator()); + return Arrays.stream(lines).map(String::trim).collect(Collectors.joining(" ")); + } + + private boolean isDeprecatedInRelease(Difference difference) { + Deprecation deprecation = difference.newProperty().getDeprecation(); + return (deprecation != null) && (deprecation.getLevel() != Deprecation.Level.ERROR); + } + + private String monospace(String value) { + return (value != null) ? "`%s`".formatted(value) : null; + } + + private void writeCell(String content) { + if (content == null) { + write("|%n"); + } + else { + String escaped = escapeForTableCell(content); + write("| %s%n".formatted(escaped)); + } + } + + private String escapeForTableCell(String content) { + return content.replace("|", "\\|"); + } + + private void write(String format, Object... args) { + if (format != null) { + Object[] strings = Arrays.stream(args).map(this::asString).toArray(); + this.out.append(format.formatted(strings)); + } + } + + private String asString(Object value) { + if (value instanceof Object[] array) { + return Stream.of(array).map(this::asString).collect(Collectors.joining(", ")); + } + return (value != null) ? value.toString() : null; + } + + @Override + public void close() { + this.out.close(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Difference.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Difference.java new file mode 100644 index 000000000000..8d0fb66cfa7e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/Difference.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty; +import org.springframework.boot.configurationmetadata.Deprecation.Level; + +/** + * A difference the metadata. + * + * @param type the type of the difference + * @param oldProperty the old property + * @param newProperty the new property + * @author Stephane Nicoll + * @author Andy Wilkinson + * @author Phillip Webb + */ +record Difference(DifferenceType type, ConfigurationMetadataProperty oldProperty, + ConfigurationMetadataProperty newProperty) { + + static Difference compute(ConfigurationMetadataProperty oldProperty, ConfigurationMetadataProperty newProperty) { + if (newProperty == null) { + if (!(oldProperty.isDeprecated() && oldProperty.getDeprecation().getLevel() == Level.ERROR)) { + return new Difference(DifferenceType.DELETED, oldProperty, null); + } + return null; + } + if (newProperty.isDeprecated() && !oldProperty.isDeprecated()) { + return new Difference(DifferenceType.DEPRECATED, oldProperty, newProperty); + } + if (oldProperty.isDeprecated() && oldProperty.getDeprecation().getLevel() == Level.WARNING + && newProperty.isDeprecated() && newProperty.getDeprecation().getLevel() == Level.ERROR) { + return new Difference(DifferenceType.DELETED, oldProperty, newProperty); + } + return null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/DifferenceType.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/DifferenceType.java new file mode 100644 index 000000000000..b673310b4072 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/DifferenceType.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +/** + * The type of a difference in the metadata. + * + * @author Andy Wilkinson + */ +enum DifferenceType { + + /** + * The entry has been added. + */ + ADDED, + + /** + * The entry has been made deprecated. It may or may not still exist in the previous + * version. + */ + DEPRECATED, + + /** + * The entry has been deleted. + */ + DELETED + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java new file mode 100644 index 000000000000..96eca3173f88 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Spring Boot configuration metadata changelog generator. + */ +package org.springframework.boot.configurationmetadata.changelog; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGeneratorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGeneratorTests.java new file mode 100644 index 000000000000..efa1760c2736 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogGeneratorTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ChangelogGenerator}. + * + * @author Phillip Webb + */ +class ChangelogGeneratorTests { + + @TempDir + File temp; + + @Test + void generateChangeLog() throws IOException { + File oldJars = new File(this.temp, "1.0"); + addJar(oldJars, "sample-1.0.json"); + File newJars = new File(this.temp, "2.0"); + addJar(newJars, "sample-2.0.json"); + File out = new File(this.temp, "changes.adoc"); + String[] args = new String[] { oldJars.getAbsolutePath(), newJars.getAbsolutePath(), out.getAbsolutePath() }; + ChangelogGenerator.main(args); + assertThat(out).usingCharset(StandardCharsets.UTF_8) + .hasSameTextualContentAs(new File("src/test/resources/sample.adoc")); + } + + private void addJar(File directory, String filename) throws IOException { + directory.mkdirs(); + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(new File(directory, "sample.jar")))) { + out.putNextEntry(new ZipEntry("META-INF/spring-configuration-metadata.json")); + try (InputStream in = new FileInputStream("src/test/resources/" + filename)) { + in.transferTo(out); + out.closeEntry(); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogTests.java new file mode 100644 index 000000000000..5057cb8087ee --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Changelog}. + * + * @author Stephane Nicoll + * @author Andy Wilkinson + */ +class ChangelogTests { + + @Test + void diffContainsDifferencesBetweenLeftAndRightInputs() { + Changelog differences = TestChangelog.load(); + assertThat(differences).isNotNull(); + assertThat(differences.oldVersionNumber()).isEqualTo("1.0"); + assertThat(differences.newVersionNumber()).isEqualTo("2.0"); + assertThat(differences.differences()).hasSize(4); + List added = differences.differences() + .stream() + .filter((difference) -> difference.type() == DifferenceType.ADDED) + .toList(); + assertThat(added).hasSize(1); + assertProperty(added.get(0).newProperty(), "test.add", String.class, "new"); + List deleted = differences.differences() + .stream() + .filter((difference) -> difference.type() == DifferenceType.DELETED) + .toList(); + assertThat(deleted).hasSize(2) + .anySatisfy((entry) -> assertProperty(entry.oldProperty(), "test.delete", String.class, "delete")) + .anySatisfy( + (entry) -> assertProperty(entry.newProperty(), "test.delete.deprecated", String.class, "delete")); + List deprecated = differences.differences() + .stream() + .filter((difference) -> difference.type() == DifferenceType.DEPRECATED) + .toList(); + assertThat(deprecated).hasSize(1); + assertProperty(deprecated.get(0).oldProperty(), "test.deprecate", String.class, "wrong"); + assertProperty(deprecated.get(0).newProperty(), "test.deprecate", String.class, "wrong"); + } + + private void assertProperty(ConfigurationMetadataProperty property, String id, Class type, Object defaultValue) { + assertThat(property).isNotNull(); + assertThat(property.getId()).isEqualTo(id); + assertThat(property.getType()).isEqualTo(type.getName()); + assertThat(property.getDefaultValue()).isEqualTo(defaultValue); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriterTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriterTests.java new file mode 100644 index 000000000000..5e72e3b567d0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ChangelogWriterTests.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +import java.io.File; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; + +import org.assertj.core.util.Files; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ChangelogWriter}. + * + * @author Phillip Webb + */ +class ChangelogWriterTests { + + @Test + void writeChangelog() { + StringWriter out = new StringWriter(); + try (ChangelogWriter writer = new ChangelogWriter(out)) { + writer.write(TestChangelog.load()); + } + String expected = Files.contentOf(new File("src/test/resources/sample.adoc"), StandardCharsets.UTF_8); + assertThat(out).hasToString(expected); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/TestChangelog.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/TestChangelog.java new file mode 100644 index 000000000000..58a1c34642b7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/TestChangelog.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-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.boot.configurationmetadata.changelog; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository; +import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepositoryJsonBuilder; + +/** + * Factory to create test {@link Changelog} instance. + * + * @author Andy Wilkinson + * @author Phillip Webb + */ +final class TestChangelog { + + private TestChangelog() { + } + + static Changelog load() { + ConfigurationMetadataRepository previousRepository = load("sample-1.0.json"); + ConfigurationMetadataRepository repository = load("sample-2.0.json"); + return Changelog.of("1.0", previousRepository, "2.0", repository); + } + + private static ConfigurationMetadataRepository load(String filename) { + try (InputStream inputStream = new FileInputStream("src/test/resources/" + filename)) { + return ConfigurationMetadataRepositoryJsonBuilder.create(inputStream).build(); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json new file mode 100644 index 000000000000..a0584bc5695b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json @@ -0,0 +1,31 @@ +{ + "properties": [ + { + "name": "test.equal", + "type": "java.lang.String", + "description": "Test equality.", + "defaultValue": "test" + }, + { + "name": "test.deprecate", + "type": "java.lang.String", + "description": "Test deprecate.", + "defaultValue": "wrong" + }, + { + "name": "test.delete", + "type": "java.lang.String", + "description": "Test delete.", + "defaultValue": "delete" + }, + { + "name": "test.delete.deprecated", + "type": "java.lang.String", + "description": "Test delete deprecated.", + "defaultValue": "delete", + "deprecation": { + "level": "warning" + } + } + ] +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json new file mode 100644 index 000000000000..ef959d39c9eb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json @@ -0,0 +1,36 @@ +{ + "properties": [ + { + "name": "test.add", + "type": "java.lang.String", + "description": "Test add.", + "defaultValue": "new" + }, + { + "name": "test.equal", + "type": "java.lang.String", + "description": "Test equality.", + "defaultValue": "test" + }, + { + "name": "test.deprecate", + "type": "java.lang.String", + "description": "Test deprecate.", + "defaultValue": "wrong", + "deprecation": { + "level": "error" + } + }, + { + "name": "test.delete.deprecated", + "type": "java.lang.String", + "description": "Test delete deprecated.", + "defaultValue": "delete", + "deprecation": { + "level": "error", + "replacement": "test.add", + "reason": "it was just bad" + } + } + ] +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample.adoc b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample.adoc new file mode 100644 index 000000000000..ac5cc843e16f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample.adoc @@ -0,0 +1,32 @@ +Configuration property changes between `1.0` and `2.0` + + + +== Deprecated in 2.0 +_None_. + + + +== Added in 2.0 +|====================== +| Key | Default value | Description + +| `test.add` +| `new` +| Test add. +|====================== + + + +== Removed in 2.0 +|====================== +| Key | Replacement | Reason + +| `test.delete` +| +| + +| `test.delete.deprecated` +| `test.add` +| it was just bad +|====================== diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata/src/main/java/org/springframework/boot/configurationmetadata/SimpleConfigurationMetadataRepository.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata/src/main/java/org/springframework/boot/configurationmetadata/SimpleConfigurationMetadataRepository.java index bba6c2562787..c196afb5e2f2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata/src/main/java/org/springframework/boot/configurationmetadata/SimpleConfigurationMetadataRepository.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata/src/main/java/org/springframework/boot/configurationmetadata/SimpleConfigurationMetadataRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -54,11 +54,8 @@ public Map getAllProperties() { public void add(Collection sources) { for (ConfigurationMetadataSource source : sources) { String groupId = source.getGroupId(); - ConfigurationMetadataGroup group = this.allGroups.get(groupId); - if (group == null) { - group = new ConfigurationMetadataGroup(groupId); - this.allGroups.put(groupId, group); - } + ConfigurationMetadataGroup group = this.allGroups.computeIfAbsent(groupId, + (key) -> new ConfigurationMetadataGroup(groupId)); String sourceType = source.getType(); if (sourceType != null) { addOrMergeSource(group.getSources(), sourceType, source); @@ -74,9 +71,9 @@ public void add(Collection sources) { */ public void add(ConfigurationMetadataProperty property, ConfigurationMetadataSource source) { if (source != null) { - putIfAbsent(source.getProperties(), property.getId(), property); + source.getProperties().putIfAbsent(property.getId(), property); } - putIfAbsent(getGroup(source).getProperties(), property.getId(), property); + getGroup(source).getProperties().putIfAbsent(property.getId(), property); } /** @@ -91,7 +88,7 @@ public void include(ConfigurationMetadataRepository repository) { } else { // Merge properties - group.getProperties().forEach((name, value) -> putIfAbsent(existingGroup.getProperties(), name, value)); + group.getProperties().forEach((name, value) -> existingGroup.getProperties().putIfAbsent(name, value)); // Merge sources group.getSources().forEach((name, value) -> addOrMergeSource(existingGroup.getSources(), name, value)); } @@ -101,12 +98,7 @@ public void include(ConfigurationMetadataRepository repository) { private ConfigurationMetadataGroup getGroup(ConfigurationMetadataSource source) { if (source == null) { - ConfigurationMetadataGroup rootGroup = this.allGroups.get(ROOT_GROUP); - if (rootGroup == null) { - rootGroup = new ConfigurationMetadataGroup(ROOT_GROUP); - this.allGroups.put(ROOT_GROUP, rootGroup); - } - return rootGroup; + return this.allGroups.computeIfAbsent(ROOT_GROUP, (key) -> new ConfigurationMetadataGroup(ROOT_GROUP)); } return this.allGroups.get(source.getGroupId()); } @@ -118,13 +110,7 @@ private void addOrMergeSource(Map sources, sources.put(name, source); } else { - source.getProperties().forEach((k, v) -> putIfAbsent(existingSource.getProperties(), k, v)); - } - } - - private void putIfAbsent(Map map, String key, V value) { - if (!map.containsKey(key)) { - map.put(key, value); + source.getProperties().forEach((k, v) -> existingSource.getProperties().putIfAbsent(k, v)); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java index 6c805144a8d8..c34a83f8d6cd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,14 +20,15 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.time.Duration; +import java.util.ArrayDeque; import java.util.Arrays; import java.util.Collections; +import java.util.Deque; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.Stack; import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.ProcessingEnvironment; @@ -57,6 +58,8 @@ * @author Phillip Webb * @author Kris De Volder * @author Jonas Keßler + * @author Scott Frederick + * @author Moritz Halbritter * @since 1.2.0 */ @SupportedAnnotationTypes({ ConfigurationMetadataAnnotationProcessor.AUTO_CONFIGURATION_ANNOTATION, @@ -102,8 +105,7 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor static final String AUTO_CONFIGURATION_ANNOTATION = "org.springframework.boot.autoconfigure.AutoConfiguration"; - private static final Set SUPPORTED_OPTIONS = Collections - .unmodifiableSet(Collections.singleton(ADDITIONAL_METADATA_LOCATIONS_OPTION)); + private static final Set SUPPORTED_OPTIONS = Collections.singleton(ADDITIONAL_METADATA_LOCATIONS_OPTION); private MetadataStore metadataStore; @@ -213,10 +215,10 @@ private void processElement(Element element) { if (annotation != null) { String prefix = getPrefix(annotation); if (element instanceof TypeElement typeElement) { - processAnnotatedTypeElement(prefix, typeElement, new Stack<>()); + processAnnotatedTypeElement(prefix, typeElement, new ArrayDeque<>()); } else if (element instanceof ExecutableElement executableElement) { - processExecutableElement(prefix, executableElement, new Stack<>()); + processExecutableElement(prefix, executableElement, new ArrayDeque<>()); } } } @@ -225,13 +227,13 @@ else if (element instanceof ExecutableElement executableElement) { } } - private void processAnnotatedTypeElement(String prefix, TypeElement element, Stack seen) { + private void processAnnotatedTypeElement(String prefix, TypeElement element, Deque seen) { String type = this.metadataEnv.getTypeUtils().getQualifiedName(element); this.metadataCollector.add(ItemMetadata.newGroup(prefix, type, type, null)); processTypeElement(prefix, element, null, seen); } - private void processExecutableElement(String prefix, ExecutableElement element, Stack seen) { + private void processExecutableElement(String prefix, ExecutableElement element, Deque seen) { if ((!element.getModifiers().contains(Modifier.PRIVATE)) && (TypeKind.VOID != element.getReturnType().getKind())) { Element returns = this.processingEnv.getTypeUtils().asElement(element.getReturnType()); @@ -254,7 +256,7 @@ private void processExecutableElement(String prefix, ExecutableElement element, } private void processTypeElement(String prefix, TypeElement element, ExecutableElement source, - Stack seen) { + Deque seen) { if (!seen.contains(element)) { seen.push(element); new PropertyDescriptorResolver(this.metadataEnv).resolve(element, source).forEach((descriptor) -> { @@ -290,18 +292,29 @@ private void processEndpoint(AnnotationMirror annotation, TypeElement element) { return; // Can't process that endpoint } String endpointKey = ItemMetadata.newItemMetadataPrefix("management.endpoint.", endpointId); - Boolean enabledByDefault = (Boolean) elementValues.get("enableByDefault"); + boolean enabledByDefault = (boolean) elementValues.getOrDefault("enableByDefault", true); String type = this.metadataEnv.getTypeUtils().getQualifiedName(element); - this.metadataCollector.add(ItemMetadata.newGroup(endpointKey, type, type, null)); - this.metadataCollector.add(ItemMetadata.newProperty(endpointKey, "enabled", Boolean.class.getName(), type, null, - String.format("Whether to enable the %s endpoint.", endpointId), - (enabledByDefault != null) ? enabledByDefault : true, null)); + this.metadataCollector.addIfAbsent(ItemMetadata.newGroup(endpointKey, type, type, null)); + this.metadataCollector.add( + ItemMetadata.newProperty(endpointKey, "enabled", Boolean.class.getName(), type, null, + "Whether to enable the %s endpoint.".formatted(endpointId), enabledByDefault, null), + (existing) -> checkEnabledValueMatchesExisting(existing, enabledByDefault, type)); if (hasMainReadOperation(element)) { - this.metadataCollector.add(ItemMetadata.newProperty(endpointKey, "cache.time-to-live", + this.metadataCollector.addIfAbsent(ItemMetadata.newProperty(endpointKey, "cache.time-to-live", Duration.class.getName(), type, null, "Maximum time that a response can be cached.", "0ms", null)); } } + private void checkEnabledValueMatchesExisting(ItemMetadata existing, boolean enabledByDefault, String sourceType) { + boolean existingDefaultValue = (boolean) existing.getDefaultValue(); + if (enabledByDefault != existingDefaultValue) { + throw new IllegalStateException( + "Existing property '%s' from type %s has a conflicting value. Existing value: %b, new value from type %s: %b" + .formatted(existing.getName(), existing.getSourceType(), existingDefaultValue, sourceType, + enabledByDefault)); + } + } + private boolean hasMainReadOperation(TypeElement element) { for (ExecutableElement method : ElementFilter.methodsIn(element.getEnclosedElements())) { if (this.metadataEnv.getReadOperationAnnotation(method) != null @@ -322,16 +335,11 @@ private boolean hasNoOrOptionalParameters(ExecutableElement method) { } private String getPrefix(AnnotationMirror annotation) { - Map elementValues = this.metadataEnv.getAnnotationElementValues(annotation); - Object prefix = elementValues.get("prefix"); - if (prefix != null && !"".equals(prefix)) { - return (String) prefix; - } - Object value = elementValues.get("value"); - if (value != null && !"".equals(value)) { - return (String) value; + String prefix = this.metadataEnv.getAnnotationElementStringValue(annotation, "prefix"); + if (prefix != null) { + return prefix; } - return null; + return this.metadataEnv.getAnnotationElementStringValue(annotation, "value"); } protected ConfigurationMetadata writeMetadata() throws Exception { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollector.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollector.java index c6fe7f81d79e..2aa1a55a074e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollector.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollector.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.function.Consumer; import javax.annotation.processing.ProcessingEnvironment; import javax.annotation.processing.RoundEnvironment; @@ -35,6 +36,7 @@ * * @author Andy Wilkinson * @author Kris De Volder + * @author Moritz Halbritter * @since 1.2.2 */ public class MetadataCollector { @@ -76,6 +78,24 @@ public void add(ItemMetadata metadata) { this.metadataItems.add(metadata); } + public void add(ItemMetadata metadata, Consumer onConflict) { + ItemMetadata existing = find(metadata.getName()); + if (existing != null) { + onConflict.accept(existing); + return; + } + add(metadata); + } + + public boolean addIfAbsent(ItemMetadata metadata) { + ItemMetadata existing = find(metadata.getName()); + if (existing != null) { + return false; + } + add(metadata); + return true; + } + public boolean hasSimilarGroup(ItemMetadata metadata) { if (!metadata.isOfItemType(ItemMetadata.ItemType.GROUP)) { throw new IllegalStateException("item " + metadata + " must be a group"); @@ -105,6 +125,13 @@ public ConfigurationMetadata getMetadata() { return metadata; } + private ItemMetadata find(String name) { + return this.metadataItems.stream() + .filter((candidate) -> name.equals(candidate.getName())) + .findFirst() + .orElse(null); + } + private boolean shouldBeMerged(ItemMetadata itemMetadata) { String sourceType = itemMetadata.getSourceType(); return (sourceType != null && !deletedInCurrentBuild(sourceType) && !processedInCurrentBuild(sourceType)); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java index 5109ffed45d6..6d4cc6c3d2ac 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java @@ -49,6 +49,7 @@ * Provide utilities to detect and validate configuration properties. * * @author Stephane Nicoll + * @author Scott Frederick */ class MetadataGenerationEnvironment { @@ -174,14 +175,13 @@ ItemDeprecation resolveItemDeprecation(Element element) { AnnotationMirror annotation = getAnnotation(element, this.deprecatedConfigurationPropertyAnnotation); String reason = null; String replacement = null; + String since = null; if (annotation != null) { - Map elementValues = getAnnotationElementValues(annotation); - reason = (String) elementValues.get("reason"); - replacement = (String) elementValues.get("replacement"); + reason = getAnnotationElementStringValue(annotation, "reason"); + replacement = getAnnotationElementStringValue(annotation, "replacement"); + since = getAnnotationElementStringValue(annotation, "since"); } - reason = (reason == null || reason.isEmpty()) ? null : reason; - replacement = (replacement == null || replacement.isEmpty()) ? null : replacement; - return new ItemDeprecation(reason, replacement); + return new ItemDeprecation(reason, replacement, since); } boolean hasConstructorBindingAnnotation(ExecutableElement element) { @@ -279,6 +279,16 @@ Map getAnnotationElementValues(AnnotationMirror annotation) { return values; } + String getAnnotationElementStringValue(AnnotationMirror annotation, String name) { + return annotation.getElementValues() + .entrySet() + .stream() + .filter((element) -> element.getKey().getSimpleName().toString().equals(name)) + .map((element) -> asString(getAnnotationValue(element.getValue()))) + .findFirst() + .orElse(null); + } + private Object getAnnotationValue(AnnotationValue annotationValue) { Object value = annotationValue.getValue(); if (value instanceof List) { @@ -289,6 +299,10 @@ private Object getAnnotationValue(AnnotationValue annotationValue) { return value; } + private String asString(Object value) { + return (value == null || value.toString().isEmpty()) ? null : (String) value; + } + TypeElement getConfigurationPropertiesAnnotationElement() { return this.elements.getTypeElement(this.configurationPropertiesAnnotation); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java index 31e5630a9541..c4808a93a71f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java @@ -91,7 +91,7 @@ Stream> resolveConstructorProperties(TypeElement type, Typ private String getParameterName(VariableElement parameter) { AnnotationMirror nameAnnotation = this.environment.getNameAnnotation(parameter); if (nameAnnotation != null) { - return (String) this.environment.getAnnotationElementValues(nameAnnotation).get("value"); + return this.environment.getAnnotationElementStringValue(nameAnnotation, "value"); } return parameter.getSimpleName().toString(); } @@ -204,7 +204,7 @@ private static ExecutableElement deduceBindConstructor(TypeElement type, List 0 && !env.hasAutowiredAnnotation(candidate)) { + if (!candidate.getParameters().isEmpty() && !env.hasAutowiredAnnotation(candidate)) { if (type.getNestingKind() == NestingKind.MEMBER && candidate.getModifiers().contains(Modifier.PRIVATE)) { return null; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ConfigurationMetadata.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ConfigurationMetadata.java index 1281954f591c..a062ba4ca90f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ConfigurationMetadata.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ConfigurationMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author 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,6 +136,9 @@ protected void mergeItemMetadata(ItemMetadata metadata) { if (deprecation.getLevel() != null) { matchingDeprecation.setLevel(deprecation.getLevel()); } + if (deprecation.getSince() != null) { + matchingDeprecation.setSince(deprecation.getSince()); + } } } } @@ -182,7 +185,7 @@ private boolean nullSafeEquals(Object o1, Object o2) { public static String nestedPrefix(String prefix, String name) { String nestedPrefix = (prefix != null) ? prefix : ""; String dashedName = toDashedCase(name); - nestedPrefix += (nestedPrefix == null || nestedPrefix.isEmpty()) ? dashedName : "." + dashedName; + nestedPrefix += nestedPrefix.isEmpty() ? dashedName : "." + dashedName; return nestedPrefix; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemDeprecation.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemDeprecation.java index 2947e94f1554..e684edf73c6f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemDeprecation.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemDeprecation.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -20,6 +20,7 @@ * Describe an item deprecation. * * @author Stephane Nicoll + * @author Scott Frederick * @since 1.3.0 */ public class ItemDeprecation { @@ -28,19 +29,22 @@ public class ItemDeprecation { private String replacement; + private String since; + private String level; public ItemDeprecation() { - this(null, null); + this(null, null, null); } - public ItemDeprecation(String reason, String replacement) { - this(reason, replacement, null); + public ItemDeprecation(String reason, String replacement, String since) { + this(reason, replacement, since, null); } - public ItemDeprecation(String reason, String replacement, String level) { + public ItemDeprecation(String reason, String replacement, String since, String level) { this.reason = reason; this.replacement = replacement; + this.since = since; this.level = level; } @@ -60,6 +64,14 @@ public void setReplacement(String replacement) { this.replacement = replacement; } + public String getSince() { + return this.since; + } + + public void setSince(String since) { + this.since = since; + } + public String getLevel() { return this.level; } @@ -78,7 +90,7 @@ public boolean equals(Object o) { } ItemDeprecation other = (ItemDeprecation) o; return nullSafeEquals(this.reason, other.reason) && nullSafeEquals(this.replacement, other.replacement) - && nullSafeEquals(this.level, other.level); + && nullSafeEquals(this.level, other.level) && nullSafeEquals(this.since, other.since); } @Override @@ -86,13 +98,14 @@ public int hashCode() { int result = nullSafeHashCode(this.reason); result = 31 * result + nullSafeHashCode(this.replacement); result = 31 * result + nullSafeHashCode(this.level); + result = 31 * result + nullSafeHashCode(this.since); return result; } @Override public String toString() { return "ItemDeprecation{reason='" + this.reason + '\'' + ", replacement='" + this.replacement + '\'' - + ", level='" + this.level + '\'' + '}'; + + ", level='" + this.level + '\'' + ", since='" + this.since + '\'' + '}'; } private boolean nullSafeEquals(Object o1, Object o2) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemMetadata.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemMetadata.java index f2f1e7e5e2fb..70ec0f3dc771 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemMetadata.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -196,7 +196,7 @@ public String toString() { return string.toString(); } - protected void buildToStringProperty(StringBuilder string, String property, Object value) { + private void buildToStringProperty(StringBuilder string, String property, Object value) { if (value != null) { string.append(" ").append(property).append(":").append(value); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonConverter.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonConverter.java index af740a80c24b..bd6239e19df1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonConverter.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonConverter.java @@ -83,6 +83,9 @@ JSONObject toJsonObject(ItemMetadata item) throws Exception { if (deprecation.getReplacement() != null) { deprecationJsonObject.put("replacement", deprecation.getReplacement()); } + if (deprecation.getSince() != null) { + deprecationJsonObject.put("since", deprecation.getSince()); + } jsonObject.put("deprecation", deprecationJsonObject); } return jsonObject; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshaller.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshaller.java index 53370badb6da..e7d6e84e8a26 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshaller.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshaller.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,30 +18,31 @@ import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; import org.springframework.boot.configurationprocessor.json.JSONArray; import org.springframework.boot.configurationprocessor.json.JSONObject; import org.springframework.boot.configurationprocessor.metadata.ItemMetadata.ItemType; /** - * Marshaller to write {@link ConfigurationMetadata} as JSON. + * Marshaller to read and write {@link ConfigurationMetadata} as JSON. * * @author Stephane Nicoll * @author Phillip Webb + * @author Moritz Halbritter * @since 1.2.0 */ public class JsonMarshaller { - private static final int BUFFER_SIZE = 4098; - public void write(ConfigurationMetadata metadata, OutputStream outputStream) throws IOException { try { JSONObject object = new JSONObject(); @@ -65,77 +66,92 @@ public void write(ConfigurationMetadata metadata, OutputStream outputStream) thr public ConfigurationMetadata read(InputStream inputStream) throws Exception { ConfigurationMetadata metadata = new ConfigurationMetadata(); JSONObject object = new JSONObject(toString(inputStream)); + JsonPath path = JsonPath.root(); + checkAllowedKeys(object, path, "groups", "properties", "hints"); JSONArray groups = object.optJSONArray("groups"); if (groups != null) { for (int i = 0; i < groups.length(); i++) { - metadata.add(toItemMetadata((JSONObject) groups.get(i), ItemType.GROUP)); + metadata + .add(toItemMetadata((JSONObject) groups.get(i), path.resolve("groups").index(i), ItemType.GROUP)); } } JSONArray properties = object.optJSONArray("properties"); if (properties != null) { for (int i = 0; i < properties.length(); i++) { - metadata.add(toItemMetadata((JSONObject) properties.get(i), ItemType.PROPERTY)); + metadata.add(toItemMetadata((JSONObject) properties.get(i), path.resolve("properties").index(i), + ItemType.PROPERTY)); } } JSONArray hints = object.optJSONArray("hints"); if (hints != null) { for (int i = 0; i < hints.length(); i++) { - metadata.add(toItemHint((JSONObject) hints.get(i))); + metadata.add(toItemHint((JSONObject) hints.get(i), path.resolve("hints").index(i))); } } return metadata; } - private ItemMetadata toItemMetadata(JSONObject object, ItemType itemType) throws Exception { + private ItemMetadata toItemMetadata(JSONObject object, JsonPath path, ItemType itemType) throws Exception { + switch (itemType) { + case GROUP -> checkAllowedKeys(object, path, "name", "type", "description", "sourceType", "sourceMethod"); + case PROPERTY -> checkAllowedKeys(object, path, "name", "type", "description", "sourceType", "defaultValue", + "deprecation", "deprecated"); + } String name = object.getString("name"); String type = object.optString("type", null); String description = object.optString("description", null); String sourceType = object.optString("sourceType", null); String sourceMethod = object.optString("sourceMethod", null); Object defaultValue = readItemValue(object.opt("defaultValue")); - ItemDeprecation deprecation = toItemDeprecation(object); + ItemDeprecation deprecation = toItemDeprecation(object, path); return new ItemMetadata(itemType, name, null, type, sourceType, sourceMethod, description, defaultValue, deprecation); } - private ItemDeprecation toItemDeprecation(JSONObject object) throws Exception { + private ItemDeprecation toItemDeprecation(JSONObject object, JsonPath path) throws Exception { if (object.has("deprecation")) { JSONObject deprecationJsonObject = object.getJSONObject("deprecation"); + checkAllowedKeys(deprecationJsonObject, path.resolve("deprecation"), "level", "reason", "replacement", + "since"); ItemDeprecation deprecation = new ItemDeprecation(); deprecation.setLevel(deprecationJsonObject.optString("level", null)); deprecation.setReason(deprecationJsonObject.optString("reason", null)); deprecation.setReplacement(deprecationJsonObject.optString("replacement", null)); + deprecation.setSince(deprecationJsonObject.optString("since", null)); return deprecation; } return object.optBoolean("deprecated") ? new ItemDeprecation() : null; } - private ItemHint toItemHint(JSONObject object) throws Exception { + private ItemHint toItemHint(JSONObject object, JsonPath path) throws Exception { + checkAllowedKeys(object, path, "name", "values", "providers"); String name = object.getString("name"); List values = new ArrayList<>(); if (object.has("values")) { JSONArray valuesArray = object.getJSONArray("values"); for (int i = 0; i < valuesArray.length(); i++) { - values.add(toValueHint((JSONObject) valuesArray.get(i))); + values.add(toValueHint((JSONObject) valuesArray.get(i), path.resolve("values").index(i))); } } List providers = new ArrayList<>(); if (object.has("providers")) { JSONArray providersObject = object.getJSONArray("providers"); for (int i = 0; i < providersObject.length(); i++) { - providers.add(toValueProvider((JSONObject) providersObject.get(i))); + providers.add(toValueProvider((JSONObject) providersObject.get(i), path.resolve("providers").index(i))); } } return new ItemHint(name, values, providers); } - private ItemHint.ValueHint toValueHint(JSONObject object) throws Exception { + private ItemHint.ValueHint toValueHint(JSONObject object, JsonPath path) throws Exception { + checkAllowedKeys(object, path, "value", "description"); Object value = readItemValue(object.get("value")); String description = object.optString("description", null); return new ItemHint.ValueHint(value, description); } - private ItemHint.ValueProvider toValueProvider(JSONObject object) throws Exception { + private ItemHint.ValueProvider toValueProvider(JSONObject object, JsonPath path) throws Exception { + checkAllowedKeys(object, path, "name", "parameters"); String name = object.getString("name"); Map parameters = new HashMap<>(); if (object.has("parameters")) { @@ -161,14 +177,48 @@ private Object readItemValue(Object value) throws Exception { } private String toString(InputStream inputStream) throws IOException { - StringBuilder out = new StringBuilder(); - InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8); - char[] buffer = new char[BUFFER_SIZE]; - int bytesRead; - while ((bytesRead = reader.read(buffer)) != -1) { - out.append(buffer, 0, bytesRead); - } - return out.toString(); + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + + @SuppressWarnings("unchecked") + private void checkAllowedKeys(JSONObject object, JsonPath path, String... allowedKeys) { + Set availableKeys = new TreeSet<>(); + object.keys().forEachRemaining((key) -> availableKeys.add((String) key)); + Arrays.stream(allowedKeys).forEach(availableKeys::remove); + if (!availableKeys.isEmpty()) { + throw new IllegalStateException("Expected only keys %s, but found additional keys %s. Path: %s" + .formatted(new TreeSet<>(Arrays.asList(allowedKeys)), availableKeys, path)); + } + } + + private static final class JsonPath { + + private final String path; + + private JsonPath(String path) { + this.path = path; + } + + JsonPath resolve(String path) { + if (this.path.endsWith(".")) { + return new JsonPath(this.path + path); + } + return new JsonPath(this.path + "." + path); + } + + JsonPath index(int index) { + return resolve("[%d]".formatted(index)); + } + + @Override + public String toString() { + return this.path; + } + + static JsonPath root() { + return new JsonPath("."); + } + } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java index 93227ecf6196..d1a831a95105 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java @@ -104,12 +104,12 @@ void simpleProperties() { .fromSource(SimpleProperties.class) .withDescription("The name of this simple properties.") .withDefaultValue("boot") - .withDeprecation(null, null)); + .withDeprecation()); assertThat(metadata).has(Metadata.withProperty("simple.flag", Boolean.class) .withDefaultValue(false) .fromSource(SimpleProperties.class) .withDescription("A simple flag.") - .withDeprecation(null, null)); + .withDeprecation()); assertThat(metadata).has(Metadata.withProperty("simple.comparator")); assertThat(metadata).doesNotHave(Metadata.withProperty("simple.counter")); assertThat(metadata).doesNotHave(Metadata.withProperty("simple.size")); @@ -188,10 +188,9 @@ void deprecatedProperties() { ConfigurationMetadata metadata = compile(type); assertThat(metadata).has(Metadata.withGroup("deprecated").fromSource(type)); assertThat(metadata) - .has(Metadata.withProperty("deprecated.name", String.class).fromSource(type).withDeprecation(null, null)); - assertThat(metadata).has(Metadata.withProperty("deprecated.description", String.class) - .fromSource(type) - .withDeprecation(null, null)); + .has(Metadata.withProperty("deprecated.name", String.class).fromSource(type).withDeprecation()); + assertThat(metadata) + .has(Metadata.withProperty("deprecated.description", String.class).fromSource(type).withDeprecation()); } @Test @@ -202,7 +201,7 @@ void singleDeprecatedProperty() { assertThat(metadata).has(Metadata.withProperty("singledeprecated.new-name", String.class).fromSource(type)); assertThat(metadata).has(Metadata.withProperty("singledeprecated.name", String.class) .fromSource(type) - .withDeprecation("renamed", "singledeprecated.new-name")); + .withDeprecation("renamed", "singledeprecated.new-name", "1.2.3")); } @Test @@ -210,9 +209,8 @@ void singleDeprecatedFieldProperty() { Class type = DeprecatedFieldSingleProperty.class; ConfigurationMetadata metadata = compile(type); assertThat(metadata).has(Metadata.withGroup("singlefielddeprecated").fromSource(type)); - assertThat(metadata).has(Metadata.withProperty("singlefielddeprecated.name", String.class) - .fromSource(type) - .withDeprecation(null, null)); + assertThat(metadata) + .has(Metadata.withProperty("singlefielddeprecated.name", String.class).fromSource(type).withDeprecation()); } @Test @@ -246,7 +244,7 @@ void deprecatedPropertyOnRecord() { assertThat(metadata).has(Metadata.withGroup("deprecated-record").fromSource(type)); assertThat(metadata).has(Metadata.withProperty("deprecated-record.alpha", String.class) .fromSource(type) - .withDeprecation("some-reason", null)); + .withDeprecation("some-reason", null, null)); assertThat(metadata).has(Metadata.withProperty("deprecated-record.bravo", String.class).fromSource(type)); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/EndpointMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/EndpointMetadataGenerationTests.java index 4c77c1794b66..e9d88b28ba09 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/EndpointMetadataGenerationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/EndpointMetadataGenerationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,16 +27,20 @@ import org.springframework.boot.configurationsample.endpoint.DisabledEndpoint; import org.springframework.boot.configurationsample.endpoint.EnabledEndpoint; import org.springframework.boot.configurationsample.endpoint.SimpleEndpoint; +import org.springframework.boot.configurationsample.endpoint.SimpleEndpoint2; +import org.springframework.boot.configurationsample.endpoint.SimpleEndpoint3; import org.springframework.boot.configurationsample.endpoint.SpecificEndpoint; import org.springframework.boot.configurationsample.endpoint.incremental.IncrementalEndpoint; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatRuntimeException; /** * Metadata generation tests for Actuator endpoints. * * @author Stephane Nicoll * @author Scott Frederick + * @author Moritz Halbritter */ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { @@ -148,6 +152,24 @@ void incrementalEndpointBuildEnableSpecificEndpoint() { assertThat(metadata.getItems()).hasSize(3); } + @Test + void shouldTolerateEndpointWithSameId() { + ConfigurationMetadata metadata = compile(SimpleEndpoint.class, SimpleEndpoint2.class); + assertThat(metadata).has(Metadata.withGroup("management.endpoint.simple").fromSource(SimpleEndpoint.class)); + assertThat(metadata).has(enabledFlag("simple", "simple", true)); + assertThat(metadata).has(cacheTtl("simple")); + assertThat(metadata.getItems()).hasSize(3); + } + + @Test + void shouldFailIfEndpointWithSameIdButWithConflictingEnabledByDefaultSetting() { + assertThatRuntimeException().isThrownBy(() -> compile(SimpleEndpoint.class, SimpleEndpoint3.class)) + .havingRootCause() + .isInstanceOf(IllegalStateException.class) + .withMessage( + "Existing property 'management.endpoint.simple.enabled' from type org.springframework.boot.configurationsample.endpoint.SimpleEndpoint has a conflicting value. Existing value: true, new value from type org.springframework.boot.configurationsample.endpoint.SimpleEndpoint3: false"); + } + private Metadata.MetadataItemCondition enabledFlag(String endpointId, String endpointSuffix, Boolean defaultValue) { return Metadata.withEnabledFlag("management.endpoint." + endpointSuffix + ".enabled") .withDefaultValue(defaultValue) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ImmutablePropertiesMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ImmutablePropertiesMetadataGenerationTests.java index 6e3571539ff3..5b87c0137ffe 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ImmutablePropertiesMetadataGenerationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ImmutablePropertiesMetadataGenerationTests.java @@ -43,7 +43,7 @@ void immutableSimpleProperties() { .withDefaultValue(false) .fromSource(ImmutableSimpleProperties.class) .withDescription("A simple flag.") - .withDeprecation(null, null)); + .withDeprecation()); assertThat(metadata).has(Metadata.withProperty("immutable.comparator")); assertThat(metadata).has(Metadata.withProperty("immutable.counter")); assertThat(metadata.getItems()).hasSize(5); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/LombokMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/LombokMetadataGenerationTests.java index 69f0bb871842..2721f0c28a52 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/LombokMetadataGenerationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/LombokMetadataGenerationTests.java @@ -139,10 +139,8 @@ private void assertSimpleLombokProperties(ConfigurationMetadata metadata, Class< .withDescription("Name description.")); assertThat(metadata).has(Metadata.withProperty(prefix + ".description")); assertThat(metadata).has(Metadata.withProperty(prefix + ".counter")); - assertThat(metadata).has(Metadata.withProperty(prefix + ".number") - .fromSource(source) - .withDefaultValue(0) - .withDeprecation(null, null)); + assertThat(metadata) + .has(Metadata.withProperty(prefix + ".number").fromSource(source).withDefaultValue(0).withDeprecation()); assertThat(metadata).has(Metadata.withProperty(prefix + ".items")); assertThat(metadata).doesNotHave(Metadata.withProperty(prefix + ".ignored")); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MergeMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MergeMetadataGenerationTests.java index 167feb22f99e..c35e74755c85 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MergeMetadataGenerationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MergeMetadataGenerationTests.java @@ -74,7 +74,7 @@ void mergeExistingPropertyDefaultValue() throws Exception { assertThat(metadata).has(Metadata.withProperty("simple.flag", Boolean.class) .fromSource(SimpleProperties.class) .withDescription("A simple flag.") - .withDeprecation(null, null) + .withDeprecation() .withDefaultValue(true)); assertThat(metadata.getItems()).hasSize(4); } @@ -125,36 +125,36 @@ void mergeExistingPropertyDescription() throws Exception { @Test void mergeExistingPropertyDeprecation() throws Exception { ItemMetadata property = ItemMetadata.newProperty("simple", "comparator", null, null, null, null, null, - new ItemDeprecation("Don't use this.", "simple.complex-comparator", "error")); + new ItemDeprecation("Don't use this.", "simple.complex-comparator", "1.2.3", "error")); String additionalMetadata = buildAdditionalMetadata(property); ConfigurationMetadata metadata = compile(additionalMetadata, SimpleProperties.class); assertThat(metadata).has(Metadata.withProperty("simple.comparator", "java.util.Comparator") .fromSource(SimpleProperties.class) - .withDeprecation("Don't use this.", "simple.complex-comparator", "error")); + .withDeprecation("Don't use this.", "simple.complex-comparator", "1.2.3", "error")); assertThat(metadata.getItems()).hasSize(4); } @Test void mergeExistingPropertyDeprecationOverride() throws Exception { ItemMetadata property = ItemMetadata.newProperty("singledeprecated", "name", null, null, null, null, null, - new ItemDeprecation("Don't use this.", "single.name")); + new ItemDeprecation("Don't use this.", "single.name", "1.2.3")); String additionalMetadata = buildAdditionalMetadata(property); ConfigurationMetadata metadata = compile(additionalMetadata, DeprecatedSingleProperty.class); assertThat(metadata).has(Metadata.withProperty("singledeprecated.name", String.class.getName()) .fromSource(DeprecatedSingleProperty.class) - .withDeprecation("Don't use this.", "single.name")); + .withDeprecation("Don't use this.", "single.name", "1.2.3")); assertThat(metadata.getItems()).hasSize(3); } @Test void mergeExistingPropertyDeprecationOverrideLevel() throws Exception { ItemMetadata property = ItemMetadata.newProperty("singledeprecated", "name", null, null, null, null, null, - new ItemDeprecation(null, null, "error")); + new ItemDeprecation(null, null, null, "error")); String additionalMetadata = buildAdditionalMetadata(property); ConfigurationMetadata metadata = compile(additionalMetadata, DeprecatedSingleProperty.class); assertThat(metadata).has(Metadata.withProperty("singledeprecated.name", String.class.getName()) .fromSource(DeprecatedSingleProperty.class) - .withDeprecation("renamed", "singledeprecated.new-name", "error")); + .withDeprecation("renamed", "singledeprecated.new-name", "1.2.3", "error")); assertThat(metadata.getItems()).hasSize(3); } @@ -175,7 +175,7 @@ void mergingOfSimpleHint() throws Exception { .fromSource(SimpleProperties.class) .withDescription("The name of this simple properties.") .withDefaultValue("boot") - .withDeprecation(null, null)); + .withDeprecation()); assertThat(metadata) .has(Metadata.withHint("simple.the-name").withValue(0, "boot", "Bla bla").withValue(1, "spring", null)); } @@ -189,7 +189,7 @@ void mergingOfHintWithNonCanonicalName() throws Exception { .fromSource(SimpleProperties.class) .withDescription("The name of this simple properties.") .withDefaultValue("boot") - .withDeprecation(null, null)); + .withDeprecation()); assertThat(metadata).has(Metadata.withHint("simple.the-name").withValue(0, "boot", "Bla bla")); } @@ -203,18 +203,19 @@ void mergingOfHintWithProvider() throws Exception { .fromSource(SimpleProperties.class) .withDescription("The name of this simple properties.") .withDefaultValue("boot") - .withDeprecation(null, null)); + .withDeprecation()); assertThat(metadata).has( Metadata.withHint("simple.the-name").withProvider("first", "target", "org.foo").withProvider("second")); } @Test void mergingOfAdditionalDeprecation() throws Exception { - String deprecations = buildPropertyDeprecations(ItemMetadata.newProperty("simple", "wrongName", - "java.lang.String", null, null, null, null, new ItemDeprecation("Lame name.", "simple.the-name"))); + String deprecations = buildPropertyDeprecations( + ItemMetadata.newProperty("simple", "wrongName", "java.lang.String", null, null, null, null, + new ItemDeprecation("Lame name.", "simple.the-name", "1.2.3"))); ConfigurationMetadata metadata = compile(deprecations, SimpleProperties.class); assertThat(metadata).has(Metadata.withProperty("simple.wrong-name", String.class) - .withDeprecation("Lame name.", "simple.the-name")); + .withDeprecation("Lame name.", "simple.the-name", "1.2.3")); } @Test @@ -268,6 +269,9 @@ private String buildPropertyDeprecations(ItemMetadata... items) throws Exception if (deprecation.getReplacement() != null) { deprecationJson.put("replacement", deprecation.getReplacement()); } + if (deprecation.getSince() != null) { + deprecationJson.put("since", deprecation.getSince()); + } jsonObject.put("deprecation", deprecationJson); } propertiesArray.put(jsonObject); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MethodBasedMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MethodBasedMetadataGenerationTests.java index e7391bf98080..591c410b16b0 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MethodBasedMetadataGenerationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MethodBasedMetadataGenerationTests.java @@ -114,11 +114,11 @@ void deprecatedMethodConfig() { assertThat(metadata).has(Metadata.withGroup("foo").fromSource(type)); assertThat(metadata).has(Metadata.withProperty("foo.name", String.class) .fromSource(DeprecatedMethodConfig.Foo.class) - .withDeprecation(null, null)); + .withDeprecation()); assertThat(metadata).has(Metadata.withProperty("foo.flag", Boolean.class) .withDefaultValue(false) .fromSource(DeprecatedMethodConfig.Foo.class) - .withDeprecation(null, null)); + .withDeprecation()); } @Test @@ -129,11 +129,11 @@ void deprecatedMethodConfigOnClass() { assertThat(metadata).has(Metadata.withGroup("foo").fromSource(type)); assertThat(metadata).has(Metadata.withProperty("foo.name", String.class) .fromSource(org.springframework.boot.configurationsample.method.DeprecatedClassMethodConfig.Foo.class) - .withDeprecation(null, null)); + .withDeprecation()); assertThat(metadata).has(Metadata.withProperty("foo.flag", Boolean.class) .withDefaultValue(false) .fromSource(org.springframework.boot.configurationsample.method.DeprecatedClassMethodConfig.Foo.class) - .withDeprecation(null, null)); + .withDeprecation()); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolverTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolverTests.java index 9f85011de406..9a2b1aeed873 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolverTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolverTests.java @@ -111,15 +111,16 @@ void propertiesWithLombokValueClass() { void propertiesWithDeducedConstructorBinding() { process(ImmutableDeducedConstructorBindingProperties.class, propertyNames((stream) -> assertThat(stream).containsExactly("theName", "flag"))); - process(ImmutableDeducedConstructorBindingProperties.class, properties((stream) -> assertThat(stream) - .allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor))); + process(ImmutableDeducedConstructorBindingProperties.class, + properties((stream) -> assertThat(stream).isNotEmpty() + .allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor))); } @Test void propertiesWithConstructorWithConstructorBinding() { process(ImmutableSimpleProperties.class, propertyNames( (stream) -> assertThat(stream).containsExactly("theName", "flag", "comparator", "counter"))); - process(ImmutableSimpleProperties.class, properties((stream) -> assertThat(stream) + process(ImmutableSimpleProperties.class, properties((stream) -> assertThat(stream).isNotEmpty() .allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor))); } @@ -127,14 +128,14 @@ void propertiesWithConstructorWithConstructorBinding() { void propertiesWithConstructorAndClassConstructorBinding() { process(ImmutableClassConstructorBindingProperties.class, propertyNames((stream) -> assertThat(stream).containsExactly("name", "description"))); - process(ImmutableClassConstructorBindingProperties.class, properties((stream) -> assertThat(stream) + process(ImmutableClassConstructorBindingProperties.class, properties((stream) -> assertThat(stream).isNotEmpty() .allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor))); } @Test void propertiesWithAutowiredConstructor() { process(AutowiredProperties.class, propertyNames((stream) -> assertThat(stream).containsExactly("theName"))); - process(AutowiredProperties.class, properties((stream) -> assertThat(stream) + process(AutowiredProperties.class, properties((stream) -> assertThat(stream).isNotEmpty() .allMatch((predicate) -> predicate instanceof JavaBeanPropertyDescriptor))); } @@ -142,21 +143,10 @@ void propertiesWithAutowiredConstructor() { void propertiesWithMultiConstructor() { process(ImmutableMultiConstructorProperties.class, propertyNames((stream) -> assertThat(stream).containsExactly("name", "description"))); - process(ImmutableMultiConstructorProperties.class, properties((stream) -> assertThat(stream) + process(ImmutableMultiConstructorProperties.class, properties((stream) -> assertThat(stream).isNotEmpty() .allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor))); } - @Test - @Deprecated(since = "3.0.0", forRemoval = true) - @SuppressWarnings("removal") - void propertiesWithMultiConstructorAndDeprecatedAnnotation() { - process(org.springframework.boot.configurationsample.immutable.DeprecatedImmutableMultiConstructorProperties.class, - propertyNames((stream) -> assertThat(stream).containsExactly("name", "description"))); - process(org.springframework.boot.configurationsample.immutable.DeprecatedImmutableMultiConstructorProperties.class, - properties((stream) -> assertThat(stream) - .allMatch((predicate) -> predicate instanceof ConstructorParameterPropertyDescriptor))); - } - @Test void propertiesWithMultiConstructorNoDirective() { process(TwoConstructorsExample.class, propertyNames((stream) -> assertThat(stream).containsExactly("name"))); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshallerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshallerTests.java index 2cbda570e8a0..00252fc252e4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshallerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/JsonMarshallerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,12 +20,14 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; /** * Tests for {@link JsonMarshaller}. @@ -38,14 +40,15 @@ class JsonMarshallerTests { @Test void marshallAndUnmarshal() throws Exception { ConfigurationMetadata metadata = new ConfigurationMetadata(); - metadata.add(ItemMetadata.newProperty("a", "b", StringBuffer.class.getName(), InputStream.class.getName(), - "sourceMethod", "desc", "x", new ItemDeprecation("Deprecation comment", "b.c.d"))); + metadata.add(ItemMetadata.newProperty("a", "b", StringBuffer.class.getName(), InputStream.class.getName(), null, + "desc", "x", new ItemDeprecation("Deprecation comment", "b.c.d", "1.2.3"))); metadata.add(ItemMetadata.newProperty("b.c.d", null, null, null, null, null, null, null)); metadata.add(ItemMetadata.newProperty("c", null, null, null, null, null, 123, null)); metadata.add(ItemMetadata.newProperty("d", null, null, null, null, null, true, null)); metadata.add(ItemMetadata.newProperty("e", null, null, null, null, null, new String[] { "y", "n" }, null)); metadata.add(ItemMetadata.newProperty("f", null, null, null, null, null, new Boolean[] { true, false }, null)); metadata.add(ItemMetadata.newGroup("d", null, null, null)); + metadata.add(ItemMetadata.newGroup("e", null, null, "sourceMethod")); metadata.add(ItemHint.newHint("a.b")); metadata.add(ItemHint.newHint("c", new ItemHint.ValueHint(123, "hey"), new ItemHint.ValueHint(456, null))); metadata.add(new ItemHint("d", null, @@ -59,13 +62,14 @@ void marshallAndUnmarshal() throws Exception { .fromSource(InputStream.class) .withDescription("desc") .withDefaultValue("x") - .withDeprecation("Deprecation comment", "b.c.d")); + .withDeprecation("Deprecation comment", "b.c.d", "1.2.3")); assertThat(read).has(Metadata.withProperty("b.c.d")); assertThat(read).has(Metadata.withProperty("c").withDefaultValue(123)); assertThat(read).has(Metadata.withProperty("d").withDefaultValue(true)); assertThat(read).has(Metadata.withProperty("e").withDefaultValue(new String[] { "y", "n" })); assertThat(read).has(Metadata.withProperty("f").withDefaultValue(new Object[] { true, false })); assertThat(read).has(Metadata.withGroup("d")); + assertThat(read).has(Metadata.withGroup("e").fromSourceMethod("sourceMethod")); assertThat(read).has(Metadata.withHint("a.b")); assertThat(read).has(Metadata.withHint("c").withValue(0, 123, "hey").withValue(1, 456, null)); assertThat(read).has(Metadata.withHint("d").withProvider("first", "target", "foo").withProvider("second")); @@ -96,10 +100,10 @@ void marshallPutDeprecatedItemsAtTheEnd() throws IOException { ConfigurationMetadata metadata = new ConfigurationMetadata(); metadata.add(ItemMetadata.newProperty("com.example.bravo", "bbb", null, null, null, null, null, null)); metadata.add(ItemMetadata.newProperty("com.example.bravo", "aaa", null, null, null, null, null, - new ItemDeprecation(null, null, "warning"))); + new ItemDeprecation(null, null, null, "warning"))); metadata.add(ItemMetadata.newProperty("com.example.alpha", "ddd", null, null, null, null, null, null)); metadata.add(ItemMetadata.newProperty("com.example.alpha", "ccc", null, null, null, null, null, - new ItemDeprecation(null, null, "warning"))); + new ItemDeprecation(null, null, null, "warning"))); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); JsonMarshaller marshaller = new JsonMarshaller(); marshaller.write(metadata, outputStream); @@ -170,4 +174,159 @@ void orderingForSamePropertyNamesWithNullSourceType() throws IOException { "\"java.lang.Boolean\"", "\"com.example.bravo.aaa\"", "\"java.lang.Integer\"", "\"com.example.Bar"); } + @Test + void shouldCheckRootFields() { + String json = """ + { + "groups": [], "properties": [], "hints": [], "dummy": [] + }"""; + assertThatException().isThrownBy(() -> read(json)) + .withMessage("Expected only keys [groups, hints, properties], but found additional keys [dummy]. Path: ."); + } + + @Test + void shouldCheckGroupFields() { + String json = """ + { + "groups": [ + { + "name": "g", + "type": "java.lang.String", + "description": "Some description", + "sourceType": "java.lang.String", + "sourceMethod": "some()", + "dummy": "dummy" + } + ], "properties": [], "hints": [] + }"""; + assertThatException().isThrownBy(() -> read(json)) + .withMessage( + "Expected only keys [description, name, sourceMethod, sourceType, type], but found additional keys [dummy]. Path: .groups.[0]"); + } + + @Test + void shouldCheckPropertyFields() { + String json = """ + { + "groups": [], "properties": [ + { + "name": "name", + "type": "java.lang.String", + "description": "Some description", + "sourceType": "java.lang.String", + "defaultValue": "value", + "deprecation": { + "level": "warning", + "reason": "some reason", + "replacement": "name-new", + "since": "v17" + }, + "deprecated": true, + "dummy": "dummy" + } + ], "hints": [] + }"""; + assertThatException().isThrownBy(() -> read(json)) + .withMessage( + "Expected only keys [defaultValue, deprecated, deprecation, description, name, sourceType, type], but found additional keys [dummy]. Path: .properties.[0]"); + } + + @Test + void shouldCheckPropertyDeprecationFields() { + String json = """ + { + "groups": [], "properties": [ + { + "name": "name", + "type": "java.lang.String", + "description": "Some description", + "sourceType": "java.lang.String", + "defaultValue": "value", + "deprecation": { + "level": "warning", + "reason": "some reason", + "replacement": "name-new", + "since": "v17", + "dummy": "dummy" + }, + "deprecated": true + } + ], "hints": [] + }"""; + assertThatException().isThrownBy(() -> read(json)) + .withMessage( + "Expected only keys [level, reason, replacement, since], but found additional keys [dummy]. Path: .properties.[0].deprecation"); + } + + @Test + void shouldCheckHintFields() { + String json = """ + { + "groups": [], "properties": [], "hints": [ + { + "name": "name", + "values": [], + "providers": [], + "dummy": "dummy" + } + ] + }"""; + assertThatException().isThrownBy(() -> read(json)) + .withMessage( + "Expected only keys [name, providers, values], but found additional keys [dummy]. Path: .hints.[0]"); + } + + @Test + void shouldCheckHintValueFields() { + String json = """ + { + "groups": [], "properties": [], "hints": [ + { + "name": "name", + "values": [ + { + "value": "value", + "description": "some description", + "dummy": "dummy" + } + ], + "providers": [] + } + ] + }"""; + assertThatException().isThrownBy(() -> read(json)) + .withMessage( + "Expected only keys [description, value], but found additional keys [dummy]. Path: .hints.[0].values.[0]"); + } + + @Test + void shouldCheckHintProviderFields() { + String json = """ + { + "groups": [], "properties": [], "hints": [ + { + "name": "name", + "values": [], + "providers": [ + { + "name": "name", + "parameters": { + "target": "jakarta.servlet.http.HttpServlet" + }, + "dummy": "dummy" + } + ] + } + ] + }"""; + assertThatException().isThrownBy(() -> read(json)) + .withMessage( + "Expected only keys [name, parameters], but found additional keys [dummy]. Path: .hints.[0].providers.[0]"); + } + + private void read(String json) throws Exception { + JsonMarshaller marshaller = new JsonMarshaller(); + marshaller.read(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java index 39be910daf52..0f4fcb10fd8c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -157,10 +157,7 @@ public boolean matches(ConfigurationMetadata value) { if (this.deprecation == null && itemMetadata.getDeprecation() != null) { return false; } - if (this.deprecation != null && !this.deprecation.equals(itemMetadata.getDeprecation())) { - return false; - } - return true; + return this.deprecation == null || this.deprecation.equals(itemMetadata.getDeprecation()); } public MetadataItemCondition ofType(Class dataType) { @@ -193,13 +190,17 @@ public MetadataItemCondition withDefaultValue(Object defaultValue) { this.description, defaultValue, this.deprecation); } - public MetadataItemCondition withDeprecation(String reason, String replacement) { - return withDeprecation(reason, replacement, null); + public MetadataItemCondition withDeprecation() { + return withDeprecation(null, null, null, null); + } + + public MetadataItemCondition withDeprecation(String reason, String replacement, String since) { + return withDeprecation(reason, replacement, since, null); } - public MetadataItemCondition withDeprecation(String reason, String replacement, String level) { + public MetadataItemCondition withDeprecation(String reason, String replacement, String since, String level) { return new MetadataItemCondition(this.itemType, this.name, this.type, this.sourceType, this.sourceMethod, - this.description, this.defaultValue, new ItemDeprecation(reason, replacement, level)); + this.description, this.defaultValue, new ItemDeprecation(reason, replacement, since, level)); } public MetadataItemCondition withNoDeprecation() { @@ -344,10 +345,7 @@ public boolean matches(ItemHint value) { if (this.value != null && !this.value.equals(valueHint.getValue())) { return false; } - if (this.description != null && !this.description.equals(valueHint.getDescription())) { - return false; - } - return true; + return this.description == null || this.description.equals(valueHint.getDescription()); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConfigurationProperty.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConfigurationProperty.java index 0853090e9388..3aaf2a6540de 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConfigurationProperty.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConfigurationProperty.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -50,4 +50,10 @@ */ String replacement() default ""; + /** + * The version in which the property became deprecated. + * @return the version + */ + String since() default ""; + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/SimpleEndpoint2.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/SimpleEndpoint2.java new file mode 100644 index 000000000000..971064af4489 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/SimpleEndpoint2.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.configurationsample.endpoint; + +import org.springframework.boot.configurationsample.Endpoint; +import org.springframework.boot.configurationsample.ReadOperation; + +/** + * A simple endpoint with no default override, with the same id as {@link SimpleEndpoint}. + * + * @author Moritz Halbritter + */ +@Endpoint(id = "simple") +public class SimpleEndpoint2 { + + @ReadOperation + public String invoke() { + return "test"; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/SimpleEndpoint3.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/SimpleEndpoint3.java new file mode 100644 index 000000000000..48c88f16f410 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/SimpleEndpoint3.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.configurationsample.endpoint; + +import org.springframework.boot.configurationsample.Endpoint; +import org.springframework.boot.configurationsample.ReadOperation; + +/** + * A simple endpoint with no default override, with the same id as {@link SimpleEndpoint}, + * but not enabled by default. + * + * @author Moritz Halbritter + */ +@Endpoint(id = "simple", enableByDefault = false) +public class SimpleEndpoint3 { + + @ReadOperation + public String invoke() { + return "test"; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeprecatedImmutableMultiConstructorProperties.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeprecatedImmutableMultiConstructorProperties.java deleted file mode 100644 index d2e0305fc149..000000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeprecatedImmutableMultiConstructorProperties.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2012-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.boot.configurationsample.immutable; - -/** - * Simple immutable properties with several constructors. - * - * @author Stephane Nicoll - */ -@SuppressWarnings("unused") -@Deprecated(since = "3.0.0", forRemoval = true) -public class DeprecatedImmutableMultiConstructorProperties { - - private final String name; - - /** - * Test description. - */ - private final String description; - - public DeprecatedImmutableMultiConstructorProperties(String name) { - this(name, null); - } - - @SuppressWarnings("removal") - @org.springframework.boot.configurationsample.DeprecatedConstructorBinding - public DeprecatedImmutableMultiConstructorProperties(String name, String description) { - this.name = name; - this.description = description; - } - -} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/simple/DeprecatedSingleProperty.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/simple/DeprecatedSingleProperty.java index f8df3f7c4aeb..984c98764386 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/simple/DeprecatedSingleProperty.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/simple/DeprecatedSingleProperty.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -30,7 +30,7 @@ public class DeprecatedSingleProperty { private String newName; @Deprecated - @DeprecatedConfigurationProperty(reason = "renamed", replacement = "singledeprecated.new-name") + @DeprecatedConfigurationProperty(reason = "renamed", replacement = "singledeprecated.new-name", since = "1.2.3") public String getName() { return getNewName(); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle index 48e174123a43..35cd303513d7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/build.gradle @@ -23,6 +23,11 @@ configurations { if (dependency.requested.group.startsWith("com.fasterxml.jackson")) { dependency.useVersion("2.14.2") } + // Downgrade Spring Framework as Gradle cannot cope with 6.1.0-M1's + // multi-version jar files with bytecode in META-INF/versions/21 + if (dependency.requested.group.equals("org.springframework")) { + dependency.useVersion("6.0.10") + } } } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/aot.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/aot.adoc index 96dca8bf6df1..e5f108d917f4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/aot.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/aot.adoc @@ -36,7 +36,7 @@ The `nativeCompile` task of the GraalVM Native Image plugin is automatically con [[aot.processing-tests]] == Processing Tests The AOT engine can be applied to JUnit 5 tests that use Spring's Test Context Framework. -Suitable tests are processed by the `processTestAot` task to generate `ApplicationContextInitialzer` code. +Suitable tests are processed by the `processTestAot` task to generate `ApplicationContextInitializer` code. As with application AOT processing, the `BeanFactory` is fully prepared at build-time. As with `processAot`, the `processTestAot` task is `JavaExec` subclass and can be configured as needed to influence this processing. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/integrating-with-actuator.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/integrating-with-actuator.adoc index 1d016b8a9bdf..4232d13f7f73 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/integrating-with-actuator.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/integrating-with-actuator.adoc @@ -95,3 +95,5 @@ include::../gradle/integrating-with-actuator/build-info-additional.gradle[tags=a ---- include::../gradle/integrating-with-actuator/build-info-additional.gradle.kts[tags=additional] ---- + +An additional property's value can be computed lazily by using a `Provider`. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc index 4322006b5040..252b495bf770 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/packaging-oci-image.adoc @@ -13,7 +13,8 @@ The task is automatically created when the `java` or `war` plugin is applied and [[build-image.docker-daemon]] == Docker Daemon The `bootBuildImage` task requires access to a Docker daemon. -By default, it will communicate with a Docker daemon over a local connection. +The task will inspect local Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] to determine the current https://docs.docker.com/engine/context/working-with-contexts/[context] and use the context connection information to communicate with a Docker daemon. +If the current context can not be determined or the context does not have connection information, then the task will use a default local connection. This works with https://docs.docker.com/install/[Docker Engine] on all supported platforms without configuration. Environment variables can be set to configure the `bootBuildImage` task to use an alternative local or remote connection. @@ -22,6 +23,12 @@ The following table shows the environment variables and their values: |=== | Environment variable | Description +| DOCKER_CONFIG +| Location of Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] used to determine the current context (defaults to `$HOME/.docker`) + +| DOCKER_CONTEXT +| Name of a https://docs.docker.com/engine/context/working-with-contexts/[context] that should be used to retrieve host information from Docker CLI configuration files (overrides `DOCKER_HOST`) + | DOCKER_HOST | URL containing the host and port for the Docker daemon - for example `tcp://192.168.99.100:2376` @@ -38,6 +45,9 @@ The following table summarizes the available properties: |=== | Property | Description +| `context` +| Name of a https://docs.docker.com/engine/context/working-with-contexts/[context] that should be used to retrieve host information from Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] + | `host` | URL containing the host and port for the Docker daemon - for example `tcp://192.168.99.100:2376` @@ -184,14 +194,22 @@ The values provided to the `tags` option should be *full* image references. See <> for more details. | +| `buildWorkspace` +| +| A temporary workspace that will be used by the builder and buildpacks to store files during image building. +The value can be a named volume or a bind mount location. +| A named volume in the Docker daemon, with a name derived from the image name. + | `buildCache` | | A cache containing layers created by buildpacks and used by the image building process. +The value can be a named volume or a bind mount location. | A named volume in the Docker daemon, with a name derived from the image name. | `launchCache` | | A cache containing layers created by buildpacks and used by the image launching process. +The value can be a named volume or a bind mount location. | A named volume in the Docker daemon, with a name derived from the image name. | `createdDate` @@ -206,6 +224,11 @@ The value must be a string in the ISO 8601 instant format, or `now` to use the c Application contents will also be in this location in the generated image. | `/workspace` +| `securityOptions` +| `--securityOptions` +| https://docs.docker.com/engine/reference/run/#security-configuration[Security options] that will be applied to the builder container, provided as an array of string values +| `["label=disable"]` on Linux and macOS, `[]` on Windows + |=== NOTE: The plugin detects the target Java compatibility of the project using the JavaPlugin's `targetCompatibility` property. @@ -431,7 +454,7 @@ The publish option can be specified on the command line as well, as shown in thi [[build-image.examples.caches]] -=== Builder Cache Configuration +=== Builder Cache and Workspace Configuration The CNB builder caches layers that are used when building and launching an image. By default, these caches are stored as named volumes in the Docker daemon with names that are derived from the full name of the target image. If the image name changes frequently, for example when the project version is used as a tag in the image name, then the caches can be invalidated frequently. @@ -450,6 +473,23 @@ include::../gradle/packaging/boot-build-image-caches.gradle[tags=caches] include::../gradle/packaging/boot-build-image-caches.gradle.kts[tags=caches] ---- +Builders and buildpacks need a location to store temporary files during image building. +By default, this temporary build workspace is stored in a named volume. + +The caches and the build workspace can be configured to use bind mounts instead of named volumes, as shown in the following example: + +[source,groovy,indent=0,subs="verbatim,attributes",role="primary"] +.Groovy +---- +include::../gradle/packaging/boot-build-image-bind-caches.gradle[tags=caches] +---- + +[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"] +.Kotlin +---- +include::../gradle/packaging/boot-build-image-bind-caches.gradle.kts[tags=caches] +---- + [[build-image.examples.docker]] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/reacting.adoc b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/reacting.adoc index bfb245f2d427..8cec77b48924 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/reacting.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/asciidoc/reacting.adoc @@ -9,7 +9,7 @@ This section describes those changes. == Reacting to the Java Plugin When Gradle's {java-plugin}[`java` plugin] is applied to a project, the Spring Boot plugin: -1. Creates a {boot-jar-javadoc}[`BootJar`] task named `bootJar` that will create an executable, fat jar for the project. +1. Creates a {boot-jar-javadoc}[`BootJar`] task named `bootJar` that will create an executable, uber jar for the project. The jar will contain everything on the runtime classpath of the main source set; classes are packaged in `BOOT-INF/classes` and jars are packaged in `BOOT-INF/lib` 2. Configures the `assemble` task to depend on the `bootJar` task. 3. Configures the `jar` task to use `plain` as the convention for its archive classifier. @@ -18,9 +18,10 @@ When Gradle's {java-plugin}[`java` plugin] is applied to a project, the Spring B 6. Creates a {boot-run-javadoc}['BootRun`] task named `bootTestRun` that can be used to run your application using the `test` source set to find its main method and provide its runtime classpath. 7. Creates a configuration named `bootArchives` that contains the artifact produced by the `bootJar` task. 8. Creates a configuration named `developmentOnly` for dependencies that are only required at development time, such as Spring Boot's Devtools, and should not be packaged in executable jars and wars. -9. Creates a configuration named `productionRuntimeClasspath`. It is equivalent to `runtimeClasspath` minus any dependencies that only appear in the `developmentOnly` configuration. -10. Configures any `JavaCompile` tasks with no configured encoding to use `UTF-8`. -11. Configures any `JavaCompile` tasks to use the `-parameters` compiler argument. +9. Creates a configuration named `testAndDevelopmentOnly` for dependencies that are only required at development time and when writing and running tests and that should not be packaged in executable jars and wars. +10. Creates a configuration named `productionRuntimeClasspath`. It is equivalent to `runtimeClasspath` minus any dependencies that only appear in the `developmentOnly` or `testDevelopmentOnly` configurations. +11. Configures any `JavaCompile` tasks with no configured encoding to use `UTF-8`. +12. Configures any `JavaCompile` tasks to use the `-parameters` compiler argument. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle new file mode 100644 index 000000000000..875239d07f80 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle @@ -0,0 +1,36 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{gradle-project-version}' +} + +tasks.named("bootJar") { + mainClass = 'com.example.ExampleApplication' +} + +// tag::caches[] +tasks.named("bootBuildImage") { + buildWorkspace { + bind { + source = "/tmp/cache-${rootProject.name}.work" + } + } + buildCache { + bind { + source = "/tmp/cache-${rootProject.name}.build" + } + } + launchCache { + bind { + source = "/tmp/cache-${rootProject.name}.launch" + } + } +} +// end::caches[] + +tasks.register("bootBuildImageCaches") { + doFirst { + bootBuildImage.buildWorkspace.asCache().with { print "buildWorkspace=$source" } + bootBuildImage.buildCache.asCache().with { println "buildCache=$source" } + bootBuildImage.launchCache.asCache().with { println "launchCache=$source" } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle.kts new file mode 100644 index 000000000000..e492703c6f96 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-build-image-bind-caches.gradle.kts @@ -0,0 +1,34 @@ +import org.springframework.boot.gradle.tasks.bundling.BootBuildImage + +plugins { + java + id("org.springframework.boot") version "{gradle-project-version}" +} + +// tag::caches[] +tasks.named("bootBuildImage") { + buildWorkspace { + bind { + source.set("/tmp/cache-${rootProject.name}.work") + } + } + buildCache { + bind { + source.set("/tmp/cache-${rootProject.name}.build") + } + } + launchCache { + bind { + source.set("/tmp/cache-${rootProject.name}.launch") + } + } +} +// end::caches[] + +tasks.register("bootBuildImageCaches") { + doFirst { + println("buildWorkspace=" + tasks.getByName("bootBuildImage").buildWorkspace.asCache().bind.source) + println("buildCache=" + tasks.getByName("bootBuildImage").buildCache.asCache().bind.source) + println("launchCache=" + tasks.getByName("bootBuildImage").launchCache.asCache().bind.source) + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle index 2872469f60fb..6a1897ae3d3b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle @@ -10,7 +10,7 @@ tasks.named("bootWar") { // tag::properties-launcher[] tasks.named("bootWar") { manifest { - attributes 'Main-Class': 'org.springframework.boot.loader.PropertiesLauncher' + attributes 'Main-Class': 'org.springframework.boot.loader.launch.PropertiesLauncher' } } // end::properties-launcher[] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle.kts b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle.kts index 19d723b795fa..f5284eb8f259 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle.kts +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/docs/gradle/packaging/boot-war-properties-launcher.gradle.kts @@ -12,7 +12,7 @@ tasks.named("bootWar") { // tag::properties-launcher[] tasks.named("bootWar") { manifest { - attributes("Main-Class" to "org.springframework.boot.loader.PropertiesLauncher") + attributes("Main-Class" to "org.springframework.boot.loader.launch.PropertiesLauncher") } } // end::properties-launcher[] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ApplicationPluginAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ApplicationPluginAction.java index a4df28854a7a..7b29a32685a9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ApplicationPluginAction.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ApplicationPluginAction.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -130,7 +130,7 @@ private void configureFilePermissions(CopySpec copySpec, int mode) { if (GradleVersion.current().compareTo(GradleVersion.version("8.3")) >= 0) { try { Method filePermissions = copySpec.getClass().getMethod("filePermissions", Action.class); - filePermissions.invoke(copySpec, new Action() { + filePermissions.invoke(copySpec, new Action<>() { @Override public void execute(Object filePermissions) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java index c6ad89c5eb39..93b2172a4a78 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/JavaPluginAction.java @@ -76,7 +76,9 @@ public Class> getPluginClass() { public void execute(Project project) { classifyJarTask(project); configureBuildTask(project); + configureProductionRuntimeClasspathConfiguration(project); configureDevelopmentOnlyConfiguration(project); + configureTestAndDevelopmentOnlyConfiguration(project); TaskProvider resolveMainClassName = configureResolveMainClassNameTask(project); TaskProvider bootJar = configureBootJarTask(project, resolveMainClassName); configureBootBuildImageTask(project, bootJar); @@ -158,12 +160,15 @@ private TaskProvider configureBootJarTask(Project project, .getByName(SourceSet.MAIN_SOURCE_SET_NAME); Configuration developmentOnly = project.getConfigurations() .getByName(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME); + Configuration testAndDevelopmentOnly = project.getConfigurations() + .getByName(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME); Configuration productionRuntimeClasspath = project.getConfigurations() .getByName(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME); Configuration runtimeClasspath = project.getConfigurations() .getByName(mainSourceSet.getRuntimeClasspathConfigurationName()); Callable classpath = () -> mainSourceSet.getRuntimeClasspath() .minus((developmentOnly.minus(productionRuntimeClasspath))) + .minus((testAndDevelopmentOnly.minus(productionRuntimeClasspath))) .filter(new JarTypeFileSpec()); return project.getTasks().register(SpringBootPlugin.BOOT_JAR_TASK_NAME, BootJar.class, (bootJar) -> { bootJar.setDescription( @@ -269,15 +274,12 @@ private void configureAdditionalMetadataLocations(JavaCompile compile) { } @SuppressWarnings({ "rawtypes", "unchecked" }) - private void configureDevelopmentOnlyConfiguration(Project project) { - Configuration developmentOnly = project.getConfigurations() - .create(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME); - developmentOnly - .setDescription("Configuration for development-only dependencies such as Spring Boot's DevTools."); - Configuration runtimeClasspath = project.getConfigurations() - .getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); + private void configureProductionRuntimeClasspathConfiguration(Project project) { Configuration productionRuntimeClasspath = project.getConfigurations() .create(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME); + productionRuntimeClasspath.setVisible(false); + Configuration runtimeClasspath = project.getConfigurations() + .getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); productionRuntimeClasspath.attributes((attributes) -> { ProviderFactory providers = project.getProviders(); AttributeContainer sourceAttributes = runtimeClasspath.getAttributes(); @@ -286,13 +288,35 @@ private void configureDevelopmentOnlyConfiguration(Project project) { providers.provider(() -> sourceAttributes.getAttribute(attribute))); } }); - productionRuntimeClasspath.setVisible(false); productionRuntimeClasspath.setExtendsFrom(runtimeClasspath.getExtendsFrom()); productionRuntimeClasspath.setCanBeResolved(runtimeClasspath.isCanBeResolved()); productionRuntimeClasspath.setCanBeConsumed(runtimeClasspath.isCanBeConsumed()); + } + + private void configureDevelopmentOnlyConfiguration(Project project) { + Configuration developmentOnly = project.getConfigurations() + .create(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME); + developmentOnly + .setDescription("Configuration for development-only dependencies such as Spring Boot's DevTools."); + Configuration runtimeClasspath = project.getConfigurations() + .getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); + runtimeClasspath.extendsFrom(developmentOnly); } + private void configureTestAndDevelopmentOnlyConfiguration(Project project) { + Configuration testAndDevelopmentOnly = project.getConfigurations() + .create(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME); + testAndDevelopmentOnly + .setDescription("Configuration for test and development-only dependencies such as Spring Boot's DevTools."); + Configuration runtimeClasspath = project.getConfigurations() + .getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); + runtimeClasspath.extendsFrom(testAndDevelopmentOnly); + Configuration testImplementation = project.getConfigurations() + .getByName(JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME); + testImplementation.extendsFrom(testAndDevelopmentOnly); + } + /** * Task {@link Action} to add additional meta-data locations. We need to use an * inner-class rather than a lambda due to diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/NativeImagePluginAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/NativeImagePluginAction.java index 2984088e09d8..c4689c80b242 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/NativeImagePluginAction.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/NativeImagePluginAction.java @@ -47,8 +47,7 @@ class NativeImagePluginAction implements PluginApplicationAction { @Override - public Class> getPluginClass() - throws ClassNotFoundException, NoClassDefFoundError { + public Class> getPluginClass() { return NativeImagePlugin.class; } @@ -84,7 +83,8 @@ private Iterable removeDevelopmentOnly(Set configu } private boolean isNotDevelopmentOnly(Configuration configuration) { - return !SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME.equals(configuration.getName()); + return !SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME.equals(configuration.getName()) + && !SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME.equals(configuration.getName()); } private void configureTestNativeBinaryClasspath(SourceSetContainer sourceSets, GraalVMExtension graalVmExtension) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootAotPlugin.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootAotPlugin.java index fa1418d7ed18..6c9af4b85306 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootAotPlugin.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootAotPlugin.java @@ -81,9 +81,9 @@ public void apply(Project project) { JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class); SourceSetContainer sourceSets = javaPluginExtension.getSourceSets(); SourceSet mainSourceSet = sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME); - SourceSet aotSourceSet = configureSourceSet(project, "aot", mainSourceSet); + SourceSet aotSourceSet = configureSourceSet(project, AOT_SOURCE_SET_NAME, mainSourceSet); SourceSet testSourceSet = sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME); - SourceSet aotTestSourceSet = configureSourceSet(project, "aotTest", testSourceSet); + SourceSet aotTestSourceSet = configureSourceSet(project, AOT_TEST_SOURCE_SET_NAME, testSourceSet); plugins.withType(SpringBootPlugin.class).all((bootPlugin) -> { registerProcessAotTask(project, aotSourceSet, mainSourceSet); registerProcessTestAotTask(project, mainSourceSet, aotTestSourceSet, testSourceSet); @@ -119,7 +119,9 @@ private void configureJavaRuntimeUsageAttribute(Project project, AttributeContai private void registerProcessAotTask(Project project, SourceSet aotSourceSet, SourceSet mainSourceSet) { TaskProvider resolveMainClassName = project.getTasks() .named(SpringBootPlugin.RESOLVE_MAIN_CLASS_NAME_TASK_NAME, ResolveMainClassName.class); - Configuration aotClasspath = createAotProcessingClasspath(project, PROCESS_AOT_TASK_NAME, mainSourceSet); + Configuration aotClasspath = createAotProcessingClasspath(project, PROCESS_AOT_TASK_NAME, mainSourceSet, + Set.of(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME, + SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME)); project.getDependencies().add(aotClasspath.getName(), project.files(mainSourceSet.getOutput())); Configuration compileClasspath = project.getConfigurations() .getByName(aotSourceSet.getCompileClasspathConfigurationName()); @@ -129,7 +131,7 @@ private void registerProcessAotTask(Project project, SourceSet aotSourceSet, Sou .dir("generated/" + aotSourceSet.getName() + "Resources"); TaskProvider processAot = project.getTasks() .register(PROCESS_AOT_TASK_NAME, ProcessAot.class, (task) -> { - configureAotTask(project, aotSourceSet, task, mainSourceSet, resourcesOutput); + configureAotTask(project, aotSourceSet, task, resourcesOutput); task.getApplicationMainClass() .set(resolveMainClassName.flatMap(ResolveMainClassName::readMainClassName)); task.setClasspath(aotClasspath); @@ -142,7 +144,7 @@ private void registerProcessAotTask(Project project, SourceSet aotSourceSet, Sou configureDependsOn(project, aotSourceSet, processAot); } - private void configureAotTask(Project project, SourceSet sourceSet, AbstractAot task, SourceSet inputSourceSet, + private void configureAotTask(Project project, SourceSet sourceSet, AbstractAot task, Provider resourcesOutput) { task.getSourcesOutput() .set(project.getLayout().getBuildDirectory().dir("generated/" + sourceSet.getName() + "Sources")); @@ -161,17 +163,19 @@ private void configureToolchainConvention(Project project, AbstractAot aotTask) } @SuppressWarnings({ "unchecked", "rawtypes" }) - private Configuration createAotProcessingClasspath(Project project, String taskName, SourceSet inputSourceSet) { + private Configuration createAotProcessingClasspath(Project project, String taskName, SourceSet inputSourceSet, + Set developmentOnlyConfigurationNames) { Configuration base = project.getConfigurations() .getByName(inputSourceSet.getRuntimeClasspathConfigurationName()); - Configuration aotClasspath = project.getConfigurations().create(taskName + "Classpath", (classpath) -> { + return project.getConfigurations().create(taskName + "Classpath", (classpath) -> { classpath.setCanBeConsumed(false); if (!classpath.isCanBeResolved()) { throw new IllegalStateException("Unexpected"); } classpath.setCanBeResolved(true); classpath.setDescription("Classpath of the " + taskName + " task."); - removeDevelopmentOnly(base.getExtendsFrom()).forEach(classpath::extendsFrom); + removeDevelopmentOnly(base.getExtendsFrom(), developmentOnlyConfigurationNames) + .forEach(classpath::extendsFrom); classpath.attributes((attributes) -> { ProviderFactory providers = project.getProviders(); AttributeContainer baseAttributes = base.getAttributes(); @@ -181,15 +185,12 @@ private Configuration createAotProcessingClasspath(Project project, String taskN } }); }); - return aotClasspath; } - private Stream removeDevelopmentOnly(Set configurations) { - return configurations.stream().filter(this::isNotDevelopmentOnly); - } - - private boolean isNotDevelopmentOnly(Configuration configuration) { - return !SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME.equals(configuration.getName()); + private Stream removeDevelopmentOnly(Set configurations, + Set developmentOnlyConfigurationNames) { + return configurations.stream() + .filter((configuration) -> !developmentOnlyConfigurationNames.contains(configuration.getName())); } private void configureDependsOn(Project project, SourceSet aotSourceSet, @@ -201,7 +202,8 @@ private void configureDependsOn(Project project, SourceSet aotSourceSet, private void registerProcessTestAotTask(Project project, SourceSet mainSourceSet, SourceSet aotTestSourceSet, SourceSet testSourceSet) { - Configuration aotClasspath = createAotProcessingClasspath(project, PROCESS_TEST_AOT_TASK_NAME, testSourceSet); + Configuration aotClasspath = createAotProcessingClasspath(project, PROCESS_TEST_AOT_TASK_NAME, testSourceSet, + Set.of(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME)); addJUnitPlatformLauncherDependency(project, aotClasspath); Configuration compileClasspath = project.getConfigurations() .getByName(aotTestSourceSet.getCompileClasspathConfigurationName()); @@ -211,7 +213,7 @@ private void registerProcessTestAotTask(Project project, SourceSet mainSourceSet .dir("generated/" + aotTestSourceSet.getName() + "Resources"); TaskProvider processTestAot = project.getTasks() .register(PROCESS_TEST_AOT_TASK_NAME, ProcessTestAot.class, (task) -> { - configureAotTask(project, aotTestSourceSet, task, testSourceSet, resourcesOutput); + configureAotTask(project, aotTestSourceSet, task, resourcesOutput); task.setClasspath(aotClasspath); task.setClasspathRoots(testSourceSet.getOutput()); }); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java index 0ff7ca3ac803..ae6530a250c9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java @@ -80,6 +80,12 @@ public class SpringBootPlugin implements Plugin { */ public static final String DEVELOPMENT_ONLY_CONFIGURATION_NAME = "developmentOnly"; + /** + * The name of the {@code testAndDevelopmentOnly} configuration. + * @since 3.2.0 + */ + public static final String TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME = "testAndDevelopmentOnly"; + /** * The name of the {@code productionRuntimeClasspath} configuration. */ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/WarPluginAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/WarPluginAction.java index 2e1382700117..6e2517ebaee9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/WarPluginAction.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/WarPluginAction.java @@ -72,6 +72,8 @@ private void classifyWarTask(Project project) { private TaskProvider configureBootWarTask(Project project) { Configuration developmentOnly = project.getConfigurations() .getByName(SpringBootPlugin.DEVELOPMENT_ONLY_CONFIGURATION_NAME); + Configuration testAndDevelopmentOnly = project.getConfigurations() + .getByName(SpringBootPlugin.TEST_AND_DEVELOPMENT_ONLY_CONFIGURATION_NAME); Configuration productionRuntimeClasspath = project.getConfigurations() .getByName(SpringBootPlugin.PRODUCTION_RUNTIME_CLASSPATH_CONFIGURATION_NAME); SourceSet mainSourceSet = project.getExtensions() @@ -82,6 +84,7 @@ private TaskProvider configureBootWarTask(Project project) { Callable classpath = () -> mainSourceSet.getRuntimeClasspath() .minus(providedRuntimeConfiguration(project)) .minus((developmentOnly.minus(productionRuntimeClasspath))) + .minus((testAndDevelopmentOnly.minus(productionRuntimeClasspath))) .filter(new JarTypeFileSpec()); TaskProvider resolveMainClassName = project.getTasks() .named(SpringBootPlugin.RESOLVE_MAIN_CLASS_NAME_TASK_NAME, ResolveMainClassName.class); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoProperties.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoProperties.java index 5ace201ad715..e75f1a3352e4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoProperties.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/buildinfo/BuildInfoProperties.java @@ -30,6 +30,7 @@ import org.gradle.api.Project; import org.gradle.api.provider.MapProperty; import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Internal; @@ -155,7 +156,12 @@ private T getIfNotExcluded(Property property, String name, Supplier de private Map coerceToStringValues(Map input) { Map output = new HashMap<>(); - input.forEach((key, value) -> output.put(key, (value != null) ? value.toString() : null)); + input.forEach((key, value) -> { + if (value instanceof Provider provider) { + value = provider.getOrNull(); + } + output.put(key, (value != null) ? value.toString() : null); + }); return output; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchive.java index 1f3875a45aab..2da97a470c8d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchive.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchive.java @@ -33,6 +33,8 @@ import org.gradle.api.tasks.Nested; import org.gradle.api.tasks.Optional; +import org.springframework.boot.loader.tools.LoaderImplementation; + /** * A Spring Boot "fat" archive task. * @@ -133,4 +135,13 @@ public interface BootArchive extends Task { */ void resolvedArtifacts(Provider> resolvedArtifacts); + /** + * The loader implementation that should be used with the archive. + * @return the loader implementation + * @since 3.2.0 + */ + @Input + @Optional + Property getLoaderImplementation(); + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchiveSupport.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchiveSupport.java index a87c2e2e32b9..330bc1aef1cd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchiveSupport.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootArchiveSupport.java @@ -46,6 +46,8 @@ import org.gradle.api.tasks.util.PatternSet; import org.gradle.util.GradleVersion; +import org.springframework.boot.loader.tools.LoaderImplementation; + /** * Support class for implementations of {@link BootArchive}. * @@ -65,9 +67,9 @@ class BootArchiveSupport { static { Set defaultLauncherClasses = new HashSet<>(); - defaultLauncherClasses.add("org.springframework.boot.loader.JarLauncher"); - defaultLauncherClasses.add("org.springframework.boot.loader.PropertiesLauncher"); - defaultLauncherClasses.add("org.springframework.boot.loader.WarLauncher"); + defaultLauncherClasses.add("org.springframework.boot.loader.launch.JarLauncher"); + defaultLauncherClasses.add("org.springframework.boot.loader.launch.PropertiesLauncher"); + defaultLauncherClasses.add("org.springframework.boot.loader.launch.WarLauncher"); DEFAULT_LAUNCHER_CLASSES = Collections.unmodifiableSet(defaultLauncherClasses); } @@ -120,11 +122,13 @@ private String determineSpringBootVersion() { return (version != null) ? version : "unknown"; } - CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies) { - return createCopyAction(jar, resolvedDependencies, null, null); + CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies, + LoaderImplementation loaderImplementation, boolean supportsSignatureFile) { + return createCopyAction(jar, resolvedDependencies, loaderImplementation, supportsSignatureFile, null, null); } - CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies, LayerResolver layerResolver, + CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies, + LoaderImplementation loaderImplementation, boolean supportsSignatureFile, LayerResolver layerResolver, String layerToolsLocation) { File output = jar.getArchiveFile().get().getAsFile(); Manifest manifest = jar.getManifest(); @@ -140,7 +144,8 @@ CopyAction createCopyAction(Jar jar, ResolvedDependencies resolvedDependencies, String encoding = jar.getMetadataCharset(); CopyAction action = new BootZipCopyAction(output, manifest, preserveFileTimestamps, dirMode, fileMode, includeDefaultLoader, layerToolsLocation, requiresUnpack, exclusions, launchScript, librarySpec, - compressionResolver, encoding, resolvedDependencies, layerResolver); + compressionResolver, encoding, resolvedDependencies, supportsSignatureFile, layerResolver, + loaderImplementation); return jar.isReproducibleFileOrder() ? new ReproducibleOrderingCopyAction(action) : action; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java index 6b2af0c45a5e..2ba12dae88e8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java @@ -69,6 +69,8 @@ public abstract class BootBuildImage extends DefaultTask { private final String projectName; + private final CacheSpec buildWorkspace; + private final CacheSpec buildCache; private final CacheSpec launchCache; @@ -91,6 +93,7 @@ public BootBuildImage() { getCleanCache().convention(false); getVerboseLogging().convention(false); getPublish().convention(false); + this.buildWorkspace = getProject().getObjects().newInstance(CacheSpec.class); this.buildCache = getProject().getObjects().newInstance(CacheSpec.class); this.launchCache = getProject().getObjects().newInstance(CacheSpec.class); this.docker = getProject().getObjects().newInstance(DockerSpec.class); @@ -222,6 +225,27 @@ public void setPullPolicy(String pullPolicy) { @Option(option = "network", description = "Connect detect and build containers to network") public abstract Property getNetwork(); + /** + * Returns the build temporary workspace that will be used when building the image. + * @return the cache + * @since 3.2.0 + */ + @Nested + @Optional + public CacheSpec getBuildWorkspace() { + return this.buildWorkspace; + } + + /** + * Customizes the {@link CacheSpec} for the build temporary workspace using the given + * {@code action}. + * @param action the action + * @since 3.2.0 + */ + public void buildWorkspace(Action action) { + action.execute(this.buildWorkspace); + } + /** * Returns the build cache that will be used when building the image. * @return the cache @@ -280,6 +304,15 @@ public void launchCache(Action action) { @Option(option = "applicationDirectory", description = "The directory containing application content in the image") public abstract Property getApplicationDirectory(); + /** + * Returns the security options that will be applied to the builder container. + * @return the security options + */ + @Input + @Optional + @Option(option = "securityOptions", description = "Security options that will be applied to the builder container") + public abstract ListProperty getSecurityOptions(); + /** * Returns the Docker configuration the builder will use. * @return docker configuration. @@ -327,6 +360,7 @@ private BuildRequest customize(BuildRequest request) { request = request.withNetwork(getNetwork().getOrNull()); request = customizeCreatedDate(request); request = customizeApplicationDirectory(request); + request = customizeSecurityOptions(request); return request; } @@ -400,6 +434,9 @@ private BuildRequest customizeTags(BuildRequest request) { } private BuildRequest customizeCaches(BuildRequest request) { + if (this.buildWorkspace.asCache() != null) { + request = request.withBuildWorkspace((this.buildWorkspace.asCache())); + } if (this.buildCache.asCache() != null) { request = request.withBuildCache(this.buildCache.asCache()); } @@ -425,4 +462,12 @@ private BuildRequest customizeApplicationDirectory(BuildRequest request) { return request; } + private BuildRequest customizeSecurityOptions(BuildRequest request) { + List securityOptions = getSecurityOptions().getOrNull(); + if (securityOptions != null) { + return request.withSecurityOptions(securityOptions); + } + return request; + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java index 2b2d1cfa2e55..7ed3f998c54f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootJar.java @@ -37,6 +37,8 @@ import org.gradle.api.tasks.bundling.Jar; import org.gradle.work.DisableCachingByDefault; +import org.springframework.boot.loader.tools.LoaderImplementation; + /** * A custom {@link Jar} task that produces a Spring Boot executable jar. * @@ -49,7 +51,7 @@ @DisableCachingByDefault(because = "Not worth caching") public abstract class BootJar extends Jar implements BootArchive { - private static final String LAUNCHER = "org.springframework.boot.loader.JarLauncher"; + private static final String LAUNCHER = "org.springframework.boot.loader.launch.JarLauncher"; private static final String CLASSES_DIRECTORY = "BOOT-INF/classes/"; @@ -141,12 +143,14 @@ private boolean isLayeredDisabled() { @Override protected CopyAction createCopyAction() { + LoaderImplementation loaderImplementation = getLoaderImplementation().getOrElse(LoaderImplementation.DEFAULT); if (!isLayeredDisabled()) { LayerResolver layerResolver = new LayerResolver(this.resolvedDependencies, this.layered, this::isLibrary); String layerToolsLocation = this.layered.getIncludeLayerTools().get() ? LIB_DIRECTORY : null; - return this.support.createCopyAction(this, this.resolvedDependencies, layerResolver, layerToolsLocation); + return this.support.createCopyAction(this, this.resolvedDependencies, loaderImplementation, true, + layerResolver, layerToolsLocation); } - return this.support.createCopyAction(this, this.resolvedDependencies); + return this.support.createCopyAction(this, this.resolvedDependencies, loaderImplementation, true); } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootWar.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootWar.java index 47ce5f0c5410..d19f152f84b6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootWar.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootWar.java @@ -37,6 +37,8 @@ import org.gradle.api.tasks.bundling.War; import org.gradle.work.DisableCachingByDefault; +import org.springframework.boot.loader.tools.LoaderImplementation; + /** * A custom {@link War} task that produces a Spring Boot executable war. * @@ -48,7 +50,7 @@ @DisableCachingByDefault(because = "Not worth caching") public abstract class BootWar extends War implements BootArchive { - private static final String LAUNCHER = "org.springframework.boot.loader.WarLauncher"; + private static final String LAUNCHER = "org.springframework.boot.loader.launch.WarLauncher"; private static final String CLASSES_DIRECTORY = "WEB-INF/classes/"; @@ -115,12 +117,14 @@ private boolean isLayeredDisabled() { @Override protected CopyAction createCopyAction() { + LoaderImplementation loaderImplementation = getLoaderImplementation().getOrElse(LoaderImplementation.DEFAULT); if (!isLayeredDisabled()) { LayerResolver layerResolver = new LayerResolver(this.resolvedDependencies, this.layered, this::isLibrary); String layerToolsLocation = this.layered.getIncludeLayerTools().get() ? LIB_DIRECTORY : null; - return this.support.createCopyAction(this, this.resolvedDependencies, layerResolver, layerToolsLocation); + return this.support.createCopyAction(this, this.resolvedDependencies, loaderImplementation, false, + layerResolver, layerToolsLocation); } - return this.support.createCopyAction(this, this.resolvedDependencies); + return this.support.createCopyAction(this, this.resolvedDependencies, loaderImplementation, false); } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java index 53ccdcece2cb..780944145dc2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java @@ -59,6 +59,7 @@ import org.springframework.boot.loader.tools.Layer; import org.springframework.boot.loader.tools.LayersIndex; import org.springframework.boot.loader.tools.LibraryCoordinates; +import org.springframework.boot.loader.tools.LoaderImplementation; import org.springframework.boot.loader.tools.NativeImageArgFile; import org.springframework.boot.loader.tools.ReachabilityMetadataProperties; import org.springframework.util.Assert; @@ -111,13 +112,18 @@ class BootZipCopyAction implements CopyAction { private final ResolvedDependencies resolvedDependencies; + private final boolean supportsSignatureFile; + private final LayerResolver layerResolver; + private final LoaderImplementation loaderImplementation; + BootZipCopyAction(File output, Manifest manifest, boolean preserveFileTimestamps, Integer dirMode, Integer fileMode, boolean includeDefaultLoader, String layerToolsLocation, Spec requiresUnpack, Spec exclusions, LaunchScriptConfiguration launchScript, Spec librarySpec, Function compressionResolver, String encoding, - ResolvedDependencies resolvedDependencies, LayerResolver layerResolver) { + ResolvedDependencies resolvedDependencies, boolean supportsSignatureFile, LayerResolver layerResolver, + LoaderImplementation loaderImplementation) { this.output = output; this.manifest = manifest; this.preserveFileTimestamps = preserveFileTimestamps; @@ -132,7 +138,9 @@ class BootZipCopyAction implements CopyAction { this.compressionResolver = compressionResolver; this.encoding = encoding; this.resolvedDependencies = resolvedDependencies; + this.supportsSignatureFile = supportsSignatureFile; this.layerResolver = layerResolver; + this.loaderImplementation = loaderImplementation; } @Override @@ -299,6 +307,7 @@ private String getParentDirectory(String name) { void finish() throws IOException { writeLoaderEntriesIfNecessary(null); writeJarToolsIfNecessary(); + writeSignatureFileIfNecessary(); writeClassPathIndexIfNecessary(); writeNativeImageArgFileIfNecessary(); // We must write the layer index last @@ -313,7 +322,8 @@ private void writeLoaderEntriesIfNecessary(FileCopyDetails details) throws IOExc // Always write loader entries after META-INF directory (see gh-16698) return; } - LoaderZipEntries loaderEntries = new LoaderZipEntries(getTime(), getDirMode(), getFileMode()); + LoaderZipEntries loaderEntries = new LoaderZipEntries(getTime(), getDirMode(), getFileMode(), + BootZipCopyAction.this.loaderImplementation); this.writtenLoaderEntries = loaderEntries.writeTo(this.out); if (BootZipCopyAction.this.layerResolver != null) { for (String name : this.writtenLoaderEntries.getFiles()) { @@ -347,6 +357,22 @@ private void writeJarModeLibrary(String location, JarModeLibrary library) throws } } + private void writeSignatureFileIfNecessary() throws IOException { + if (BootZipCopyAction.this.supportsSignatureFile && hasSignedLibrary()) { + writeEntry("META-INF/BOOT.SF", (out) -> { + }, false); + } + } + + private boolean hasSignedLibrary() throws IOException { + for (FileCopyDetails writtenLibrary : this.writtenLibraries.values()) { + if (FileUtils.isSignedJarFile(writtenLibrary.getFile())) { + return true; + } + } + return false; + } + private void writeClassPathIndexIfNecessary() throws IOException { Attributes manifestAttributes = BootZipCopyAction.this.manifest.getAttributes(); String classPathIndex = (String) manifestAttributes.get("Spring-Boot-Classpath-Index"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java index d33d6a964966..235a3665f148 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/CacheSpec.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2022 the original author or authors. + * Copyright 2021-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. @@ -60,6 +60,19 @@ public void volume(Action action) { this.cache = Cache.volume(spec.getName().get()); } + /** + * Configures a bind cache using the given {@code action}. + * @param action the action + */ + public void bind(Action action) { + if (this.cache != null) { + throw new GradleException("Each image building cache can be configured only once"); + } + BindCacheSpec spec = this.objectFactory.newInstance(BindCacheSpec.class); + action.execute(spec); + this.cache = Cache.bind(spec.getSource().get()); + } + /** * Configuration for an image building cache stored in a Docker volume. */ @@ -74,4 +87,18 @@ public abstract static class VolumeCacheSpec { } + /** + * Configuration for an image building cache stored in a bind mount. + */ + public abstract static class BindCacheSpec { + + /** + * Returns the source of the cache. + * @return the cache source + */ + @Input + public abstract Property getSource(); + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java index ce3907a4db16..ffed3ddba17c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DockerSpec.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -54,6 +54,10 @@ public DockerSpec(ObjectFactory objects) { this.publishRegistry = publishRegistry; } + @Input + @Optional + public abstract Property getContext(); + @Input @Optional public abstract Property getHost(); @@ -124,7 +128,15 @@ DockerConfiguration asDockerConfiguration() { } private DockerConfiguration customizeHost(DockerConfiguration dockerConfiguration) { + String context = getContext().getOrNull(); String host = getHost().getOrNull(); + if (context != null && host != null) { + throw new GradleException( + "Invalid Docker configuration, either context or host can be provided but not both"); + } + if (context != null) { + return dockerConfiguration.withContext(context); + } if (host != null) { return dockerConfiguration.withHost(host, getTlsVerify().get(), getCertPath().getOrNull()); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java index dd4d50894ff4..64b3ea1685ee 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java @@ -28,6 +28,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.gradle.api.file.FileTreeElement; +import org.springframework.boot.loader.tools.LoaderImplementation; import org.springframework.util.StreamUtils; /** @@ -39,30 +40,34 @@ */ class LoaderZipEntries { + private final LoaderImplementation loaderImplementation; + private final Long entryTime; private final int dirMode; private final int fileMode; - LoaderZipEntries(Long entryTime, int dirMode, int fileMode) { + LoaderZipEntries(Long entryTime, int dirMode, int fileMode, LoaderImplementation loaderImplementation) { this.entryTime = entryTime; this.dirMode = dirMode; this.fileMode = fileMode; + this.loaderImplementation = (loaderImplementation != null) ? loaderImplementation + : LoaderImplementation.DEFAULT; } WrittenEntries writeTo(ZipArchiveOutputStream out) throws IOException { WrittenEntries written = new WrittenEntries(); try (ZipInputStream loaderJar = new ZipInputStream( - getClass().getResourceAsStream("/META-INF/loader/spring-boot-loader.jar"))) { + getClass().getResourceAsStream("/" + this.loaderImplementation.getJarResourceName()))) { java.util.zip.ZipEntry entry = loaderJar.getNextEntry(); while (entry != null) { if (entry.isDirectory() && !entry.getName().equals("META-INF/")) { writeDirectory(new ZipArchiveEntry(entry), out); written.addDirectory(entry); } - else if (entry.getName().endsWith(".class")) { - writeClass(new ZipArchiveEntry(entry), loaderJar, out); + else if (entry.getName().endsWith(".class") || entry.getName().startsWith("META-INF/services/")) { + writeFile(new ZipArchiveEntry(entry), loaderJar, out); written.addFile(entry); } entry = loaderJar.getNextEntry(); @@ -77,7 +82,7 @@ private void writeDirectory(ZipArchiveEntry entry, ZipArchiveOutputStream out) t out.closeArchiveEntry(); } - private void writeClass(ZipArchiveEntry entry, ZipInputStream in, ZipArchiveOutputStream out) throws IOException { + private void writeFile(ZipArchiveEntry entry, ZipInputStream in, ZipArchiveOutputStream out) throws IOException { prepareEntry(entry, this.fileMode); out.putArchiveEntry(entry); copy(in, out); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java index f38abf738313..d9cfc3d49226 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/docs/PackagingDocumentationTests.java @@ -166,7 +166,7 @@ void bootWarPropertiesLauncher() throws IOException { assertThat(file).isFile(); try (JarFile jar = new JarFile(file)) { assertThat(jar.getManifest().getMainAttributes().getValue("Main-Class")) - .isEqualTo("org.springframework.boot.loader.PropertiesLauncher"); + .isEqualTo("org.springframework.boot.loader.launch.PropertiesLauncher"); } } @@ -347,6 +347,15 @@ void bootBuildImageWithCaches() { .containsPattern("launchCache=cache-gradle-[\\d]+.launch"); } + @TestTemplate + void bootBuildImageWithBindCaches() { + BuildResult result = this.gradleBuild.script("src/docs/gradle/packaging/boot-build-image-bind-caches") + .build("bootBuildImageCaches"); + assertThat(result.getOutput()).containsPattern("buildWorkspace=/tmp/cache-gradle-[\\d]+.work") + .containsPattern("buildCache=/tmp/cache-gradle-[\\d]+.build") + .containsPattern("launchCache=/tmp/cache-gradle-[\\d]+.launch"); + } + protected void jarFile(File file) throws IOException { try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) { jar.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java index a9fdff46ee7b..3c97cff173e7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests.java @@ -144,7 +144,52 @@ void additionalMetadataLocationsNotConfiguredWhenProcessorIsAbsent() throws IOEx @TestTemplate void applyingJavaPluginCreatesDevelopmentOnlyConfiguration() { - assertThat(this.gradleBuild.build("build").getOutput()).contains("developmentOnly exists = true"); + assertThat(this.gradleBuild.build("help").getOutput()).contains("developmentOnly exists = true"); + } + + @TestTemplate + void applyingJavaPluginCreatesTestAndDevelopmentOnlyConfiguration() { + assertThat(this.gradleBuild.build("help").getOutput()).contains("testAndDevelopmentOnly exists = true"); + } + + @TestTemplate + void testCompileClasspathIncludesTestAndDevelopmentOnlyDependencies() { + assertThat(this.gradleBuild.build("help").getOutput()).contains("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void testRuntimeClasspathIncludesTestAndDevelopmentOnlyDependencies() { + assertThat(this.gradleBuild.build("help").getOutput()).contains("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void testCompileClasspathDoesNotIncludeDevelopmentOnlyDependencies() { + assertThat(this.gradleBuild.build("help").getOutput()).doesNotContain("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void testRuntimeClasspathDoesNotIncludeDevelopmentOnlyDependencies() { + assertThat(this.gradleBuild.build("help").getOutput()).doesNotContain("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void compileClasspathDoesNotIncludeTestAndDevelopmentOnlyDependencies() { + assertThat(this.gradleBuild.build("help").getOutput()).doesNotContain("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void runtimeClasspathIncludesTestAndDevelopmentOnlyDependencies() { + assertThat(this.gradleBuild.build("help").getOutput()).contains("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void compileClasspathDoesNotIncludeDevelopmentOnlyDependencies() { + assertThat(this.gradleBuild.build("help").getOutput()).doesNotContain("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void runtimeClasspathIncludesDevelopmentOnlyDependencies() { + assertThat(this.gradleBuild.build("help").getOutput()).contains("commons-lang3-3.12.0.jar"); } @TestTemplate diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/KotlinPluginActionIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/KotlinPluginActionIntegrationTests.java index 85a089bd5084..ab32302702fc 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/KotlinPluginActionIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/KotlinPluginActionIntegrationTests.java @@ -23,11 +23,13 @@ import java.util.Set; import org.gradle.testkit.runner.BuildResult; -import org.gradle.util.GradleVersion; -import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledForJreRange; +import org.junit.jupiter.api.condition.JRE; +import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.gradle.junit.GradleCompatibility; import org.springframework.boot.testsupport.gradle.testkit.GradleBuild; +import org.springframework.boot.testsupport.gradle.testkit.GradleBuildExtension; import static org.assertj.core.api.Assertions.assertThat; @@ -36,43 +38,41 @@ * * @author Andy Wilkinson */ -@GradleCompatibility +@DisabledForJreRange(min = JRE.JAVA_20) +@ExtendWith(GradleBuildExtension.class) class KotlinPluginActionIntegrationTests { - GradleBuild gradleBuild; + GradleBuild gradleBuild = new GradleBuild(); - @TestTemplate + @Test void noKotlinVersionPropertyWithoutKotlinPlugin() { assertThat(this.gradleBuild.build("kotlinVersion").getOutput()).contains("Kotlin version: none"); } - @TestTemplate + @Test void kotlinVersionPropertyIsSet() { - String output = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.1-rc-1") - .build("kotlinVersion", "dependencies", "--configuration", "compileClasspath") + String output = this.gradleBuild.build("kotlinVersion", "dependencies", "--configuration", "compileClasspath") .getOutput(); assertThat(output).containsPattern("Kotlin version: [0-9]\\.[0-9]\\.[0-9]+"); } - @TestTemplate + @Test void kotlinCompileTasksUseJavaParametersFlagByDefault() { - assertThat(this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.1-rc-1") - .build("kotlinCompileTasksJavaParameters") - .getOutput()).contains("compileKotlin java parameters: true") + assertThat(this.gradleBuild.build("kotlinCompileTasksJavaParameters").getOutput()) + .contains("compileKotlin java parameters: true") .contains("compileTestKotlin java parameters: true"); } - @TestTemplate + @Test void kotlinCompileTasksCanOverrideDefaultJavaParametersFlag() { - assertThat(this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.1-rc-1") - .build("kotlinCompileTasksJavaParameters") - .getOutput()).contains("compileKotlin java parameters: false") + assertThat(this.gradleBuild.build("kotlinCompileTasksJavaParameters").getOutput()) + .contains("compileKotlin java parameters: false") .contains("compileTestKotlin java parameters: false"); } - @TestTemplate + @Test void taskConfigurationIsAvoided() throws IOException { - BuildResult result = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.1-rc-1").build("help"); + BuildResult result = this.gradleBuild.build("help"); String output = result.getOutput(); BufferedReader reader = new BufferedReader(new StringReader(output)); String line; @@ -82,16 +82,7 @@ void taskConfigurationIsAvoided() throws IOException { configured.add(line.substring("Configuring :".length())); } } - if (GradleVersion.version(this.gradleBuild.getGradleVersion()).compareTo(GradleVersion.version("7.3.3")) < 0) { - assertThat(configured).containsExactly("help"); - } - else if (GradleVersion.version(this.gradleBuild.getGradleVersion()) - .compareTo(GradleVersion.version("8.3")) < 0) { - assertThat(configured).containsExactlyInAnyOrder("help", "clean"); - } - else { - assertThat(configured).containsExactlyInAnyOrder("help", "clean", "compileJava"); - } + assertThat(configured).containsExactlyInAnyOrder("help", "clean"); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests.java index c7dc9f62436b..87cc9458aec1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests.java @@ -93,7 +93,7 @@ void bootBuildImageIsConfiguredToBuildANativeImage() { writeDummySpringApplicationAotProcessorMainClass(); BuildResult result = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.2-rc-1") .build("bootBuildImageConfiguration"); - assertThat(result.getOutput()).contains("paketobuildpacks/builder-jammy-tiny:latest") + assertThat(result.getOutput()).contains("paketobuildpacks/builder-jammy-tiny") .contains("BP_NATIVE_IMAGE = true"); } @@ -105,6 +105,14 @@ void developmentOnlyDependenciesDoNotAppearInNativeImageClasspath() { assertThat(result.getOutput()).doesNotContain("commons-lang"); } + @TestTemplate + void testAndDevelopmentOnlyDependenciesDoNotAppearInNativeImageClasspath() { + writeDummySpringApplicationAotProcessorMainClass(); + BuildResult result = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.2-rc-1") + .build("checkNativeImageClasspath"); + assertThat(result.getOutput()).doesNotContain("commons-lang"); + } + @TestTemplate void classesGeneratedDuringAotProcessingAreOnTheNativeImageClasspath() { BuildResult result = this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.2-rc-1") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests.java index aead5ebcc535..7abf855bbeae 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests.java @@ -106,10 +106,22 @@ void processAotDoesNotHaveDevelopmentOnlyDependenciesOnItsClasspath() { @TestTemplate void processTestAotDoesNotHaveDevelopmentOnlyDependenciesOnItsClasspath() { - String output = this.gradleBuild.build("processTestAotClasspath", "--stacktrace").getOutput(); + String output = this.gradleBuild.build("processTestAotClasspath").getOutput(); + assertThat(output).doesNotContain("commons-lang"); + } + + @TestTemplate + void processAotDoesNotHaveTestAndDevelopmentOnlyDependenciesOnItsClasspath() { + String output = this.gradleBuild.build("processAotClasspath").getOutput(); assertThat(output).doesNotContain("commons-lang"); } + @TestTemplate + void processTestAotHasTestAndDevelopmentOnlyDependenciesOnItsClasspath() { + String output = this.gradleBuild.build("processTestAotClasspath").getOutput(); + assertThat(output).contains("commons-lang"); + } + @TestTemplate void processAotRunsWhenProjectHasMainSource() throws IOException { writeMainClass("org.springframework.boot", "SpringApplicationAotProcessor"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveIntegrationTests.java index 42a4f9918156..4019ffbf6aff 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveIntegrationTests.java @@ -106,6 +106,16 @@ void reproducibleArchive() throws IOException, InterruptedException { assertThat(firstHash).isEqualTo(secondHash); } + @TestTemplate + void classicLoader() throws IOException { + assertThat(this.gradleBuild.build(this.taskName).task(":" + this.taskName).getOutcome()) + .isEqualTo(TaskOutcome.SUCCESS); + File jar = new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0]; + try (JarFile jarFile = new JarFile(jar)) { + assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNotNull(); + } + } + @TestTemplate void upToDateWhenBuiltTwice() { assertThat(this.gradleBuild.build(this.taskName).task(":" + this.taskName).getOutcome()) @@ -221,6 +231,41 @@ void developmentOnlyDependenciesCanBeIncludedInTheArchive() throws IOException { } } + @TestTemplate + void testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault() throws IOException { + File srcMainResources = new File(this.gradleBuild.getProjectDir(), "src/main/resources"); + srcMainResources.mkdirs(); + new File(srcMainResources, "resource").createNewFile(); + assertThat(this.gradleBuild.build(this.taskName).task(":" + this.taskName).getOutcome()) + .isEqualTo(TaskOutcome.SUCCESS); + try (JarFile jarFile = new JarFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) { + Stream libEntryNames = jarFile.stream() + .filter((entry) -> !entry.isDirectory()) + .map(JarEntry::getName) + .filter((name) -> name.startsWith(this.libPath)); + assertThat(libEntryNames).containsExactly(this.libPath + "commons-io-2.6.jar"); + Stream classesEntryNames = jarFile.stream() + .filter((entry) -> !entry.isDirectory()) + .map(JarEntry::getName) + .filter((name) -> name.startsWith(this.classesPath)); + assertThat(classesEntryNames).containsExactly(this.classesPath + "resource"); + } + } + + @TestTemplate + void testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive() throws IOException { + assertThat(this.gradleBuild.build(this.taskName).task(":" + this.taskName).getOutcome()) + .isEqualTo(TaskOutcome.SUCCESS); + try (JarFile jarFile = new JarFile(new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0])) { + Stream libEntryNames = jarFile.stream() + .filter((entry) -> !entry.isDirectory()) + .map(JarEntry::getName) + .filter((name) -> name.startsWith(this.libPath)); + assertThat(libEntryNames).containsExactly(this.libPath + "commons-io-2.6.jar", + this.libPath + "commons-lang3-3.9.jar"); + } + } + @TestTemplate void jarTypeFilteringIsApplied() throws IOException { File flatDirRepository = new File(this.gradleBuild.getProjectDir(), "repository"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java index ec338420f1b9..c5c78eb5a21c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java @@ -65,6 +65,7 @@ import org.springframework.boot.gradle.junit.GradleProjectBuilder; import org.springframework.boot.loader.tools.DefaultLaunchScript; import org.springframework.boot.loader.tools.JarModeLibrary; +import org.springframework.boot.loader.tools.LoaderImplementation; import org.springframework.util.FileCopyUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -255,7 +256,8 @@ void loaderIsWrittenToTheRootOfTheJarAfterManifest() throws IOException { this.task.getMainClass().set("com.example.Main"); executeTask(); try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) { - assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNotNull(); + assertThat(jarFile.getEntry("org/springframework/boot/loader/launch/LaunchedClassLoader.class")) + .isNotNull(); assertThat(jarFile.getEntry("org/springframework/boot/loader/")).isNotNull(); } // gh-16698 @@ -270,7 +272,21 @@ void loaderIsWrittenToTheRootOfTheJarAfterManifest() throws IOException { void loaderIsWrittenToTheRootOfTheJarWhenUsingThePropertiesLauncher() throws IOException { this.task.getMainClass().set("com.example.Main"); executeTask(); - this.task.getManifest().getAttributes().put("Main-Class", "org.springframework.boot.loader.PropertiesLauncher"); + this.task.getManifest() + .getAttributes() + .put("Main-Class", "org.springframework.boot.loader.launch.PropertiesLauncher"); + try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) { + assertThat(jarFile.getEntry("org/springframework/boot/loader/launch/LaunchedClassLoader.class")) + .isNotNull(); + assertThat(jarFile.getEntry("org/springframework/boot/loader/")).isNotNull(); + } + } + + @Test + void loaderIsWrittenToTheRootOfTheJarWhenUsingClassicLoader() throws IOException { + this.task.getMainClass().set("com.example.Main"); + this.task.getLoaderImplementation().set(LoaderImplementation.CLASSIC); + executeTask(); try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) { assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNotNull(); assertThat(jarFile.getEntry("org/springframework/boot/loader/")).isNotNull(); @@ -362,7 +378,7 @@ void customMainClassInTheManifestIsHonored() throws IOException { assertThat(jarFile.getManifest().getMainAttributes().getValue("Main-Class")) .isEqualTo("com.example.CustomLauncher"); assertThat(jarFile.getManifest().getMainAttributes().getValue("Start-Class")).isEqualTo("com.example.Main"); - assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNull(); + assertThat(jarFile.getEntry("org/springframework/boot/loader/launch/LaunchedClassLoader.class")).isNull(); } } @@ -488,7 +504,7 @@ void archiveShouldBeLayeredByDefault() throws IOException { void jarWhenLayersDisabledShouldNotContainLayersIndex() throws IOException { List entryNames = getEntryNames( createLayeredJar((configuration) -> configuration.getEnabled().set(false))); - assertThat(entryNames).doesNotContain(this.indexPath + "layers.idx"); + assertThat(entryNames).isNotEmpty().doesNotContain(this.indexPath + "layers.idx"); } @Test @@ -605,7 +621,7 @@ void whenArchiveIsLayeredThenLayerToolsAreAddedToTheJar() throws IOException { void whenArchiveIsLayeredAndIncludeLayerToolsIsFalseThenLayerToolsAreNotAddedToTheJar() throws IOException { List entryNames = getEntryNames( createLayeredJar((configuration) -> configuration.getIncludeLayerTools().set(false))); - assertThat(entryNames) + assertThat(entryNames).isNotEmpty() .doesNotContain(this.indexPath + "layers/dependencies/lib/spring-boot-jarmode-layertools.jar"); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java index 2a2238c4a9ee..b20924ad7d6b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests.java @@ -37,6 +37,7 @@ import org.gradle.testkit.runner.BuildResult; import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.condition.EnabledOnOs; import org.junit.jupiter.api.condition.OS; import org.springframework.boot.buildpack.platform.docker.DockerApi; @@ -50,6 +51,7 @@ import org.springframework.boot.testsupport.gradle.testkit.GradleBuild; import org.springframework.boot.testsupport.junit.DisabledOnOs; import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; +import org.springframework.util.FileSystemUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -297,6 +299,28 @@ void buildsImageWithVolumeCaches() throws IOException { deleteVolumes("cache-" + projectName + ".build", "cache-" + projectName + ".launch"); } + @TestTemplate + @EnabledOnOs(value = OS.LINUX, disabledReason = "Works with Docker Engine on Linux but is not reliable with " + + "Docker Desktop on other OSs") + void buildsImageWithBindCaches() throws IOException { + writeMainClass(); + writeLongNameResource(); + BuildResult result = this.gradleBuild.build("bootBuildImage"); + String projectName = this.gradleBuild.getProjectDir().getName(); + assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertThat(result.getOutput()).contains("docker.io/library/" + projectName); + assertThat(result.getOutput()).contains("---> Test Info buildpack building"); + assertThat(result.getOutput()).contains("---> Test Info buildpack done"); + removeImages(projectName); + String tempDir = System.getProperty("java.io.tmpdir"); + Path buildCachePath = Paths.get(tempDir, "junit-image-cache-" + projectName + "-build"); + Path launchCachePath = Paths.get(tempDir, "junit-image-cache-" + projectName + "-launch"); + assertThat(buildCachePath).exists().isDirectory(); + assertThat(launchCachePath).exists().isDirectory(); + FileSystemUtils.deleteRecursively(buildCachePath); + FileSystemUtils.deleteRecursively(launchCachePath); + } + @TestTemplate void buildsImageWithCreatedDate() throws IOException { writeMainClass(); @@ -344,6 +368,19 @@ void buildsImageWithApplicationDirectory() throws IOException { removeImages(projectName); } + @TestTemplate + void buildsImageWithEmptySecurityOptions() throws IOException { + writeMainClass(); + writeLongNameResource(); + BuildResult result = this.gradleBuild.build("bootBuildImage"); + String projectName = this.gradleBuild.getProjectDir().getName(); + assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertThat(result.getOutput()).contains("docker.io/library/" + projectName); + assertThat(result.getOutput()).contains("---> Test Info buildpack building"); + assertThat(result.getOutput()).contains("---> Test Info buildpack done"); + removeImages(projectName); + } + @TestTemplate void failsWithInvalidCreatedDate() throws IOException { writeMainClass(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java index 146fb595ae60..d83e54ed165c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests.java @@ -16,12 +16,15 @@ package org.springframework.boot.gradle.tasks.bundling; +import java.io.File; import java.io.IOException; import java.util.Arrays; import java.util.Set; import java.util.TreeSet; +import java.util.jar.JarFile; import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.TaskOutcome; import org.junit.jupiter.api.TestTemplate; import org.springframework.boot.gradle.junit.GradleCompatibility; @@ -42,6 +45,15 @@ class BootJarIntegrationTests extends AbstractBootArchiveIntegrationTests { super("bootJar", "BOOT-INF/lib/", "BOOT-INF/classes/", "BOOT-INF/"); } + @TestTemplate + void signed() throws Exception { + assertThat(this.gradleBuild.build("bootJar").task(":bootJar").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + File jar = new File(this.gradleBuild.getProjectDir(), "build/libs").listFiles()[0]; + try (JarFile jarFile = new JarFile(jar)) { + assertThat(jarFile.getEntry("META-INF/BOOT.SF")).isNotNull(); + } + } + @TestTemplate void whenAResolvableCopyOfAnUnresolvableConfigurationIsResolvedThenResolutionSucceeds() { this.gradleBuild.expectDeprecationWarningsWithAtLeastVersion("8.0").build("build"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java index 5355e1ba79d8..2cbe89cf5712 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java @@ -41,7 +41,7 @@ class BootJarTests extends AbstractBootArchiveTests { BootJarTests() { - super(BootJar.class, "org.springframework.boot.loader.JarLauncher", "BOOT-INF/lib/", "BOOT-INF/classes/", + super(BootJar.class, "org.springframework.boot.loader.launch.JarLauncher", "BOOT-INF/lib/", "BOOT-INF/classes/", "BOOT-INF/"); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java index e53080ca779f..8728298b4936 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java @@ -39,7 +39,7 @@ class BootWarTests extends AbstractBootArchiveTests { BootWarTests() { - super(BootWar.class, "org.springframework.boot.loader.WarLauncher", "WEB-INF/lib/", "WEB-INF/classes/", + super(BootWar.class, "org.springframework.boot.loader.launch.WarLauncher", "WEB-INF/lib/", "WEB-INF/classes/", "WEB-INF/"); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java index 7cce2758b0ad..3252cedb2da0 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DockerSpecTests.java @@ -25,7 +25,7 @@ import org.junit.jupiter.api.io.TempDir; import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; -import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; import org.springframework.boot.gradle.junit.GradleProjectBuilder; import static org.assertj.core.api.Assertions.assertThat; @@ -68,10 +68,11 @@ void asDockerConfigurationWithHostConfiguration() { this.dockerSpec.getTlsVerify().set(true); this.dockerSpec.getCertPath().set("/tmp/ca-cert"); DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration(); - DockerHost host = dockerConfiguration.getHost(); + DockerHostConfiguration host = dockerConfiguration.getHost(); assertThat(host.getAddress()).isEqualTo("docker.example.com"); assertThat(host.isSecure()).isTrue(); assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert"); + assertThat(host.getContext()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) @@ -85,10 +86,11 @@ void asDockerConfigurationWithHostConfiguration() { void asDockerConfigurationWithHostConfigurationNoTlsVerify() { this.dockerSpec.getHost().set("docker.example.com"); DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration(); - DockerHost host = dockerConfiguration.getHost(); + DockerHostConfiguration host = dockerConfiguration.getHost(); assertThat(host.getAddress()).isEqualTo("docker.example.com"); assertThat(host.isSecure()).isFalse(); assertThat(host.getCertificatePath()).isNull(); + assertThat(host.getContext()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) @@ -98,12 +100,38 @@ void asDockerConfigurationWithHostConfigurationNoTlsVerify() { .contains("\"serveraddress\" : \"\""); } + @Test + void asDockerConfigurationWithContextConfiguration() { + this.dockerSpec.getContext().set("test-context"); + DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration(); + DockerHostConfiguration host = dockerConfiguration.getHost(); + assertThat(host.getContext()).isEqualTo("test-context"); + assertThat(host.getAddress()).isNull(); + assertThat(host.isSecure()).isFalse(); + assertThat(host.getCertificatePath()).isNull(); + assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); + assertThat(this.dockerSpec.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); + assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) + .contains("\"username\" : \"\"") + .contains("\"password\" : \"\"") + .contains("\"email\" : \"\"") + .contains("\"serveraddress\" : \"\""); + } + + @Test + void asDockerConfigurationWithHostAndContextFails() { + this.dockerSpec.getContext().set("test-context"); + this.dockerSpec.getHost().set("docker.example.com"); + assertThatExceptionOfType(GradleException.class).isThrownBy(this.dockerSpec::asDockerConfiguration) + .withMessageContaining("Invalid Docker configuration"); + } + @Test void asDockerConfigurationWithBindHostToBuilder() { this.dockerSpec.getHost().set("docker.example.com"); this.dockerSpec.getBindHostToBuilder().set(true); DockerConfiguration dockerConfiguration = this.dockerSpec.asDockerConfiguration(); - DockerHost host = dockerConfiguration.getHost(); + DockerHostConfiguration host = dockerConfiguration.getHost(); assertThat(host.getAddress()).isEqualTo("docker.example.com"); assertThat(host.isSecure()).isFalse(); assertThat(host.getCertificatePath()).isNull(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests.java index 7550399b8824..54ee2a33f0f5 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests.java @@ -146,6 +146,22 @@ void classesFromASecondarySourceSetCanBeOnTheClasspath() throws IOException { assertThat(result.getOutput()).contains("com.example.bootrun.main.CustomMainClass"); } + @TestTemplate + void developmentOnlyDependenciesAreOnTheClasspath() throws IOException { + copyClasspathApplication(); + BuildResult result = this.gradleBuild.build("bootRun"); + assertThat(result.task(":bootRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertThat(result.getOutput()).contains("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void testAndDevelopmentOnlyDependenciesAreOnTheClasspath() throws IOException { + copyClasspathApplication(); + BuildResult result = this.gradleBuild.build("bootRun"); + assertThat(result.task(":bootRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertThat(result.getOutput()).contains("commons-lang3-3.12.0.jar"); + } + private void copyMainClassApplication() throws IOException { copyApplication("main"); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests.java index 8bb449d09543..79b884dcb7ef 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests.java @@ -117,6 +117,22 @@ void failsGracefullyWhenNoTestMainMethodIsFound() throws IOException { } } + @TestTemplate + void developmentOnlyDependenciesAreNotOnTheClasspath() throws IOException { + copyClasspathApplication(); + BuildResult result = this.gradleBuild.build("bootTestRun"); + assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertThat(result.getOutput()).doesNotContain("commons-lang3-3.12.0.jar"); + } + + @TestTemplate + void testAndDevelopmentOnlyDependenciesAreOnTheClasspath() throws IOException { + copyClasspathApplication(); + BuildResult result = this.gradleBuild.build("bootTestRun"); + assertThat(result.task(":bootTestRun").getOutcome()).isEqualTo(TaskOutcome.SUCCESS); + assertThat(result.getOutput()).contains("commons-lang3-3.12.0.jar"); + } + private void copyClasspathApplication() throws IOException { copyApplication("classpath"); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/dsl/BuildInfoDslIntegrationTests-additionalProperties.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/dsl/BuildInfoDslIntegrationTests-additionalProperties.gradle index 0567e3acb71c..d8d2b8d319e6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/dsl/BuildInfoDslIntegrationTests-additionalProperties.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/dsl/BuildInfoDslIntegrationTests-additionalProperties.gradle @@ -10,7 +10,8 @@ springBoot { buildInfo { properties { additional = [ - 'a': 'alpha', 'b': 'bravo' + 'a': 'alpha', + 'b': providers.provider({'bravo'}) ] } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-applyingJavaPluginCreatesTestAndDevelopmentOnlyConfiguration.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-applyingJavaPluginCreatesTestAndDevelopmentOnlyConfiguration.gradle new file mode 100644 index 000000000000..ebf12ae42908 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-applyingJavaPluginCreatesTestAndDevelopmentOnlyConfiguration.gradle @@ -0,0 +1,12 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +gradle.taskGraph.whenReady { + println "testAndDevelopmentOnly exists = ${configurations.findByName('testAndDevelopmentOnly') != null}" +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle new file mode 100644 index 000000000000..b956631b4634 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + developmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +gradle.taskGraph.whenReady { + configurations.compileClasspath.resolve().each { println it } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeTestAndDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeTestAndDevelopmentOnlyDependencies.gradle new file mode 100644 index 000000000000..6e53332c97f5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-compileClasspathDoesNotIncludeTestAndDevelopmentOnlyDependencies.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +gradle.taskGraph.whenReady { + configurations.compileClasspath.resolve().each { println it } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesDevelopmentOnlyDependencies.gradle new file mode 100644 index 000000000000..a8b1f4d3bffd --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesDevelopmentOnlyDependencies.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + developmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +gradle.taskGraph.whenReady { + configurations.runtimeClasspath.resolve().each { println it } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle new file mode 100644 index 000000000000..6a51fe371128 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-runtimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +gradle.taskGraph.whenReady { + configurations.runtimeClasspath.resolve().each { println it } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle new file mode 100644 index 000000000000..264944e602a6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + developmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +gradle.taskGraph.whenReady { + configurations.testCompileClasspath.resolve().each { println it } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle new file mode 100644 index 000000000000..1f934deadb11 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testCompileClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +gradle.taskGraph.whenReady { + configurations.testCompileClasspath.resolve().each { println it } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle new file mode 100644 index 000000000000..4334e2a97bd2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathDoesNotIncludeDevelopmentOnlyDependencies.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + developmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +gradle.taskGraph.whenReady { + configurations.testRuntimeClasspath.resolve().each { println it } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle new file mode 100644 index 000000000000..581c58617968 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/JavaPluginActionIntegrationTests-testRuntimeClasspathIncludesTestAndDevelopmentOnlyDependencies.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +springBoot { + mainClass = "com.example.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +gradle.taskGraph.whenReady { + configurations.testRuntimeClasspath.resolve().each { println it } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests-testAndDevelopmentOnlyDependenciesDoNotAppearInNativeImageClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests-testAndDevelopmentOnlyDependenciesDoNotAppearInNativeImageClasspath.gradle new file mode 100644 index 000000000000..62d4912299a1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/NativeImagePluginActionIntegrationTests-testAndDevelopmentOnlyDependenciesDoNotAppearInNativeImageClasspath.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +apply plugin: 'org.graalvm.buildtools.native' + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +task('checkNativeImageClasspath') { + doFirst { + tasks.nativeCompile.options.get().classpath.each { println it } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processAotDoesNotHaveTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processAotDoesNotHaveTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle new file mode 100644 index 000000000000..0568dc1a9c21 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processAotDoesNotHaveTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle @@ -0,0 +1,20 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +apply plugin: 'org.springframework.boot.aot' + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +task('processAotClasspath') { + doFirst { + tasks.processAot.classpath.each { println it } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processTestAotHasTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processTestAotHasTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle new file mode 100644 index 000000000000..fe8815af3f30 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/SpringBootAotPluginIntegrationTests-processTestAotHasTestAndDevelopmentOnlyDependenciesOnItsClasspath.gradle @@ -0,0 +1,31 @@ +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +apply plugin: 'org.springframework.boot.aot' + +repositories { + mavenCentral() + maven { url 'file:repository' } +} + +configurations.all { + resolutionStrategy { + eachDependency { + if (it.requested.group == 'org.springframework.boot') { + it.useVersion project.bootVersion + } + } + } +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} + +task('processTestAotClasspath') { + doFirst { + tasks.processTestAot.classpath.each { println it } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBindCaches.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBindCaches.gradle new file mode 100644 index 000000000000..e4c4e40a455e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithBindCaches.gradle @@ -0,0 +1,24 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +bootBuildImage { + builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2" + pullPolicy = "IF_NOT_PRESENT" + buildWorkspace { + bind { + source = System.getProperty('java.io.tmpdir') + "/junit-image-pack-${rootProject.name}-work" + } + } + buildCache { + bind { + source = System.getProperty('java.io.tmpdir') + "/junit-image-cache-${rootProject.name}-build" + } + } + launchCache { + bind { + source = System.getProperty('java.io.tmpdir') + "/junit-image-cache-${rootProject.name}-launch" + } + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithEmptySecurityOptions.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithEmptySecurityOptions.gradle new file mode 100644 index 000000000000..5cac53acfd9b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithEmptySecurityOptions.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +bootBuildImage { + builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2" + pullPolicy = "IF_NOT_PRESENT" + securityOptions = [] +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle index d416020ccd2e..a9e1be87fb78 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-buildsImageWithVolumeCaches.gradle @@ -6,6 +6,11 @@ plugins { bootBuildImage { builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2" pullPolicy = "IF_NOT_PRESENT" + buildWorkspace { + volume { + name = "pack-${rootProject.name}.work" + } + } buildCache { volume { name = "cache-${rootProject.name}.build" diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle index f421a19e7c15..77068a791fcc 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootBuildImageIntegrationTests-failsWhenCachesAreConfiguredTwice.gradle @@ -7,10 +7,10 @@ bootBuildImage { builder = "projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2" buildCache { volume { - name = "build-cache-volume1" + name = "build-cache-volume" } - volume { - name = "build-cache-volum2" + bind { + name = "/tmp/build-cache-bind" } } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-classicLoader.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-classicLoader.gradle new file mode 100644 index 000000000000..2e9e26c99cad --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-classicLoader.gradle @@ -0,0 +1,9 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +bootJar { + mainClass = 'com.example.Application' + loaderImplementation = org.springframework.boot.loader.tools.LoaderImplementation.CLASSIC +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-explodedApplicationClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-explodedApplicationClasspath.gradle index d4fb21f8c9f0..c0139a8a971d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-explodedApplicationClasspath.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-explodedApplicationClasspath.gradle @@ -21,5 +21,5 @@ task explode(type: Sync) { task launch(type: JavaExec) { classpath = files(explode) - mainClass = 'org.springframework.boot.loader.JarLauncher' + mainClass = 'org.springframework.boot.loader.launch.JarLauncher' } \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-signed.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-signed.gradle new file mode 100644 index 000000000000..e879cc96e8a0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-signed.gradle @@ -0,0 +1,17 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +bootJar { + mainClass = 'com.example.Application' +} + +repositories { + mavenCentral() + maven { url "file:repository" } +} + +dependencies { + implementation("org.bouncycastle:bcprov-jdk18on:1.76") +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle new file mode 100644 index 000000000000..7f4ca313065c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle @@ -0,0 +1,24 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +bootJar { + mainClass = 'com.example.Application' +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.9") + testAndDevelopmentOnly("commons-io:commons-io:2.6") + implementation("commons-io:commons-io:2.6") +} + +bootJar { + layered { + enabled = false + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle new file mode 100644 index 000000000000..45041d1c1908 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootJarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +bootJar { + mainClass = 'com.example.Application' +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.9") + implementation("commons-io:commons-io:2.6") +} + +bootJar { + classpath configurations.testAndDevelopmentOnly +} + +bootJar { + layered { + enabled = false + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-classicLoader.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-classicLoader.gradle new file mode 100644 index 000000000000..fd14cc1a64af --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-classicLoader.gradle @@ -0,0 +1,9 @@ +plugins { + id 'war' + id 'org.springframework.boot' version '{version}' +} + +bootWar { + mainClass = 'com.example.Application' + loaderImplementation = org.springframework.boot.loader.tools.LoaderImplementation.CLASSIC +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle new file mode 100644 index 000000000000..f2d285e40810 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesAreNotIncludedInTheArchiveByDefault.gradle @@ -0,0 +1,24 @@ +plugins { + id 'war' + id 'org.springframework.boot' version '{version}' +} + +bootWar { + mainClass = 'com.example.Application' +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.9") + testAndDevelopmentOnly("commons-io:commons-io:2.6") + implementation("commons-io:commons-io:2.6") +} + +bootWar { + layered { + enabled = false + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle new file mode 100644 index 000000000000..de8e9d652170 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/bundling/BootWarIntegrationTests-testAndDevelopmentOnlyDependenciesCanBeIncludedInTheArchive.gradle @@ -0,0 +1,27 @@ +plugins { + id 'war' + id 'org.springframework.boot' version '{version}' +} + +bootWar { + mainClass = 'com.example.Application' +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.9") + implementation("commons-io:commons-io:2.6") +} + +bootWar { + classpath configurations.testAndDevelopmentOnly +} + +bootWar { + layered { + enabled = false + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-developmentOnlyDependenciesAreOnTheClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-developmentOnlyDependenciesAreOnTheClasspath.gradle new file mode 100644 index 000000000000..39b58bb700d6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-developmentOnlyDependenciesAreOnTheClasspath.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +repositories { + mavenCentral() +} + +dependencies { + developmentOnly("org.apache.commons:commons-lang3:3.12.0") +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle new file mode 100644 index 000000000000..6d4b5ce828fb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-developmentOnlyDependenciesAreNotOnTheClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-developmentOnlyDependenciesAreNotOnTheClasspath.gradle new file mode 100644 index 000000000000..39b58bb700d6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-developmentOnlyDependenciesAreNotOnTheClasspath.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +repositories { + mavenCentral() +} + +dependencies { + developmentOnly("org.apache.commons:commons-lang3:3.12.0") +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle new file mode 100644 index 000000000000..6d4b5ce828fb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/tasks/run/BootTestRunIntegrationTests-testAndDevelopmentOnlyDependenciesAreOnTheClasspath.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '{version}' +} + +repositories { + mavenCentral() +} + +dependencies { + testAndDevelopmentOnly("org.apache.commons:commons-lang3:3.12.0") +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleBuild.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleBuild.java index 22b8b43a2c02..6fb983549f13 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleBuild.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-test-support/src/main/java/org/springframework/boot/testsupport/gradle/testkit/GradleBuild.java @@ -118,7 +118,6 @@ private List pluginClasspath() { new File(pathOfJarContaining(ClassVisitor.class)), new File(pathOfJarContaining(DependencyManagementPlugin.class)), new File(pathOfJarContaining("org.jetbrains.kotlin.cli.common.PropertiesKt")), - new File(pathOfJarContaining("org.jetbrains.kotlin.compilerRunner.KotlinLogger")), new File(pathOfJarContaining(KotlinPlatformJvmPlugin.class)), new File(pathOfJarContaining(KotlinProject.class)), new File(pathOfJarContaining(KotlinToolingVersion.class)), diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/build.gradle index 1f78242394e5..96d503924997 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/build.gradle @@ -7,7 +7,7 @@ plugins { description = "Spring Boot Layers Tools" dependencies { - implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader")) + implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-classic")) implementation("org.springframework:spring-core") testImplementation("org.assertj:assertj-core") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/HelpCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/HelpCommand.java index 70f5d385c3fb..4f25c80dc259 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/HelpCommand.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/HelpCommand.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -40,10 +40,10 @@ class HelpCommand extends Command { @Override protected void run(Map options, List parameters) { - run(System.out, options, parameters); + run(System.out, parameters); } - void run(PrintStream out, Map options, List parameters) { + void run(PrintStream out, List parameters) { Command command = (!parameters.isEmpty()) ? Command.find(this.commands, parameters.get(0)) : null; if (command != null) { printCommandHelp(out, command); @@ -66,8 +66,7 @@ private void printCommandHelp(PrintStream out, Command command) { } private void printOptionSummary(PrintStream out, Option option, int padding) { - out.println(String.format(" --%-" + padding + "s %s", option.getNameAndValueDescription(), - option.getDescription())); + out.printf(" --%-" + padding + "s %s%n", option.getNameAndValueDescription(), option.getDescription()); } private String getUsage(Command command) { @@ -76,7 +75,7 @@ private String getUsage(Command command) { if (!command.getOptions().isEmpty()) { usage.append(" [options]"); } - command.getParameters().getDescriptions().forEach((param) -> usage.append(" " + param)); + command.getParameters().getDescriptions().forEach((param) -> usage.append(" ").append(param)); return usage.toString(); } @@ -95,7 +94,7 @@ private int getMaxLength(int minimum, Stream strings) { } private void printCommandSummary(PrintStream out, Command command, int padding) { - out.println(String.format(" %-" + padding + "s %s", command.getName(), command.getDescription())); + out.printf(" %-" + padding + "s %s%n", command.getName(), command.getDescription()); } private String getJavaCommand() { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractCommandTests.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractCommandTests.java index c2d2a815dcb3..319e59c69af9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractCommandTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractCommandTests.java @@ -23,7 +23,6 @@ import java.io.InputStreamReader; import java.lang.Runtime.Version; import java.nio.file.Files; -import java.nio.file.LinkOption; import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; @@ -109,7 +108,7 @@ void runExtractsLayers() { private void timeAttributes(File file) { try { BasicFileAttributes basicAttributes = Files - .getFileAttributeView(file.toPath(), BasicFileAttributeView.class, new LinkOption[0]) + .getFileAttributeView(file.toPath(), BasicFileAttributeView.class) .readAttributes(); assertThat(basicAttributes.lastModifiedTime().to(TimeUnit.SECONDS)) .isEqualTo(LAST_MODIFIED_TIME.to(TimeUnit.SECONDS)); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/HelpCommandTests.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/HelpCommandTests.java index 4491d8798f18..9acb9c9c6ca8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/HelpCommandTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/HelpCommandTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -62,13 +62,13 @@ void setup() throws Exception { @Test void runWhenHasNoParametersPrintsUsage() { - this.command.run(this.out, Collections.emptyMap(), Collections.emptyList()); + this.command.run(this.out, Collections.emptyList()); assertThat(this.out).hasSameContentAsResource("help-output.txt"); } @Test void runWhenHasNoCommandParameterPrintsUsage() { - this.command.run(this.out, Collections.emptyMap(), Arrays.asList("extract")); + this.command.run(this.out, Arrays.asList("extract")); System.out.println(this.out); assertThat(this.out).hasSameContentAsResource("help-extract-output.txt"); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/build.gradle new file mode 100644 index 000000000000..17d2a7b519a5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/build.gradle @@ -0,0 +1,23 @@ +plugins { + id "java-library" + id "org.springframework.boot.conventions" + id "org.springframework.boot.deployed" +} + +description = "Spring Boot Classic Loader" + +dependencies { + compileOnly("org.springframework:spring-core") + + testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) + testImplementation("org.assertj:assertj-core") + testImplementation("org.awaitility:awaitility") + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.mockito:mockito-core") + testImplementation("org.springframework:spring-test") + testImplementation("org.springframework:spring-core-test") + + testRuntimeOnly("ch.qos.logback:logback-classic") + testRuntimeOnly("org.bouncycastle:bcprov-jdk18on:1.71") + testRuntimeOnly("org.springframework:spring-webmvc") +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java similarity index 98% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java index c08407941b3a..5ad01e507127 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java index 91b84b1140de..d2ceaf61c565 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/JarLauncher.java similarity index 97% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/JarLauncher.java index 2c86b3d41f43..5061573e2460 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/JarLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/Launcher.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/Launcher.java index f83f685d24f7..2f4cac944408 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/Launcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/MainMethodRunner.java similarity index 96% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/MainMethodRunner.java index 9b7a551a8b6d..12355a2bef46 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/MainMethodRunner.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/WarLauncher.java similarity index 96% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/WarLauncher.java index 81e0a744144c..482832c1f722 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/WarLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/Archive.java similarity index 98% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/Archive.java index a99c1c2c229b..c1f2bbb2f75b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/Archive.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java index 08734078520c..f8cd52dc16f1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/package-info.java similarity index 93% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/package-info.java index ebaca84bb95d..27ce99b006f0 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/archive/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java similarity index 97% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java index ec1aa5e4a1e8..e96d5ea81a05 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/package-info.java similarity index 93% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/package-info.java index 8f456bd685dc..34bf2ead4378 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/data/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java similarity index 97% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java index 88726e373754..6a98ef682189 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java index 4b6e2678b3ec..cfe121b68996 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Bytes.java similarity index 94% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Bytes.java index 7f53bac6297f..d46a22555dcb 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Bytes.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java index b971b590abd1..61db0b73f422 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java similarity index 98% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java index 71a767853561..eff96a56e2cc 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java similarity index 94% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java index d160cbf84772..22e04b329c30 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/FileHeader.java similarity index 96% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/FileHeader.java index 4b8de5008cf4..7e4134fe5649 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/FileHeader.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Handler.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/Handler.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntry.java similarity index 98% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntry.java index 5b8f3bedb20b..8f54dc3070df 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntry.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java similarity index 97% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java index cbf66412e215..ffd629e09428 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java similarity index 95% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java index 98ed4b905e5c..6804f0ba37f9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFile.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFile.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java similarity index 98% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java index 0a3bf030a5e0..b65358947ad1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java similarity index 98% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java index 9e6af077ed99..12850a4ebe3e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java new file mode 100644 index 000000000000..67624460ccd7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-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.boot.loader.jar; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +/** + * {@link InflaterInputStream} that supports the writing of an extra "dummy" byte (which + * is required with JDK 6) and returns accurate available() results. + * + * @author Phillip Webb + */ +class ZipInflaterInputStream extends InflaterInputStream { + + private int available; + + private boolean extraBytesWritten; + + ZipInflaterInputStream(InputStream inputStream, int size) { + super(inputStream, new Inflater(true), getInflaterBufferSize(size)); + this.available = size; + } + + @Override + public int available() throws IOException { + if (this.available < 0) { + return super.available(); + } + return this.available; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int result = super.read(b, off, len); + if (result != -1) { + this.available -= result; + } + return result; + } + + @Override + public void close() throws IOException { + super.close(); + this.inf.end(); + } + + @Override + protected void fill() throws IOException { + try { + super.fill(); + } + catch (EOFException ex) { + if (this.extraBytesWritten) { + throw ex; + } + this.len = 1; + this.buf[0] = 0x0; + this.extraBytesWritten = true; + this.inf.setInput(this.buf, 0, this.len); + } + } + + private static int getInflaterBufferSize(long size) { + size += 2; // inflater likes some space + size = (size > 65536) ? 8192 : size; + size = (size <= 0) ? 4096 : size; + return (int) size; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java new file mode 100644 index 000000000000..638afe45f497 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Support for loading and manipulating JAR/WAR files. + */ +package org.springframework.boot.loader.jar; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java new file mode 100644 index 000000000000..162e4a6a7396 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-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.boot.loader.jarmode; + +/** + * Interface registered in {@code spring.factories} to provides extended 'jarmode' + * support. + * + * @author Phillip Webb + * @since 2.3.0 + */ +public interface JarMode { + + /** + * Returns if this accepts and can run the given mode. + * @param mode the mode to check + * @return if this instance accepts the mode + */ + boolean accepts(String mode); + + /** + * Run the jar in the given mode. + * @param mode the mode to use + * @param args any program arguments + */ + void run(String mode, String[] args); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java similarity index 92% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java index 42a89a50a35b..600266a241be 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -22,7 +22,7 @@ import org.springframework.util.ClassUtils; /** - * Delegate class used to launch the fat jar in a specific mode. + * Delegate class used to launch the uber jar in a specific mode. * * @author Phillip Webb * @since 2.3.0 diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java similarity index 94% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java index 6a6e83ff23c4..2e17175690a5 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java new file mode 100644 index 000000000000..2f3b5a74e8fd --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2012-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. + */ + +/** + * Support for launching the JAR using jarmode. + * + * @see org.springframework.boot.loader.jarmode.JarModeLauncher + */ +package org.springframework.boot.loader.jarmode; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/YamlPropertySourceLoaderSnakeYaml20Tests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java similarity index 65% rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/YamlPropertySourceLoaderSnakeYaml20Tests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java index 684440176523..5beb8d109640 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/YamlPropertySourceLoaderSnakeYaml20Tests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java @@ -14,16 +14,21 @@ * limitations under the License. */ -package org.springframework.boot.env; - -import org.springframework.boot.testsupport.classpath.ClassPathOverrides; +package org.springframework.boot.loader.launch; /** - * Tests for {@link YamlPropertySourceLoader} with SnakeYAML 2.0. + * Repackaged {@link org.springframework.boot.loader.JarLauncher}. * - * @author Andy Wilkinson + * @author Phillip Webb + * @since 3.2.0 */ -@ClassPathOverrides("org.yaml:snakeyaml:2.0") -class YamlPropertySourceLoaderSnakeYaml20Tests extends YamlPropertySourceLoaderTests { +public final class JarLauncher { + + private JarLauncher() { + } + + public static void main(String[] args) throws Exception { + org.springframework.boot.loader.JarLauncher.main(args); + } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java new file mode 100644 index 000000000000..d80fb0bb7105 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +/** + * Repackaged {@link org.springframework.boot.loader.PropertiesLauncher}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class PropertiesLauncher { + + private PropertiesLauncher() { + } + + public static void main(String[] args) throws Exception { + org.springframework.boot.loader.PropertiesLauncher.main(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java new file mode 100644 index 000000000000..9392d3bf2b45 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +/** + * Repackaged {@link org.springframework.boot.loader.WarLauncher}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class WarLauncher { + + private WarLauncher() { + } + + public static void main(String[] args) throws Exception { + org.springframework.boot.loader.WarLauncher.main(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java new file mode 100644 index 000000000000..7968d509a2bb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-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. + */ + +/** + * Repackaged launcher classes. + * + * @see org.springframework.boot.loader.launch.JarLauncher + * @see org.springframework.boot.loader.launch.WarLauncher + */ +package org.springframework.boot.loader.launch; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java similarity index 95% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java index c2114c2d83bb..4b32f644f542 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java index b6f0e3a3a7fb..df00705e9eec 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java similarity index 92% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java index af0aa2d1a7dc..d3d7eef2d9db 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java similarity index 98% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java index 48d7340ee384..60e3cb2765eb 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java index fa713034304a..afa32a7c4f18 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/TestJarCreator.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/TestJarCreator.java index 100e2c757e37..c5c5fd3b95c9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/TestJarCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/WarLauncherTests.java similarity index 98% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/WarLauncherTests.java index aef78cfa53d9..fbab8d36ed0a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/WarLauncherTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java similarity index 99% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java index 1188dd0ba81c..27c30a0a7aa7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java @@ -220,7 +220,7 @@ void getName() { @Test void size() throws Exception { try (ZipFile zip = new ZipFile(this.rootJarFile)) { - assertThat(this.jarFile.size()).isEqualTo(zip.size()); + assertThat(this.jarFile).hasSize(zip.size()); } } @@ -582,7 +582,7 @@ void zip64JarThatExceedsZipEntryLimitCanBeRead() throws Exception { @Test void zip64JarThatExceedsZipSizeLimitCanBeRead() throws Exception { - Assumptions.assumeTrue(this.tempDir.getFreeSpace() > 6 * 1024 * 1024 * 1024, "Insufficient disk space"); + Assumptions.assumeTrue(this.tempDir.getFreeSpace() > 6L * 1024 * 1024 * 1024, "Insufficient disk space"); File zip64Jar = new File(this.tempDir, "zip64.jar"); File entry = new File(this.tempDir, "entry.dat"); CRC32 crc32 = new CRC32(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java similarity index 96% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java index 0697b77b7bba..802a762e79dd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/application.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/application.properties new file mode 100644 index 000000000000..85a390f4d4e0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/application.properties @@ -0,0 +1 @@ +loader.main: demo.Application diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/bar.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/bar.properties new file mode 100644 index 000000000000..6b37480f8b99 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/bar.properties @@ -0,0 +1 @@ +loader.main: my.BootInfBarApplication diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/foo.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/foo.properties new file mode 100644 index 000000000000..36bd211df41b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/foo.properties @@ -0,0 +1,3 @@ +foo: Application +loader.main: my.${foo} +loader.path: etc diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/loader.properties new file mode 100644 index 000000000000..85a390f4d4e0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/BOOT-INF/classes/loader.properties @@ -0,0 +1 @@ +loader.main: demo.Application diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/META-INF/spring.factories new file mode 100644 index 000000000000..c45c87d76f45 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Jar Modes +org.springframework.boot.loader.jarmode.JarMode=\ +org.springframework.boot.loader.jarmode.TestJarMode \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/bar.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/bar.properties new file mode 100644 index 000000000000..8301c2649f3c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/bar.properties @@ -0,0 +1 @@ +loader.main: my.BarApplication diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/explodedsample/ExampleClass.txt similarity index 83% rename from spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/package-info.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/explodedsample/ExampleClass.txt index 6dfcc180e669..c53100f90fa1 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/actuate/metrics/package-info.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/explodedsample/ExampleClass.txt @@ -14,7 +14,13 @@ * limitations under the License. */ +package explodedsample; + /** - * Auto-configuration for handling metrics in tests. + * Example class used to test class loading. + * + * @author Phillip Webb */ -package org.springframework.boot.test.autoconfigure.actuate.metrics; +public class ExampleClass { + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/home/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/home/loader.properties new file mode 100644 index 000000000000..7a134969b766 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/home/loader.properties @@ -0,0 +1 @@ +loader.main: demo.HomeApplication diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/app.jar new file mode 100644 index 000000000000..fb02c027012d Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/app.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/more-jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/more-jars/app.jar new file mode 100644 index 000000000000..3945fd020d34 Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/more-jars/app.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/app.jar new file mode 100644 index 000000000000..5600ed279efb Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/app.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/nested-jar-app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/nested-jar-app.jar new file mode 100644 index 000000000000..4c2254f6352b Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/nested-jars/nested-jar-app.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/META-INF/MANIFEST.MF b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/META-INF/MANIFEST.MF new file mode 100644 index 000000000000..d95a13c5284e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/META-INF/MANIFEST.MF @@ -0,0 +1,2 @@ +Manifest-Version: 1.0 +Start-Class: ${foo.main} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/loader.properties new file mode 100644 index 000000000000..32f7d00f2d01 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/placeholders/loader.properties @@ -0,0 +1 @@ +foo.main: demo.FooApplication diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/MANIFEST.MF b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/MANIFEST.MF similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/MANIFEST.MF rename to spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/MANIFEST.MF diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/spring/application.xml b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/spring/application.xml new file mode 100644 index 000000000000..cf04aa4fbe43 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/root/META-INF/spring/application.xml @@ -0,0 +1,6 @@ + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle index c6b187317071..f7968f659d51 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle @@ -13,10 +13,25 @@ configurations { extendsFrom dependencyManagement transitive = false } + loaderClassic { + extendsFrom dependencyManagement + transitive = false + } jarmode { extendsFrom dependencyManagement transitive = false } + all { + resolutionStrategy { + eachDependency { dependency -> + // Downgrade Spring Framework as Gradle cannot cope with 6.1.0-M1's + // multi-version jar files with bytecode in META-INF/versions/21 + if (dependency.requested.group.equals("org.springframework")) { + dependency.useVersion("6.0.10") + } + } + } + } } dependencies { @@ -26,6 +41,7 @@ dependencies { compileOnly("ch.qos.logback:logback-classic") loader(project(":spring-boot-project:spring-boot-tools:spring-boot-loader")) + loaderClassic(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-classic")) jarmode(project(":spring-boot-project:spring-boot-tools:spring-boot-jarmode-layertools")) @@ -50,6 +66,21 @@ task reproducibleLoaderJar(type: Jar) { destinationDirectory = file("${generatedResources}/META-INF/loader") } +task reproducibleLoaderClassicJar(type: Jar) { + dependsOn configurations.loaderClassic + from { + zipTree(configurations.loaderClassic.incoming.files.singleFile).matching { + exclude "META-INF/LICENSE.txt" + exclude "META-INF/NOTICE.txt" + exclude "META-INF/spring-boot.properties" + } + } + reproducibleFileOrder = true + preserveFileTimestamps = false + archiveFileName = "spring-boot-loader-classic.jar" + destinationDirectory = file("${generatedResources}/META-INF/loader") +} + task layerToolsJar(type: Sync) { dependsOn configurations.jarmode from { @@ -61,12 +92,12 @@ task layerToolsJar(type: Sync) { sourceSets { main { - output.dir(generatedResources, builtBy: [layerToolsJar, reproducibleLoaderJar]) + output.dir(generatedResources, builtBy: [layerToolsJar, reproducibleLoaderJar, reproducibleLoaderClassicJar]) } } compileJava { if ((!project.hasProperty("toolchainVersion")) && JavaVersion.current() == JavaVersion.VERSION_1_8) { - options.compilerArgs += ['-Xlint:-sunapi', '-XDenableSunApiLintControl'] - } + options.compilerArgs += ['-Xlint:-sunapi', '-XDenableSunApiLintControl'] + } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java index ffbdf5ec73b9..429f63c2b36f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java @@ -51,8 +51,6 @@ */ public abstract class AbstractJarWriter implements LoaderClassesWriter { - private static final String NESTED_LOADER_JAR = "META-INF/loader/spring-boot-loader.jar"; - private static final int BUFFER_SIZE = 32 * 1024; private static final int UNIX_FILE_MODE = UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM; @@ -199,13 +197,15 @@ private long getNestedLibraryTime(Library library) { return library.getLastModified(); } - /** - * Write the required spring-boot-loader classes to the JAR. - * @throws IOException if the classes cannot be written - */ @Override public void writeLoaderClasses() throws IOException { - writeLoaderClasses(NESTED_LOADER_JAR); + writeLoaderClasses(LoaderImplementation.DEFAULT); + } + + @Override + public void writeLoaderClasses(LoaderImplementation loaderImplementation) throws IOException { + writeLoaderClasses((loaderImplementation != null) ? loaderImplementation.getJarResourceName() + : LoaderImplementation.DEFAULT.getJarResourceName()); } /** @@ -220,7 +220,7 @@ public void writeLoaderClasses(String loaderJarResourceName) throws IOException try (JarInputStream inputStream = new JarInputStream(new BufferedInputStream(loaderJar.openStream()))) { JarEntry entry; while ((entry = inputStream.getNextJarEntry()) != null) { - if (isDirectoryEntry(entry) || isClassEntry(entry)) { + if (isDirectoryEntry(entry) || isClassEntry(entry) || isServicesEntry(entry)) { writeEntry(new JarArchiveEntry(entry), new InputStreamEntryWriter(inputStream)); } } @@ -235,6 +235,10 @@ private boolean isClassEntry(JarEntry entry) { return entry.getName().endsWith(".class"); } + private boolean isServicesEntry(JarEntry entry) { + return !entry.isDirectory() && entry.getName().startsWith("META-INF/services/"); + } + private void writeEntry(JarArchiveEntry entry, EntryWriter entryWriter) throws IOException { writeEntry(entry, null, entryWriter, UnpackHandler.NEVER); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/FileUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/FileUtils.java index ecfded739077..0347e1cbe6f4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/FileUtils.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/FileUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -18,6 +18,9 @@ import java.io.File; import java.io.IOException; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.Manifest; /** * Utilities for manipulating files and directories in Spring Boot tooling. @@ -61,4 +64,31 @@ public static String sha1Hash(File file) throws IOException { return Digest.sha1(InputStreamSupplier.forFile(file)); } + /** + * Returns {@code true} if the given jar file has been signed. + * @param file the file to check + * @return if the file has been signed + * @throws IOException on IO error + */ + public static boolean isSignedJarFile(File file) throws IOException { + try (JarFile jarFile = new JarFile(file)) { + if (hasDigestEntry(jarFile.getManifest())) { + return true; + } + } + return false; + } + + private static boolean hasDigestEntry(Manifest manifest) { + return (manifest != null) && manifest.getEntries().values().stream().anyMatch(FileUtils::hasDigestName); + } + + private static boolean hasDigestName(Attributes attributes) { + return attributes.keySet().stream().anyMatch(FileUtils::isDigestName); + } + + private static boolean isDigestName(Object name) { + return String.valueOf(name).toUpperCase().endsWith("-DIGEST"); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Layouts.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Layouts.java index 61586d3d1c51..e6f99282a717 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Layouts.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Layouts.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -23,7 +23,7 @@ import java.util.Map; /** - * Common {@link Layout}s. + * Common {@link Layout layouts}. * * @author Phillip Webb * @author Dave Syer @@ -66,7 +66,7 @@ public static class Jar implements RepackagingLayout { @Override public String getLauncherClassName() { - return "org.springframework.boot.loader.JarLauncher"; + return "org.springframework.boot.loader.launch.JarLauncher"; } @Override @@ -108,7 +108,7 @@ public static class Expanded extends Jar { @Override public String getLauncherClassName() { - return "org.springframework.boot.loader.PropertiesLauncher"; + return "org.springframework.boot.loader.launch.PropertiesLauncher"; } } @@ -148,7 +148,7 @@ public static class War implements Layout { @Override public String getLauncherClassName() { - return "org.springframework.boot.loader.WarLauncher"; + return "org.springframework.boot.loader.launch.WarLauncher"; } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Library.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Library.java index f2fe532223c5..213809553815 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Library.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Library.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -64,7 +64,7 @@ public Library(File file, LibraryScope scope) { * @param unpackRequired if the library needs to be unpacked before it can be used * @param local if the library is local (part of the same build) to the application * that is being packaged - * @param included if the library is included in the fat jar + * @param included if the library is included in the uber jar * @since 2.4.8 */ public Library(String name, File file, LibraryScope scope, LibraryCoordinates coordinates, boolean unpackRequired, @@ -142,7 +142,7 @@ public boolean isLocal() { } /** - * Return if the library is included in the fat jar. + * Return if the library is included in the uber jar. * @return if the library is included */ public boolean isIncluded() { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderClassesWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderClassesWriter.java index 187ff0b90292..864992279d35 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderClassesWriter.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderClassesWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -34,6 +34,14 @@ public interface LoaderClassesWriter { */ void writeLoaderClasses() throws IOException; + /** + * Write the default required spring-boot-loader classes to the JAR. + * @param loaderImplementation the specific implementation to write + * @throws IOException if the classes cannot be written + * @since 3.2.0 + */ + void writeLoaderClasses(LoaderImplementation loaderImplementation) throws IOException; + /** * Write custom required spring-boot-loader classes to the JAR. * @param loaderJarResourceName the name of the resource containing the loader classes diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderImplementation.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderImplementation.java new file mode 100644 index 000000000000..6414a3cfbbf8 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/LoaderImplementation.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-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.boot.loader.tools; + +/** + * Supported loader implementations. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public enum LoaderImplementation { + + /** + * The default recommended loader implementation. + */ + DEFAULT("META-INF/loader/spring-boot-loader.jar"), + + /** + * The classic loader implementation as used with Spring Boot 3.1 and earlier. + */ + CLASSIC("META-INF/loader/spring-boot-loader-classic.jar"); + + private final String jarResourceName; + + LoaderImplementation(String jarResourceName) { + this.jarResourceName = jarResourceName; + } + + /** + * Return the name of the nested resource that can be loaded from the tools jar. + * @return the jar resource name + */ + public String getJarResourceName() { + return this.jarResourceName; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java index 335a45a6c586..8a66fa186aaa 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java @@ -88,6 +88,8 @@ public abstract class Packager { private Layout layout; + private LoaderImplementation loaderImplementation; + private LayoutFactory layoutFactory; private Layers layers; @@ -135,6 +137,14 @@ public void setLayout(Layout layout) { this.layout = layout; } + /** + * Sets the loader implementation to use. + * @param loaderImplementation the loaderImplementation to set + */ + public void setLoaderImplementation(LoaderImplementation loaderImplementation) { + this.loaderImplementation = loaderImplementation; + } + /** * Sets the layout factory for the jar. The factory can be used when no specific * layout is specified. @@ -207,6 +217,7 @@ private void write(JarFile sourceJar, AbstractJarWriter writer, PackagedLibrarie if (isLayered()) { writeLayerIndex(writer); } + writeSignatureFileIfNecessary(writtenLibraries, writer); } private void writeLoaderClasses(AbstractJarWriter writer) throws IOException { @@ -215,7 +226,7 @@ private void writeLoaderClasses(AbstractJarWriter writer) throws IOException { customLoaderLayout.writeLoadedClasses(writer); } else if (layout.isExecutable()) { - writer.writeLoaderClasses(); + writer.writeLoaderClasses(this.loaderImplementation); } } @@ -253,6 +264,10 @@ private void writeLayerIndex(AbstractJarWriter writer) throws IOException { } } + protected void writeSignatureFileIfNecessary(Map writtenLibraries, AbstractJarWriter writer) + throws IOException { + } + private EntryTransformer getEntityTransformer() { if (getLayout() instanceof RepackagingLayout repackagingLayout) { return new RepackagingEntryTransformer(repackagingLayout); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java index 07da873c83a4..764c84f9fde8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -19,6 +19,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.attribute.FileTime; +import java.util.Map; import java.util.jar.JarFile; import org.springframework.util.Assert; @@ -46,6 +47,24 @@ public Repackager(File source) { super(source); } + @Override + protected void writeSignatureFileIfNecessary(Map writtenLibraries, AbstractJarWriter writer) + throws IOException { + if (getSource().getName().toLowerCase().endsWith(".jar") && hasSignedLibrary(writtenLibraries)) { + writer.writeEntry("META-INF/BOOT.SF", (entryWriter) -> { + }); + } + } + + private boolean hasSignedLibrary(Map writtenLibraries) throws IOException { + for (Library library : writtenLibraries.values()) { + if (!(library instanceof JarModeLibrary) && FileUtils.isSignedJarFile(library.getFile())) { + return true; + } + } + return false; + } + /** * Sets if source files should be backed up when they would be overwritten. * @param backupSource if source files should be backed up diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/StandardLayers.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/StandardLayers.java index 544c213f3ad9..58254591eec8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/StandardLayers.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/StandardLayers.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -27,7 +27,7 @@ *
    *
  1. "dependencies" - For non snapshot dependencies
  2. *
  3. "spring-boot-loader" - For classes from {@code spring-boot-loader} used to launch a - * fat jar
  4. + * uber jar *
  5. "snapshot-dependencies" - For snapshot dependencies
  6. *
  7. "application" - For application classes and resources
  8. *
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java index 3429e4af90ea..7dce78edafc1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -105,7 +105,7 @@ void specificMainClass() throws Exception { execute(packager, NO_LIBRARIES); Manifest actualManifest = getPackagedManifest(); assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) - .isEqualTo("org.springframework.boot.loader.JarLauncher"); + .isEqualTo("org.springframework.boot.loader.launch.JarLauncher"); assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); assertThat(hasPackagedLauncherClasses()).isTrue(); } @@ -121,7 +121,7 @@ void mainClassFromManifest() throws Exception { execute(packager, NO_LIBRARIES); Manifest actualManifest = getPackagedManifest(); assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) - .isEqualTo("org.springframework.boot.loader.JarLauncher"); + .isEqualTo("org.springframework.boot.loader.launch.JarLauncher"); assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); assertThat(hasPackagedLauncherClasses()).isTrue(); } @@ -133,7 +133,7 @@ void mainClassFound() throws Exception { execute(packager, NO_LIBRARIES); Manifest actualManifest = getPackagedManifest(); assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) - .isEqualTo("org.springframework.boot.loader.JarLauncher"); + .isEqualTo("org.springframework.boot.loader.launch.JarLauncher"); assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); assertThat(hasPackagedLauncherClasses()).isTrue(); } @@ -651,7 +651,7 @@ void nativeImageArgFileWithExcludesIsWritten() throws Exception { expected.add("\\Q" + libraryTwo.getName() + "\\E"); expected.add("^/META-INF/native-image/.*"); assertThat(getPackagedEntryContent("META-INF/native-image/argfile")) - .isEqualTo(expected.stream().collect(Collectors.joining("\n")) + "\n"); + .isEqualTo(String.join("\n", expected) + "\n"); } private File createLibraryJar() throws IOException { @@ -660,7 +660,7 @@ private File createLibraryJar() throws IOException { return library.getFile(); } - private Library newLibrary(File file, LibraryScope scope, boolean unpackRequired) { + protected Library newLibrary(File file, LibraryScope scope, boolean unpackRequired) { return new Library(null, file, scope, null, unpackRequired, false, true); } @@ -684,10 +684,10 @@ protected Collection getPackagedEntryNames() throws IOException { protected boolean hasPackagedLauncherClasses() throws IOException { return hasPackagedEntry("org/springframework/boot/") - && hasPackagedEntry("org/springframework/boot/loader/JarLauncher.class"); + && hasPackagedEntry("org/springframework/boot/loader/launch/JarLauncher.class"); } - private boolean hasPackagedEntry(String name) throws IOException { + protected boolean hasPackagedEntry(String name) throws IOException { return getPackagedEntry(name) != null; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/FileUtilsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/FileUtilsTests.java index edf8b38b889a..e6e084bc4d6f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/FileUtilsTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/FileUtilsTests.java @@ -17,9 +17,13 @@ package org.springframework.boot.loader.tools; import java.io.File; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -99,4 +103,28 @@ void hash() throws Exception { assertThat(FileUtils.sha1Hash(file)).isEqualTo("7037807198c22a7d2b0807371d763779a84fdfcf"); } + @Test + void isSignedJarFileWhenSignedReturnsTrue() throws IOException { + Manifest manifest = new Manifest(getClass().getResourceAsStream("signed-manifest.mf")); + File jarFile = new File(this.tempDir, "test.jar"); + writeTestJar(manifest, jarFile); + assertThat(FileUtils.isSignedJarFile(jarFile)).isTrue(); + } + + @Test + void isSignedJarFileWhenNotSignedReturnsFalse() throws IOException { + Manifest manifest = new Manifest(); + File jarFile = new File(this.tempDir, "test.jar"); + writeTestJar(manifest, jarFile); + assertThat(FileUtils.isSignedJarFile(jarFile)).isFalse(); + } + + private void writeTestJar(Manifest manifest, File jarFile) throws IOException, FileNotFoundException { + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(jarFile))) { + out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + manifest.write(out); + out.closeEntry(); + } + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java index a4a648c34fb9..239c0cc381e4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java @@ -28,6 +28,7 @@ import java.util.Collection; import java.util.Enumeration; import java.util.List; +import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.Manifest; @@ -79,7 +80,7 @@ void jarIsOnlyRepackagedOnce() throws Exception { repackager.repackage(NO_LIBRARIES); Manifest actualManifest = getPackagedManifest(); assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) - .isEqualTo("org.springframework.boot.loader.JarLauncher"); + .isEqualTo("org.springframework.boot.loader.launch.JarLauncher"); assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); assertThat(hasPackagedLauncherClasses()).isTrue(); } @@ -218,9 +219,23 @@ void repackagingDeeplyNestedPackageIsNotProhibitivelySlow() throws IOException { assertThat(stopWatch.getTotalTimeMillis()).isLessThan(5000); } + @Test + void signedJar() throws Exception { + Repackager packager = createPackager(); + packager.setMainClass("a.b.C"); + Manifest manifest = new Manifest(); + Attributes attributes = new Attributes(); + attributes.putValue("SHA1-Digest", "0000"); + manifest.getEntries().put("a/b/C.class", attributes); + TestJarFile libJar = new TestJarFile(this.tempDir); + libJar.addManifest(manifest); + execute(packager, (callback) -> callback.library(newLibrary(libJar.getFile(), LibraryScope.COMPILE, false))); + assertThat(hasPackagedEntry("META-INF/BOOT.SF")).isTrue(); + } + private boolean hasLauncherClasses(File file) throws IOException { return hasEntry(file, "org/springframework/boot/") - && hasEntry(file, "org/springframework/boot/loader/JarLauncher.class"); + && hasEntry(file, "org/springframework/boot/loader/launch/JarLauncher.class"); } private boolean hasEntry(File file, String name) throws IOException { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/resources/org/springframework/boot/loader/tools/signed-manifest.mf b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/resources/org/springframework/boot/loader/tools/signed-manifest.mf new file mode 100644 index 000000000000..8316a0550d50 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/resources/org/springframework/boot/loader/tools/signed-manifest.mf @@ -0,0 +1,9 @@ +Manifest-Version: 1.0 +Created-By: 1.5.0_08 (Sun Microsystems Inc.) +Specification-Version: 1.1 + +Name: org/bouncycastle/pqc/legacy/math/linearalgebra/GoppaCode.class +SHA-256-Digest: wNhEfeTvNG9ggqKfLjQDDoFoDqeWwGUc47JiL7VqxqU= + +Name: org/bouncycastle/crypto/modes/gcm/Tables8kGCMMultiplier.class +SHA-256-Digest: nqljr9DNx4nNie4sbkZajVenvd3LdMF3X5s5dmSMToM= diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ManifestInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ManifestInfo.java new file mode 100644 index 000000000000..1a6b592f3287 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ManifestInfo.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-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.boot.loader.jar; + +import java.util.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Info obtained from a {@link ZipContent} instance relating to the {@link Manifest}. + * + * @author Phillip Webb + */ +class ManifestInfo { + + private static final Name MULTI_RELEASE = new Name("Multi-Release"); + + static final ManifestInfo NONE = new ManifestInfo(null, false); + + private final Manifest manifest; + + private volatile Boolean multiRelease; + + /** + * Create a new {@link ManifestInfo} instance. + * @param manifest the jar manifest + */ + ManifestInfo(Manifest manifest) { + this(manifest, null); + } + + private ManifestInfo(Manifest manifest, Boolean multiRelease) { + this.manifest = manifest; + this.multiRelease = multiRelease; + } + + /** + * Return the manifest, if any. + * @return the manifest or {@code null} + */ + Manifest getManifest() { + return this.manifest; + } + + /** + * Return if this is a multi-release jar. + * @return if the jar is multi-release + */ + boolean isMultiRelease() { + if (this.manifest == null) { + return false; + } + Boolean multiRelease = this.multiRelease; + if (multiRelease != null) { + return multiRelease; + } + Attributes attributes = this.manifest.getMainAttributes(); + multiRelease = attributes.containsKey(MULTI_RELEASE); + this.multiRelease = multiRelease; + return multiRelease; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.java new file mode 100644 index 000000000000..caf76a2b96f6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-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.boot.loader.jar; + +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.IntFunction; + +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Info obtained from a {@link ZipContent} instance relating to the directories listed + * under {@code META-INF/versions/}. + * + * @author Phillip Webb + */ +final class MetaInfVersionsInfo { + + static final MetaInfVersionsInfo NONE = new MetaInfVersionsInfo(Collections.emptySet()); + + private static final String META_INF_VERSIONS = NestedJarFile.META_INF_VERSIONS; + + private final int[] versions; + + private final String[] directories; + + private MetaInfVersionsInfo(Set versions) { + this.versions = versions.stream().mapToInt(Integer::intValue).toArray(); + this.directories = versions.stream().map((version) -> META_INF_VERSIONS + version + "/").toArray(String[]::new); + } + + /** + * Return the versions listed under {@code META-INF/versions/} in ascending order. + * @return the versions + */ + int[] versions() { + return this.versions; + } + + /** + * Return the version directories in the same order as {@link #versions()}. + * @return the version directories + */ + String[] directories() { + return this.directories; + } + + /** + * Get {@link MetaInfVersionsInfo} for the given {@link ZipContent}. + * @param zipContent the zip content + * @return the {@link MetaInfVersionsInfo}. + */ + static MetaInfVersionsInfo get(ZipContent zipContent) { + return get(zipContent.size(), zipContent::getEntry); + } + + /** + * Get {@link MetaInfVersionsInfo} for the given details. + * @param size the number of entries + * @param entries a function to get an entry from an index + * @return the {@link MetaInfVersionsInfo}. + */ + static MetaInfVersionsInfo get(int size, IntFunction entries) { + Set versions = new TreeSet<>(); + for (int i = 0; i < size; i++) { + ZipContent.Entry contentEntry = entries.apply(i); + if (contentEntry.hasNameStartingWith(META_INF_VERSIONS) && !contentEntry.isDirectory()) { + String name = contentEntry.getName(); + int slash = name.indexOf('/', META_INF_VERSIONS.length()); + String version = name.substring(META_INF_VERSIONS.length(), slash); + try { + int versionNumber = Integer.parseInt(version); + if (versionNumber >= NestedJarFile.BASE_VERSION) { + versions.add(versionNumber); + } + } + catch (NumberFormatException ex) { + // Ignore + } + } + } + return (!versions.isEmpty()) ? new MetaInfVersionsInfo(versions) : NONE; + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java new file mode 100644 index 000000000000..1c2fea69a403 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java @@ -0,0 +1,835 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.jar; + +import java.io.File; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.ref.Cleaner.Cleanable; +import java.nio.ByteBuffer; +import java.nio.file.attribute.FileTime; +import java.security.CodeSigner; +import java.security.cert.Certificate; +import java.time.LocalDateTime; +import java.util.Enumeration; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Spliterator; +import java.util.Spliterators.AbstractSpliterator; +import java.util.function.Consumer; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import java.util.zip.Inflater; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; + +import org.springframework.boot.loader.log.DebugLogger; +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.zip.CloseableDataBlock; +import org.springframework.boot.loader.zip.ZipContent; +import org.springframework.boot.loader.zip.ZipContent.Entry; + +/** + * Extended variant of {@link JarFile} that behaves in the same way but can open nested + * jars. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 3.2.0 + */ +public class NestedJarFile extends JarFile { + + private static final int DECIMAL = 10; + + private static final String META_INF = "META-INF/"; + + static final String META_INF_VERSIONS = META_INF + "versions/"; + + static final int BASE_VERSION = baseVersion().feature(); + + private static final DebugLogger debug = DebugLogger.get(NestedJarFile.class); + + private final Cleaner cleaner; + + private final NestedJarFileResources resources; + + private final Cleanable cleanup; + + private final String name; + + private final int version; + + private volatile NestedJarEntry lastEntry; + + private volatile boolean closed; + + private volatile ManifestInfo manifestInfo; + + private volatile MetaInfVersionsInfo metaInfVersionsInfo; + + /** + * Creates a new {@link NestedJarFile} instance to read from the specific + * {@code File}. + * @param file the jar file to be opened for reading + * @throws IOException on I/O error + */ + NestedJarFile(File file) throws IOException { + this(file, null, null, false, Cleaner.instance); + } + + /** + * Creates a new {@link NestedJarFile} instance to read from the specific + * {@code File}. + * @param file the jar file to be opened for reading + * @param nestedEntryName the nested entry name to open or {@code null} + * @throws IOException on I/O error + */ + public NestedJarFile(File file, String nestedEntryName) throws IOException { + this(file, nestedEntryName, null, true, Cleaner.instance); + } + + /** + * Creates a new {@link NestedJarFile} instance to read from the specific + * {@code File}. + * @param file the jar file to be opened for reading + * @param nestedEntryName the nested entry name to open or {@code null} + * @param version the release version to use when opening a multi-release jar + * @throws IOException on I/O error + */ + public NestedJarFile(File file, String nestedEntryName, Runtime.Version version) throws IOException { + this(file, nestedEntryName, version, true, Cleaner.instance); + } + + /** + * Creates a new {@link NestedJarFile} instance to read from the specific + * {@code File}. + * @param file the jar file to be opened for reading + * @param nestedEntryName the nested entry name to open or {@code null} + * @param version the release version to use when opening a multi-release jar + * @param onlyNestedJars if only nested jars should be opened + * @param cleaner the cleaner used to release resources + * @throws IOException on I/O error + */ + NestedJarFile(File file, String nestedEntryName, Runtime.Version version, boolean onlyNestedJars, Cleaner cleaner) + throws IOException { + super(file); + if (onlyNestedJars && (nestedEntryName == null || nestedEntryName.isEmpty())) { + throw new IllegalArgumentException("nestedEntryName must not be empty"); + } + debug.log("Created nested jar file (%s, %s, %s)", file, nestedEntryName, version); + this.cleaner = cleaner; + this.resources = new NestedJarFileResources(file, nestedEntryName); + this.cleanup = cleaner.register(this, this.resources); + this.name = file.getPath() + ((nestedEntryName != null) ? "!/" + nestedEntryName : ""); + this.version = (version != null) ? version.feature() : baseVersion().feature(); + } + + public InputStream getRawZipDataInputStream() throws IOException { + RawZipDataInputStream inputStream = new RawZipDataInputStream( + this.resources.zipContent().openRawZipData().asInputStream()); + this.resources.addInputStream(inputStream); + return inputStream; + } + + @Override + public Manifest getManifest() throws IOException { + try { + return this.resources.zipContentForManifest() + .getInfo(ManifestInfo.class, this::getManifestInfo) + .getManifest(); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + @Override + public Enumeration entries() { + synchronized (this) { + ensureOpen(); + return new JarEntriesEnumeration(this.resources.zipContent()); + } + } + + @Override + public Stream stream() { + synchronized (this) { + ensureOpen(); + return streamContentEntries().map(NestedJarEntry::new); + } + } + + @Override + public Stream versionedStream() { + synchronized (this) { + ensureOpen(); + return streamContentEntries().map(this::getBaseName) + .filter(Objects::nonNull) + .distinct() + .map(this::getJarEntry) + .filter(Objects::nonNull); + } + } + + private Stream streamContentEntries() { + ZipContentEntriesSpliterator spliterator = new ZipContentEntriesSpliterator(this.resources.zipContent()); + return StreamSupport.stream(spliterator, false); + } + + private String getBaseName(ZipContent.Entry contentEntry) { + String name = contentEntry.getName(); + if (!name.startsWith(META_INF_VERSIONS)) { + return name; + } + int versionNumberStartIndex = META_INF_VERSIONS.length(); + int versionNumberEndIndex = (versionNumberStartIndex != -1) ? name.indexOf('/', versionNumberStartIndex) : -1; + if (versionNumberEndIndex == -1 || versionNumberEndIndex == (name.length() - 1)) { + return null; + } + try { + int versionNumber = Integer.parseInt(name, versionNumberStartIndex, versionNumberEndIndex, DECIMAL); + if (versionNumber > this.version) { + return null; + } + } + catch (NumberFormatException ex) { + return null; + } + return name.substring(versionNumberEndIndex + 1); + } + + @Override + public JarEntry getJarEntry(String name) { + return getNestedJarEntry(name); + } + + @Override + public JarEntry getEntry(String name) { + return getNestedJarEntry(name); + } + + /** + * Return if an entry with the given name exists. + * @param name the name to check + * @return if the entry exists + */ + public boolean hasEntry(String name) { + NestedJarEntry lastEntry = this.lastEntry; + if (lastEntry != null && name.equals(lastEntry.getName())) { + return true; + } + ZipContent.Entry entry = getVersionedContentEntry(name); + if (entry != null) { + return true; + } + synchronized (this) { + ensureOpen(); + return this.resources.zipContent().hasEntry(null, name); + } + } + + private NestedJarEntry getNestedJarEntry(String name) { + Objects.requireNonNull(name, "name"); + NestedJarEntry lastEntry = this.lastEntry; + if (lastEntry != null && name.equals(lastEntry.getName())) { + return lastEntry; + } + ZipContent.Entry entry = getVersionedContentEntry(name); + entry = (entry != null) ? entry : getContentEntry(null, name); + if (entry == null) { + return null; + } + NestedJarEntry nestedJarEntry = new NestedJarEntry(entry, name); + this.lastEntry = nestedJarEntry; + return nestedJarEntry; + } + + private ZipContent.Entry getVersionedContentEntry(String name) { + // NOTE: we can't call isMultiRelease() directly because it's a final method and + // it inspects the container jar. We use ManifestInfo instead. + if (BASE_VERSION >= this.version || name.startsWith(META_INF) || !getManifestInfo().isMultiRelease()) { + return null; + } + MetaInfVersionsInfo metaInfVersionsInfo = getMetaInfVersionsInfo(); + int[] versions = metaInfVersionsInfo.versions(); + String[] directories = metaInfVersionsInfo.directories(); + for (int i = versions.length - 1; i >= 0; i--) { + if (versions[i] <= this.version) { + ZipContent.Entry entry = getContentEntry(directories[i], name); + if (entry != null) { + return entry; + } + } + } + return null; + } + + private ZipContent.Entry getContentEntry(String namePrefix, String name) { + synchronized (this) { + ensureOpen(); + return this.resources.zipContent().getEntry(namePrefix, name); + } + } + + private ManifestInfo getManifestInfo() { + ManifestInfo manifestInfo = this.manifestInfo; + if (manifestInfo != null) { + return manifestInfo; + } + synchronized (this) { + ensureOpen(); + manifestInfo = this.resources.zipContent().getInfo(ManifestInfo.class, this::getManifestInfo); + } + this.manifestInfo = manifestInfo; + return manifestInfo; + } + + private ManifestInfo getManifestInfo(ZipContent zipContent) { + ZipContent.Entry contentEntry = zipContent.getEntry(MANIFEST_NAME); + if (contentEntry == null) { + return ManifestInfo.NONE; + } + try { + try (InputStream inputStream = getInputStream(contentEntry)) { + Manifest manifest = new Manifest(inputStream); + return new ManifestInfo(manifest); + } + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private MetaInfVersionsInfo getMetaInfVersionsInfo() { + MetaInfVersionsInfo metaInfVersionsInfo = this.metaInfVersionsInfo; + if (metaInfVersionsInfo != null) { + return metaInfVersionsInfo; + } + synchronized (this) { + ensureOpen(); + metaInfVersionsInfo = this.resources.zipContent() + .getInfo(MetaInfVersionsInfo.class, MetaInfVersionsInfo::get); + } + this.metaInfVersionsInfo = metaInfVersionsInfo; + return metaInfVersionsInfo; + } + + @Override + public InputStream getInputStream(ZipEntry entry) throws IOException { + Objects.requireNonNull(entry, "entry"); + if (entry instanceof NestedJarEntry nestedJarEntry && nestedJarEntry.isOwnedBy(this)) { + return getInputStream(nestedJarEntry.contentEntry()); + } + return getInputStream(getNestedJarEntry(entry.getName()).contentEntry()); + } + + private InputStream getInputStream(ZipContent.Entry contentEntry) throws IOException { + int compression = contentEntry.getCompressionMethod(); + if (compression != ZipEntry.STORED && compression != ZipEntry.DEFLATED) { + throw new ZipException("invalid compression method"); + } + synchronized (this) { + ensureOpen(); + InputStream inputStream = new JarEntryInputStream(contentEntry); + try { + if (compression == ZipEntry.DEFLATED) { + inputStream = new JarEntryInflaterInputStream((JarEntryInputStream) inputStream, this.resources); + } + this.resources.addInputStream(inputStream); + return inputStream; + } + catch (RuntimeException ex) { + inputStream.close(); + throw ex; + } + } + } + + @Override + public String getComment() { + synchronized (this) { + ensureOpen(); + return this.resources.zipContent().getComment(); + } + } + + @Override + public int size() { + synchronized (this) { + ensureOpen(); + return this.resources.zipContent().size(); + } + } + + @Override + public void close() throws IOException { + super.close(); + if (this.closed) { + return; + } + this.closed = true; + synchronized (this) { + try { + this.cleanup.clean(); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + } + + @Override + public String getName() { + return this.name; + } + + private void ensureOpen() { + if (this.closed) { + throw new IllegalStateException("Zip file closed"); + } + if (this.resources.zipContent() == null) { + throw new IllegalStateException("The object is not initialized."); + } + } + + /** + * Clear any internal caches. + */ + public void clearCache() { + synchronized (this) { + this.lastEntry = null; + } + } + + /** + * An individual entry from a {@link NestedJarFile}. + */ + private class NestedJarEntry extends java.util.jar.JarEntry { + + private static final IllegalStateException CANNOT_BE_MODIFIED_EXCEPTION = new IllegalStateException( + "Neste jar entries cannot be modified"); + + private final ZipContent.Entry contentEntry; + + private final String name; + + private volatile boolean populated; + + NestedJarEntry(Entry contentEntry) { + this(contentEntry, contentEntry.getName()); + } + + NestedJarEntry(ZipContent.Entry contentEntry, String name) { + super(contentEntry.getName()); + this.contentEntry = contentEntry; + this.name = name; + } + + @Override + public long getTime() { + populate(); + return super.getTime(); + } + + @Override + public LocalDateTime getTimeLocal() { + populate(); + return super.getTimeLocal(); + } + + @Override + public void setTime(long time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public void setTimeLocal(LocalDateTime time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public FileTime getLastModifiedTime() { + populate(); + return super.getLastModifiedTime(); + } + + @Override + public ZipEntry setLastModifiedTime(FileTime time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public FileTime getLastAccessTime() { + populate(); + return super.getLastAccessTime(); + } + + @Override + public ZipEntry setLastAccessTime(FileTime time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public FileTime getCreationTime() { + populate(); + return super.getCreationTime(); + } + + @Override + public ZipEntry setCreationTime(FileTime time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public long getSize() { + return this.contentEntry.getUncompressedSize() & 0xFFFFFFFFL; + } + + @Override + public void setSize(long size) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public long getCompressedSize() { + populate(); + return super.getCompressedSize(); + } + + @Override + public void setCompressedSize(long csize) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public long getCrc() { + populate(); + return super.getCrc(); + } + + @Override + public void setCrc(long crc) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public int getMethod() { + populate(); + return super.getMethod(); + } + + @Override + public void setMethod(int method) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public byte[] getExtra() { + populate(); + return super.getExtra(); + } + + @Override + public void setExtra(byte[] extra) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public String getComment() { + populate(); + return super.getComment(); + } + + @Override + public void setComment(String comment) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + boolean isOwnedBy(NestedJarFile nestedJarFile) { + return NestedJarFile.this == nestedJarFile; + } + + @Override + public String getRealName() { + return super.getName(); + } + + @Override + public String getName() { + return this.name; + } + + @Override + public Attributes getAttributes() throws IOException { + Manifest manifest = getManifest(); + return (manifest != null) ? manifest.getAttributes(getName()) : null; + } + + @Override + public Certificate[] getCertificates() { + return getSecurityInfo().getCertificates(contentEntry()); + } + + @Override + public CodeSigner[] getCodeSigners() { + return getSecurityInfo().getCodeSigners(contentEntry()); + } + + private SecurityInfo getSecurityInfo() { + return NestedJarFile.this.resources.zipContent().getInfo(SecurityInfo.class, SecurityInfo::get); + } + + ZipContent.Entry contentEntry() { + return this.contentEntry; + } + + private void populate() { + boolean populated = this.populated; + if (!populated) { + ZipEntry entry = this.contentEntry.as(ZipEntry::new); + super.setMethod(entry.getMethod()); + super.setTime(entry.getTime()); + super.setCrc(entry.getCrc()); + super.setCompressedSize(entry.getCompressedSize()); + super.setSize(entry.getSize()); + super.setExtra(entry.getExtra()); + super.setComment(entry.getComment()); + this.populated = true; + } + } + + } + + /** + * {@link Enumeration} of {@link NestedJarEntry} instances. + */ + private class JarEntriesEnumeration implements Enumeration { + + private final ZipContent zipContent; + + private int cursor; + + JarEntriesEnumeration(ZipContent zipContent) { + this.zipContent = zipContent; + } + + @Override + public boolean hasMoreElements() { + return this.cursor < this.zipContent.size(); + } + + @Override + public NestedJarEntry nextElement() { + if (!hasMoreElements()) { + throw new NoSuchElementException(); + } + synchronized (NestedJarFile.this) { + ensureOpen(); + return new NestedJarEntry(this.zipContent.getEntry(this.cursor++)); + } + } + + } + + /** + * {@link Spliterator} for {@link ZipContent.Entry} instances. + */ + private class ZipContentEntriesSpliterator extends AbstractSpliterator { + + private static final int ADDITIONAL_CHARACTERISTICS = Spliterator.ORDERED | Spliterator.DISTINCT + | Spliterator.IMMUTABLE | Spliterator.NONNULL; + + private final ZipContent zipContent; + + private int cursor; + + ZipContentEntriesSpliterator(ZipContent zipContent) { + super(zipContent.size(), ADDITIONAL_CHARACTERISTICS); + this.zipContent = zipContent; + } + + @Override + public boolean tryAdvance(Consumer action) { + if (this.cursor < this.zipContent.size()) { + synchronized (NestedJarFile.this) { + ensureOpen(); + action.accept(this.zipContent.getEntry(this.cursor++)); + } + return true; + } + return false; + } + + } + + /** + * {@link InputStream} to read jar entry content. + */ + private class JarEntryInputStream extends InputStream { + + private final int uncompressedSize; + + private final CloseableDataBlock content; + + private long pos; + + private long remaining; + + private volatile boolean closed; + + JarEntryInputStream(ZipContent.Entry entry) throws IOException { + this.uncompressedSize = entry.getUncompressedSize(); + this.content = entry.openContent(); + } + + @Override + public int read() throws IOException { + byte[] b = new byte[1]; + return (read(b, 0, 1) == 1) ? b[0] & 0xFF : -1; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int result; + synchronized (NestedJarFile.this) { + ensureOpen(); + ByteBuffer dst = ByteBuffer.wrap(b, off, len); + int count = this.content.read(dst, this.pos); + if (count > 0) { + this.pos += count; + this.remaining -= count; + } + result = count; + } + if (this.remaining == 0) { + close(); + } + return result; + } + + @Override + public long skip(long n) throws IOException { + long result; + synchronized (NestedJarFile.this) { + result = (n > 0) ? maxForwardSkip(n) : maxBackwardSkip(n); + this.pos += result; + this.remaining -= result; + } + if (this.remaining == 0) { + close(); + } + return result; + } + + private long maxForwardSkip(long n) { + boolean willCauseOverflow = (this.pos + n) < 0; + return (willCauseOverflow || n > this.remaining) ? this.remaining : n; + } + + private long maxBackwardSkip(long n) { + return Math.max(-this.pos, n); + } + + @Override + public int available() { + return (this.remaining < Integer.MAX_VALUE) ? (int) this.remaining : Integer.MAX_VALUE; + } + + private void ensureOpen() throws ZipException { + if (NestedJarFile.this.closed || this.closed) { + throw new ZipException("ZipFile closed"); + } + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + this.content.close(); + NestedJarFile.this.resources.removeInputStream(this); + } + + int getUncompressedSize() { + return this.uncompressedSize; + } + + } + + /** + * {@link ZipInflaterInputStream} to read and inflate jar entry content. + */ + private class JarEntryInflaterInputStream extends ZipInflaterInputStream { + + private final Cleanable cleanup; + + private volatile boolean closed; + + JarEntryInflaterInputStream(JarEntryInputStream inputStream, NestedJarFileResources resources) { + this(inputStream, resources, resources.getOrCreateInflater()); + } + + private JarEntryInflaterInputStream(JarEntryInputStream inputStream, NestedJarFileResources resources, + Inflater inflater) { + super(inputStream, inflater, inputStream.getUncompressedSize()); + this.cleanup = NestedJarFile.this.cleaner.register(this, resources.createInflatorCleanupAction(inflater)); + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + super.close(); + NestedJarFile.this.resources.removeInputStream(this); + this.cleanup.clean(); + } + + } + + /** + * {@link InputStream} for raw zip data. + */ + private class RawZipDataInputStream extends FilterInputStream { + + private volatile boolean closed; + + RawZipDataInputStream(InputStream in) { + super(in); + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + super.close(); + NestedJarFile.this.resources.removeInputStream(this); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java new file mode 100644 index 000000000000..e4e66ee2ac8a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java @@ -0,0 +1,237 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.jar; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.zip.Inflater; + +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.zip.ZipContent; +import org.springframework.boot.loader.zip.ZipContent.Kind; + +/** + * Resources created managed and cleaned by a {@link NestedJarFile} instance and suitable + * for registration with a {@link Cleaner}. + * + * @author Phillip Webb + */ +class NestedJarFileResources implements Runnable { + + private static final int INFLATER_CACHE_LIMIT = 20; + + private ZipContent zipContent; + + private ZipContent zipContentForManifest; + + private final Set inputStreams = Collections.newSetFromMap(new WeakHashMap<>()); + + private Deque inflaterCache = new ArrayDeque<>(); + + /** + * Create a new {@link NestedJarFileResources} instance. + * @param file the source zip file + * @param nestedEntryName the nested entry or {@code null} + * @throws IOException on I/O error + */ + NestedJarFileResources(File file, String nestedEntryName) throws IOException { + this.zipContent = ZipContent.open(file.toPath(), nestedEntryName); + this.zipContentForManifest = (this.zipContent.getKind() != Kind.NESTED_DIRECTORY) ? null + : ZipContent.open(file.toPath()); + } + + /** + * Return the underling {@link ZipContent}. + * @return the zip content + */ + ZipContent zipContent() { + return this.zipContent; + } + + /** + * Return the underlying {@link ZipContent} that should be used to load manifest + * content. + * @return the zip content to use when loading the manifest + */ + ZipContent zipContentForManifest() { + return (this.zipContentForManifest != null) ? this.zipContentForManifest : this.zipContent; + } + + /** + * Add a managed input stream resource. + * @param inputStream the input stream + */ + void addInputStream(InputStream inputStream) { + synchronized (this.inputStreams) { + this.inputStreams.add(inputStream); + } + } + + /** + * Remove a managed input stream resource. + * @param inputStream the input stream + */ + void removeInputStream(InputStream inputStream) { + synchronized (this.inputStreams) { + this.inputStreams.remove(inputStream); + } + } + + /** + * Create a {@link Runnable} action to cleanup the given inflater. + * @param inflater the inflater to cleanup + * @return the cleanup action + */ + Runnable createInflatorCleanupAction(Inflater inflater) { + return () -> endOrCacheInflater(inflater); + } + + /** + * Get previously used {@link Inflater} from the cache, or create a new one. + * @return a usable {@link Inflater} + */ + Inflater getOrCreateInflater() { + Deque inflaterCache = this.inflaterCache; + if (inflaterCache != null) { + synchronized (inflaterCache) { + Inflater inflater = this.inflaterCache.poll(); + if (inflater != null) { + return inflater; + } + } + } + return new Inflater(true); + } + + /** + * Either release the given {@link Inflater} by calling {@link Inflater#end()} or add + * it to the cache for later reuse. + * @param inflater the inflater to end or cache + */ + private void endOrCacheInflater(Inflater inflater) { + Deque inflaterCache = this.inflaterCache; + if (inflaterCache != null) { + synchronized (inflaterCache) { + if (this.inflaterCache == inflaterCache && inflaterCache.size() < INFLATER_CACHE_LIMIT) { + inflater.reset(); + this.inflaterCache.add(inflater); + return; + } + } + } + inflater.end(); + } + + /** + * Called by the {@link Cleaner} to free resources. + * @see java.lang.Runnable#run() + */ + @Override + public void run() { + releaseAll(); + } + + private void releaseAll() { + IOException exceptionChain = null; + exceptionChain = releaseInflators(exceptionChain); + exceptionChain = releaseInputStreams(exceptionChain); + exceptionChain = releaseZipContent(exceptionChain); + exceptionChain = releaseZipContentForManifest(exceptionChain); + if (exceptionChain != null) { + throw new UncheckedIOException(exceptionChain); + } + } + + private IOException releaseInflators(IOException exceptionChain) { + Deque inflaterCache = this.inflaterCache; + if (inflaterCache != null) { + try { + synchronized (inflaterCache) { + inflaterCache.forEach(Inflater::end); + } + } + finally { + this.inflaterCache = null; + } + } + return exceptionChain; + } + + private IOException releaseInputStreams(IOException exceptionChain) { + synchronized (this.inputStreams) { + for (InputStream inputStream : List.copyOf(this.inputStreams)) { + try { + inputStream.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + } + this.inputStreams.clear(); + } + return exceptionChain; + } + + private IOException releaseZipContent(IOException exceptionChain) { + ZipContent zipContent = this.zipContent; + if (zipContent != null) { + try { + zipContent.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + finally { + this.zipContent = null; + } + } + return exceptionChain; + } + + private IOException releaseZipContentForManifest(IOException exceptionChain) { + ZipContent zipContentForManifest = this.zipContentForManifest; + if (zipContentForManifest != null) { + try { + zipContentForManifest.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + finally { + this.zipContentForManifest = null; + } + } + return exceptionChain; + } + + private IOException addToExceptionChain(IOException exceptionChain, IOException ex) { + if (exceptionChain != null) { + exceptionChain.addSuppressed(ex); + return exceptionChain; + } + return ex; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java new file mode 100644 index 000000000000..3b20bebdbe4d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java @@ -0,0 +1,110 @@ +/* + * Copyright 2012-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.boot.loader.jar; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.security.CodeSigner; +import java.security.cert.Certificate; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; + +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Security information ({@link Certificate} and {@link CodeSigner} details) for entries + * in the jar. + * + * @author Phillip Webb + */ +final class SecurityInfo { + + static final SecurityInfo NONE = new SecurityInfo(null, null); + + private final Certificate[][] certificateLookups; + + private final CodeSigner[][] codeSignerLookups; + + private SecurityInfo(Certificate[][] entryCertificates, CodeSigner[][] entryCodeSigners) { + this.certificateLookups = entryCertificates; + this.codeSignerLookups = entryCodeSigners; + } + + Certificate[] getCertificates(ZipContent.Entry contentEntry) { + return (this.certificateLookups != null) ? clone(this.certificateLookups[contentEntry.getLookupIndex()]) : null; + } + + CodeSigner[] getCodeSigners(ZipContent.Entry contentEntry) { + return (this.codeSignerLookups != null) ? clone(this.codeSignerLookups[contentEntry.getLookupIndex()]) : null; + } + + private T[] clone(T[] array) { + return (array != null) ? array.clone() : null; + } + + /** + * Get the {@link SecurityInfo} for the given {@link ZipContent}. + * @param content the zip content + * @return the security info + */ + static SecurityInfo get(ZipContent content) { + if (!content.hasJarSignatureFile()) { + return NONE; + } + try { + return load(content); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * Load security info from the jar file. We need to use {@link JarInputStream} to + * obtain the security info since we don't have an actual real file to read. This + * isn't that fast, but hopefully doesn't happen too often and the result is cached. + * @param content the zip content + * @return the security info + * @throws IOException on I/O error + */ + private static SecurityInfo load(ZipContent content) throws IOException { + int size = content.size(); + boolean hasSecurityInfo = false; + Certificate[][] entryCertificates = new Certificate[size][]; + CodeSigner[][] entryCodeSigners = new CodeSigner[size][]; + try (JarInputStream in = new JarInputStream(content.openRawZipData().asInputStream())) { + JarEntry jarEntry = in.getNextJarEntry(); + while (jarEntry != null) { + in.closeEntry(); // Close to trigger a read and set certs/signers + Certificate[] certificates = jarEntry.getCertificates(); + CodeSigner[] codeSigners = jarEntry.getCodeSigners(); + if (certificates != null || codeSigners != null) { + ZipContent.Entry contentEntry = content.getEntry(jarEntry.getName()); + if (contentEntry != null) { + hasSecurityInfo = true; + entryCertificates[contentEntry.getLookupIndex()] = certificates; + entryCodeSigners[contentEntry.getLookupIndex()] = codeSigners; + } + } + jarEntry = in.getNextJarEntry(); + } + return (!hasSecurityInfo) ? NONE : new SecurityInfo(entryCertificates, entryCodeSigners); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java index 87587bed3ff1..1528f0b9c507 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -24,27 +24,32 @@ /** * {@link InflaterInputStream} that supports the writing of an extra "dummy" byte (which - * is required with JDK 6) and returns accurate available() results. + * is required when using an {@link Inflater} with {@code nowrap}) and returns accurate + * available() results. * * @author Phillip Webb */ -class ZipInflaterInputStream extends InflaterInputStream { +abstract class ZipInflaterInputStream extends InflaterInputStream { private int available; private boolean extraBytesWritten; - ZipInflaterInputStream(InputStream inputStream, int size) { - super(inputStream, new Inflater(true), getInflaterBufferSize(size)); + ZipInflaterInputStream(InputStream inputStream, Inflater inflater, int size) { + super(inputStream, inflater, getInflaterBufferSize(size)); this.available = size; } + private static int getInflaterBufferSize(long size) { + size += 2; // inflater likes some space + size = (size > 65536) ? 8192 : size; + size = (size <= 0) ? 4096 : size; + return (int) size; + } + @Override public int available() throws IOException { - if (this.available < 0) { - return super.available(); - } - return this.available; + return (this.available >= 0) ? this.available : super.available(); } @Override @@ -56,12 +61,6 @@ public int read(byte[] b, int off, int len) throws IOException { return result; } - @Override - public void close() throws IOException { - super.close(); - this.inf.end(); - } - @Override protected void fill() throws IOException { try { @@ -78,11 +77,4 @@ protected void fill() throws IOException { } } - private static int getInflaterBufferSize(long size) { - size += 2; // inflater likes some space - size = (size > 65536) ? 8192 : size; - size = (size <= 0) ? 4096 : size; - return (int) size; - } - } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java index e232261ff47e..ae1ba30639e2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -15,6 +15,7 @@ */ /** - * Support for loading and manipulating JAR/WAR files. + * Alternative {@link java.util.jar.JarFile} implementation with support for nested jars. + * @see org.springframework.boot.loader.jar.NestedJarFile */ package org.springframework.boot.loader.jar; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java index c711e206f5da..162e4a6a7396 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java index 315cb5696b83..d68ef83474eb 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -16,7 +16,5 @@ /** * Support for launching the JAR using jarmode. - * - * @see org.springframework.boot.loader.jarmode.JarModeLauncher */ package org.springframework.boot.loader.jarmode; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Archive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Archive.java new file mode 100644 index 000000000000..933a630ffb30 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Archive.java @@ -0,0 +1,150 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.Set; +import java.util.function.Predicate; +import java.util.jar.Manifest; + +/** + * An archive that can be launched by the {@link Launcher}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public interface Archive extends AutoCloseable { + + /** + * Predicate that accepts all entries. + */ + Predicate ALL_ENTRIES = (entry) -> true; + + /** + * Returns the manifest of the archive. + * @return the manifest or {@code null} + * @throws IOException if the manifest cannot be read + */ + Manifest getManifest() throws IOException; + + /** + * Returns classpath URLs for the archive that match the specified filter. + * @param includeFilter filter used to determine which entries should be included. + * @return the classpath URLs + * @throws IOException on IO error + */ + default Set getClassPathUrls(Predicate includeFilter) throws IOException { + return getClassPathUrls(includeFilter, ALL_ENTRIES); + + } + + /** + * Returns classpath URLs for the archive that match the specified filters. + * @param includeFilter filter used to determine which entries should be included + * @param directorySearchFilter filter used to optimize tree walking for exploded + * archives by determining if a directory needs to be searched or not + * @return the classpath URLs + * @throws IOException on IO error + */ + Set getClassPathUrls(Predicate includeFilter, Predicate directorySearchFilter) + throws IOException; + + /** + * Returns if this archive is backed by an exploded archive directory. + * @return if the archive is exploded + */ + default boolean isExploded() { + return getRootDirectory() != null; + } + + /** + * Returns the root directory of this archive or {@code null} if the archive is not + * backed by a directory. + * @return the root directory + */ + default File getRootDirectory() { + return null; + } + + /** + * Closes the {@code Archive}, releasing any open resources. + * @throws Exception if an error occurs during close processing + */ + @Override + default void close() throws Exception { + } + + /** + * Factory method to create an appropriate {@link Archive} from the given + * {@link Class} target. + * @param target a target class that will be used to find the archive code source + * @return an new {@link Archive} instance + * @throws Exception if the archive cannot be created + */ + static Archive create(Class target) throws Exception { + return create(target.getProtectionDomain()); + } + + static Archive create(ProtectionDomain protectionDomain) throws Exception { + CodeSource codeSource = protectionDomain.getCodeSource(); + URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null; + String path = (location != null) ? location.getSchemeSpecificPart() : null; + if (path == null) { + throw new IllegalStateException("Unable to determine code source archive"); + } + return create(new File(path)); + } + + /** + * Factory method to create an {@link Archive} from the given {@link File} target. + * @param target a target {@link File} used to create the archive. May be a directory + * or a jar file. + * @return a new {@link Archive} instance. + * @throws Exception if the archive cannot be created + */ + static Archive create(File target) throws Exception { + if (!target.exists()) { + throw new IllegalStateException("Unable to determine code source archive from " + target); + } + return (target.isDirectory() ? new ExplodedArchive(target) : new JarFileArchive(target)); + } + + /** + * Represents a single entry in the archive. + */ + interface Entry { + + /** + * Returns the name of the entry. + * @return the name of the entry + */ + String name(); + + /** + * Returns {@code true} if the entry represents a directory. + * @return if the entry is a directory + */ + boolean isDirectory(); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ClassPathIndexFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ClassPathIndexFile.java new file mode 100644 index 000000000000..dcc4384099a7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ClassPathIndexFile.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A class path index file that provides an ordered classpath for exploded JARs. + * + * @author Madhura Bhave + * @author Phillip Webb + */ +final class ClassPathIndexFile { + + private final File root; + + private final Set lines; + + private ClassPathIndexFile(File root, List lines) { + this.root = root; + this.lines = lines.stream().map(this::extractName).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private String extractName(String line) { + if (line.startsWith("- \"") && line.endsWith("\"")) { + return line.substring(3, line.length() - 1); + } + throw new IllegalStateException("Malformed classpath index line [" + line + "]"); + } + + int size() { + return this.lines.size(); + } + + boolean containsEntry(String name) { + if (name == null || name.isEmpty()) { + return false; + } + return this.lines.contains(name); + } + + List getUrls() { + return this.lines.stream().map(this::asUrl).toList(); + } + + private URL asUrl(String line) { + try { + return new File(this.root, line).toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + static ClassPathIndexFile loadIfPossible(File root, String location) throws IOException { + return loadIfPossible(root, new File(root, location)); + } + + private static ClassPathIndexFile loadIfPossible(File root, File indexFile) throws IOException { + if (indexFile.exists() && indexFile.isFile()) { + List lines = Files.readAllLines(indexFile.toPath()) + .stream() + .filter(ClassPathIndexFile::lineHasText) + .toList(); + return new ClassPathIndexFile(root, lines); + } + return null; + } + + private static boolean lineHasText(String line) { + return !line.trim().isEmpty(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java new file mode 100644 index 000000000000..fd6bd8cf527c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java @@ -0,0 +1,136 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.launch.Archive.Entry; + +/** + * Base class for a {@link Launcher} backed by an executable archive. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + * @since 3.2.0 + * @see JarLauncher + * @see WarLauncher + */ +public abstract class ExecutableArchiveLauncher extends Launcher { + + private static final String START_CLASS_ATTRIBUTE = "Start-Class"; + + protected static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index"; + + protected static final String DEFAULT_CLASSPATH_INDEX_FILE_NAME = "classpath.idx"; + + private final Archive archive; + + private final ClassPathIndexFile classPathIndex; + + public ExecutableArchiveLauncher() throws Exception { + this(Archive.create(Launcher.class)); + } + + protected ExecutableArchiveLauncher(Archive archive) throws Exception { + this.archive = archive; + this.classPathIndex = getClassPathIndex(this.archive); + } + + ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException { + if (!archive.isExploded()) { + return null; // Regular archives already have a defined order + } + String location = getClassPathIndexFileLocation(archive); + return ClassPathIndexFile.loadIfPossible(archive.getRootDirectory(), location); + } + + private String getClassPathIndexFileLocation(Archive archive) throws IOException { + Manifest manifest = archive.getManifest(); + Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null; + String location = (attributes != null) ? attributes.getValue(BOOT_CLASSPATH_INDEX_ATTRIBUTE) : null; + return (location != null) ? location : getEntryPathPrefix() + DEFAULT_CLASSPATH_INDEX_FILE_NAME; + } + + @Override + protected ClassLoader createClassLoader(Collection urls) throws Exception { + if (this.classPathIndex != null) { + urls = new ArrayList<>(urls); + urls.addAll(this.classPathIndex.getUrls()); + } + return super.createClassLoader(urls); + } + + @Override + protected final Archive getArchive() { + return this.archive; + } + + @Override + protected String getMainClass() throws Exception { + Manifest manifest = this.archive.getManifest(); + String mainClass = (manifest != null) ? manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE) : null; + if (mainClass == null) { + throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this); + } + return mainClass; + } + + @Override + protected Set getClassPathUrls() throws Exception { + return this.archive.getClassPathUrls(this::isIncludedOnClassPathAndNotIndexed, this::isSearchedDirectory); + } + + private boolean isIncludedOnClassPathAndNotIndexed(Entry entry) { + if (!isIncludedOnClassPath(entry)) { + return false; + } + return (this.classPathIndex == null) || !this.classPathIndex.containsEntry(entry.name()); + } + + /** + * Determine if the specified directory entry is a candidate for further searching. + * @param entry the entry to check + * @return {@code true} if the entry is a candidate for further searching + */ + protected boolean isSearchedDirectory(Archive.Entry entry) { + return ((getEntryPathPrefix() == null) || entry.name().startsWith(getEntryPathPrefix())) + && !isIncludedOnClassPath(entry); + } + + /** + * Determine if the specified entry is a nested item that should be added to the + * classpath. + * @param entry the entry to check + * @return {@code true} if the entry is a nested item (jar or directory) + */ + protected abstract boolean isIncludedOnClassPath(Archive.Entry entry); + + /** + * Return the path prefix for relevant entries in the archive. + * @return the entry path prefix + */ + protected abstract String getEntryPathPrefix(); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.java new file mode 100644 index 000000000000..79cb729f60f7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.java @@ -0,0 +1,139 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import java.util.jar.Manifest; + +/** + * {@link Archive} implementation backed by an exploded archive directory. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + */ +class ExplodedArchive implements Archive { + + private static final Object NO_MANIFEST = new Object(); + + private static final Set SKIPPED_NAMES = Set.of(".", ".."); + + private static final Comparator entryComparator = Comparator.comparing(File::getAbsolutePath); + + private final File rootDirectory; + + private final String rootUriPath; + + private volatile Object manifest; + + /** + * Create a new {@link ExplodedArchive} instance. + * @param rootDirectory the root directory + */ + ExplodedArchive(File rootDirectory) { + if (!rootDirectory.exists() || !rootDirectory.isDirectory()) { + throw new IllegalArgumentException("Invalid source directory " + rootDirectory); + } + this.rootDirectory = rootDirectory; + this.rootUriPath = ExplodedArchive.this.rootDirectory.toURI().getPath(); + } + + @Override + public Manifest getManifest() throws IOException { + Object manifest = this.manifest; + if (manifest == null) { + manifest = loadManifest(); + this.manifest = manifest; + } + return (manifest != NO_MANIFEST) ? (Manifest) manifest : null; + } + + private Object loadManifest() throws IOException { + File file = new File(this.rootDirectory, "META-INF/MANIFEST.MF"); + if (!file.exists()) { + return NO_MANIFEST; + } + try (FileInputStream inputStream = new FileInputStream(file)) { + return new Manifest(inputStream); + } + } + + @Override + public Set getClassPathUrls(Predicate includeFilter, Predicate directorySearchFilter) + throws IOException { + Set urls = new LinkedHashSet<>(); + LinkedList files = new LinkedList<>(listFiles(this.rootDirectory)); + while (!files.isEmpty()) { + File file = files.poll(); + if (SKIPPED_NAMES.contains(file.getName())) { + continue; + } + String entryName = file.toURI().getPath().substring(this.rootUriPath.length()); + Entry entry = new FileArchiveEntry(entryName, file); + if (entry.isDirectory() && directorySearchFilter.test(entry)) { + files.addAll(0, listFiles(file)); + } + if (includeFilter.test(entry)) { + urls.add(file.toURI().toURL()); + } + } + return urls; + } + + private List listFiles(File file) { + File[] files = file.listFiles(); + if (files == null) { + return Collections.emptyList(); + } + Arrays.sort(files, entryComparator); + return Arrays.asList(files); + } + + @Override + public File getRootDirectory() { + return this.rootDirectory; + } + + @Override + public String toString() { + return this.rootDirectory.toString(); + } + + /** + * {@link Entry} backed by a File. + */ + private record FileArchiveEntry(String name, File file) implements Entry { + + @Override + public boolean isDirectory() { + return this.file.isDirectory(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java new file mode 100755 index 000000000000..a38379de908c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java @@ -0,0 +1,205 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.stream.Collectors; + +import org.springframework.boot.loader.net.protocol.jar.JarUrl; + +/** + * {@link Archive} implementation backed by a {@link JarFile}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class JarFileArchive implements Archive { + + private static final String UNPACK_MARKER = "UNPACK:"; + + private static final FileAttribute[] NO_FILE_ATTRIBUTES = {}; + + private static final FileAttribute[] DIRECTORY_PERMISSION_ATTRIBUTES = asFileAttributes( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE); + + private static final FileAttribute[] FILE_PERMISSION_ATTRIBUTES = asFileAttributes( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + + private static final Path TEMP = Paths.get(System.getProperty("java.io.tmpdir")); + + private final File file; + + private final JarFile jarFile; + + private volatile Path tempUnpackDirectory; + + JarFileArchive(File file) throws IOException { + this(file, new JarFile(file)); + } + + private JarFileArchive(File file, JarFile jarFile) { + this.file = file; + this.jarFile = jarFile; + } + + @Override + public Manifest getManifest() throws IOException { + return this.jarFile.getManifest(); + } + + @Override + public Set getClassPathUrls(Predicate includeFilter, Predicate directorySearchFilter) + throws IOException { + return this.jarFile.stream() + .map(JarArchiveEntry::new) + .filter(includeFilter) + .map(this::getNestedJarUrl) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private URL getNestedJarUrl(JarArchiveEntry archiveEntry) { + try { + JarEntry jarEntry = archiveEntry.jarEntry(); + String comment = jarEntry.getComment(); + if (comment != null && comment.startsWith(UNPACK_MARKER)) { + return getUnpackedNestedJarUrl(jarEntry); + } + return JarUrl.create(this.file, jarEntry); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private URL getUnpackedNestedJarUrl(JarEntry jarEntry) throws IOException { + String name = jarEntry.getName(); + if (name.lastIndexOf('/') != -1) { + name = name.substring(name.lastIndexOf('/') + 1); + } + Path path = getTempUnpackDirectory().resolve(name); + if (!Files.exists(path) || Files.size(path) != jarEntry.getSize()) { + unpack(jarEntry, path); + } + return path.toUri().toURL(); + } + + private Path getTempUnpackDirectory() { + Path tempUnpackDirectory = this.tempUnpackDirectory; + if (tempUnpackDirectory != null) { + return tempUnpackDirectory; + } + synchronized (TEMP) { + tempUnpackDirectory = this.tempUnpackDirectory; + if (tempUnpackDirectory == null) { + tempUnpackDirectory = createUnpackDirectory(TEMP); + this.tempUnpackDirectory = tempUnpackDirectory; + } + } + return tempUnpackDirectory; + } + + private Path createUnpackDirectory(Path parent) { + int attempts = 0; + String fileName = Paths.get(this.jarFile.getName()).getFileName().toString(); + while (attempts++ < 100) { + Path unpackDirectory = parent.resolve(fileName + "-spring-boot-libs-" + UUID.randomUUID()); + try { + createDirectory(unpackDirectory); + return unpackDirectory; + } + catch (IOException ex) { + // Ignore + } + } + throw new IllegalStateException("Failed to create unpack directory in directory '" + parent + "'"); + } + + private void createDirectory(Path path) throws IOException { + Files.createDirectory(path, getFileAttributes(path, DIRECTORY_PERMISSION_ATTRIBUTES)); + } + + private void unpack(JarEntry entry, Path path) throws IOException { + createFile(path); + path.toFile().deleteOnExit(); + try (InputStream in = this.jarFile.getInputStream(entry)) { + Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING); + } + } + + private void createFile(Path path) throws IOException { + Files.createFile(path, getFileAttributes(path, FILE_PERMISSION_ATTRIBUTES)); + } + + private FileAttribute[] getFileAttributes(Path path, FileAttribute[] permissionAttributes) { + return (!supportsPosix(path.getFileSystem())) ? NO_FILE_ATTRIBUTES : permissionAttributes; + } + + private boolean supportsPosix(FileSystem fileSystem) { + return fileSystem.supportedFileAttributeViews().contains("posix"); + } + + @Override + public void close() throws IOException { + this.jarFile.close(); + } + + @Override + public String toString() { + return this.file.toString(); + } + + private static FileAttribute[] asFileAttributes(PosixFilePermission... permissions) { + return new FileAttribute[] { PosixFilePermissions.asFileAttribute(Set.of(permissions)) }; + } + + /** + * {@link Entry} implementation backed by a {@link JarEntry}. + */ + private record JarArchiveEntry(JarEntry jarEntry) implements Entry { + + @Override + public String name() { + return this.jarEntry.getName(); + } + + @Override + public boolean isDirectory() { + return this.jarEntry.isDirectory(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java new file mode 100644 index 000000000000..3a6d1339ca11 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +/** + * {@link Launcher} for JAR based archives. This launcher assumes that dependency jars are + * included inside a {@code /BOOT-INF/lib} directory and that application classes are + * included inside a {@code /BOOT-INF/classes} directory. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + * @since 3.2.0 + */ +public class JarLauncher extends ExecutableArchiveLauncher { + + public JarLauncher() throws Exception { + } + + protected JarLauncher(Archive archive) throws Exception { + super(archive); + } + + @Override + protected boolean isIncludedOnClassPath(Archive.Entry entry) { + return isLibraryFileOrClassesDirectory(entry); + } + + @Override + protected String getEntryPathPrefix() { + return "BOOT-INF/"; + } + + static boolean isLibraryFileOrClassesDirectory(Archive.Entry entry) { + String name = entry.name(); + if (entry.isDirectory()) { + return name.equals("BOOT-INF/classes/"); + } + return name.startsWith("BOOT-INF/lib/"); + } + + public static void main(String[] args) throws Exception { + new JarLauncher().launch(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarModeRunner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarModeRunner.java new file mode 100644 index 000000000000..4805a633d48c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarModeRunner.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.util.List; + +import org.springframework.boot.loader.jarmode.JarMode; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; + +/** + * Delegate class used to run the nested jar in a specific mode. + * + * @author Phillip Webb + */ +final class JarModeRunner { + + static final String DISABLE_SYSTEM_EXIT = JarModeRunner.class.getName() + ".DISABLE_SYSTEM_EXIT"; + + private JarModeRunner() { + } + + static void main(String[] args) { + String mode = System.getProperty("jarmode"); + List candidates = SpringFactoriesLoader.loadFactories(JarMode.class, + ClassUtils.getDefaultClassLoader()); + for (JarMode candidate : candidates) { + if (candidate.accepts(mode)) { + candidate.run(mode, args); + return; + } + } + System.err.println("Unsupported jarmode '" + mode + "'"); + if (!Boolean.getBoolean(DISABLE_SYSTEM_EXIT)) { + System.exit(1); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.java new file mode 100644 index 000000000000..c604df0487a4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.java @@ -0,0 +1,189 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.function.Supplier; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.net.protocol.jar.JarUrlClassLoader; + +/** + * {@link ClassLoader} used by the {@link Launcher}. + * + * @author Phillip Webb + * @author Dave Syer + * @author Andy Wilkinson + * @since 3.2.0 + */ +public class LaunchedClassLoader extends JarUrlClassLoader { + + private static final String JAR_MODE_PACKAGE_PREFIX = "org.springframework.boot.loader.jarmode."; + + private static final String JAR_MODE_RUNNER_CLASS_NAME = JarModeRunner.class.getName(); + + static { + ClassLoader.registerAsParallelCapable(); + } + + private final boolean exploded; + + private final Archive rootArchive; + + private final Object definePackageLock = new Object(); + + private volatile DefinePackageCallType definePackageCallType; + + /** + * Create a new {@link LaunchedClassLoader} instance. + * @param exploded if the underlying archive is exploded + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + */ + public LaunchedClassLoader(boolean exploded, URL[] urls, ClassLoader parent) { + this(exploded, null, urls, parent); + } + + /** + * Create a new {@link LaunchedClassLoader} instance. + * @param exploded if the underlying archive is exploded + * @param rootArchive the root archive or {@code null} + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + */ + public LaunchedClassLoader(boolean exploded, Archive rootArchive, URL[] urls, ClassLoader parent) { + super(urls, parent); + this.exploded = exploded; + this.rootArchive = rootArchive; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith(JAR_MODE_PACKAGE_PREFIX) || name.equals(JAR_MODE_RUNNER_CLASS_NAME)) { + try { + Class result = loadClassInLaunchedClassLoader(name); + if (resolve) { + resolveClass(result); + } + return result; + } + catch (ClassNotFoundException ex) { + // Ignore + } + } + return super.loadClass(name, resolve); + } + + private Class loadClassInLaunchedClassLoader(String name) throws ClassNotFoundException { + try { + String internalName = name.replace('.', '/') + ".class"; + try (InputStream inputStream = getParent().getResourceAsStream(internalName); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + if (inputStream == null) { + throw new ClassNotFoundException(name); + } + inputStream.transferTo(outputStream); + byte[] bytes = outputStream.toByteArray(); + Class definedClass = defineClass(name, bytes, 0, bytes.length); + definePackageIfNecessary(name); + return definedClass; + } + } + catch (IOException ex) { + throw new ClassNotFoundException("Cannot load resource for class [" + name + "]", ex); + } + } + + @Override + protected Package definePackage(String name, Manifest man, URL url) throws IllegalArgumentException { + return (!this.exploded) ? super.definePackage(name, man, url) : definePackageForExploded(name, man, url); + } + + private Package definePackageForExploded(String name, Manifest man, URL url) { + synchronized (this.definePackageLock) { + return definePackage(DefinePackageCallType.MANIFEST, () -> super.definePackage(name, man, url)); + } + } + + @Override + protected Package definePackage(String name, String specTitle, String specVersion, String specVendor, + String implTitle, String implVersion, String implVendor, URL sealBase) throws IllegalArgumentException { + if (!this.exploded) { + return super.definePackage(name, specTitle, specVersion, specVendor, implTitle, implVersion, implVendor, + sealBase); + } + return definePackageForExploded(name, sealBase, () -> super.definePackage(name, specTitle, specVersion, + specVendor, implTitle, implVersion, implVendor, sealBase)); + } + + private Package definePackageForExploded(String name, URL sealBase, Supplier call) { + synchronized (this.definePackageLock) { + if (this.definePackageCallType == null) { + // We're not part of a call chain which means that the URLClassLoader + // is trying to define a package for our exploded JAR. We use the + // manifest version to ensure package attributes are set + Manifest manifest = getManifest(this.rootArchive); + if (manifest != null) { + return definePackage(name, manifest, sealBase); + } + } + return definePackage(DefinePackageCallType.ATTRIBUTES, call); + } + } + + private T definePackage(DefinePackageCallType type, Supplier call) { + DefinePackageCallType existingType = this.definePackageCallType; + try { + this.definePackageCallType = type; + return call.get(); + } + finally { + this.definePackageCallType = existingType; + } + } + + private Manifest getManifest(Archive archive) { + try { + return (archive != null) ? archive.getManifest() : null; + } + catch (IOException ex) { + return null; + } + } + + /** + * The different types of call made to define a package. We track these for exploded + * jars so that we can detect packages that should have manifest attributes applied. + */ + private enum DefinePackageCallType { + + /** + * A define package call from a resource that has a manifest. + */ + MANIFEST, + + /** + * A define package call with a direct set of attributes. + */ + ATTRIBUTES + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Launcher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Launcher.java new file mode 100644 index 000000000000..2cae9b06b916 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Launcher.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.UncheckedIOException; +import java.lang.reflect.Method; +import java.net.URL; +import java.util.Collection; +import java.util.Set; + +import org.springframework.boot.loader.net.protocol.Handlers; + +/** + * Base class for launchers that can start an application with a fully configured + * classpath. + * + * @author Phillip Webb + * @author Dave Syer + * @since 3.2.0 + */ +public abstract class Launcher { + + private static final String JAR_MODE_RUNNER_CLASS_NAME = JarModeRunner.class.getName(); + + /** + * Launch the application. This method is the initial entry point that should be + * called by a subclass {@code public static void main(String[] args)} method. + * @param args the incoming arguments + * @throws Exception if the application fails to launch + */ + protected void launch(String[] args) throws Exception { + if (!isExploded()) { + Handlers.register(); + } + try { + ClassLoader classLoader = createClassLoader(getClassPathUrls()); + String jarMode = System.getProperty("jarmode"); + String mainClassName = hasLength(jarMode) ? JAR_MODE_RUNNER_CLASS_NAME : getMainClass(); + launch(classLoader, mainClassName, args); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + private boolean hasLength(String jarMode) { + return (jarMode != null) && !jarMode.isEmpty(); + } + + /** + * Create a classloader for the specified archives. + * @param urls the classpath URLs + * @return the classloader + * @throws Exception if the classloader cannot be created + */ + protected ClassLoader createClassLoader(Collection urls) throws Exception { + return createClassLoader(urls.toArray(new URL[0])); + } + + private ClassLoader createClassLoader(URL[] urls) { + ClassLoader parent = getClass().getClassLoader(); + return new LaunchedClassLoader(isExploded(), getArchive(), urls, parent); + } + + /** + * Launch the application given the archive file and a fully configured classloader. + * @param classLoader the classloader + * @param mainClassName the main class to run + * @param args the incoming arguments + * @throws Exception if the launch fails + */ + protected void launch(ClassLoader classLoader, String mainClassName, String[] args) throws Exception { + Thread.currentThread().setContextClassLoader(classLoader); + Class mainClass = Class.forName(mainClassName, false, classLoader); + Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); + mainMethod.setAccessible(true); + mainMethod.invoke(null, new Object[] { args }); + } + + /** + * Returns if the launcher is running in an exploded mode. If this method returns + * {@code true} then only regular JARs are supported and the additional URL and + * ClassLoader support infrastructure can be optimized. + * @return if the jar is exploded. + */ + protected boolean isExploded() { + Archive archive = getArchive(); + return (archive != null) && archive.isExploded(); + } + + /** + * Return the archive being launched or {@code null} if there is no archive. + * @return the launched archive + */ + protected abstract Archive getArchive(); + + /** + * Returns the main class that should be launched. + * @return the name of the main class + * @throws Exception if the main class cannot be obtained + */ + protected abstract String getMainClass() throws Exception; + + /** + * Returns the archives that will be used to construct the class path. + * @return the class path archives + * @throws Exception if the class path archives cannot be obtained + */ + protected abstract Set getClassPathUrls() throws Exception; + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java new file mode 100644 index 000000000000..efa9d80c0b3f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java @@ -0,0 +1,611 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; +import java.util.function.Predicate; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.boot.loader.launch.Archive.Entry; +import org.springframework.boot.loader.log.DebugLogger; +import org.springframework.boot.loader.net.protocol.jar.JarUrl; + +/** + * {@link Launcher} for archives with user-configured classpath and main class through a + * properties file. + *

+ * Looks in various places for a properties file to extract loader settings, defaulting to + * {@code loader.properties} either on the current classpath or in the current working + * directory. The name of the properties file can be changed by setting a System property + * {@code loader.config.name} (e.g. {@code -Dloader.config.name=my} will look for + * {@code my.properties}. If that file doesn't exist then tries + * {@code loader.config.location} (with allowed prefixes {@code classpath:} and + * {@code file:} or any valid URL). Once that file is located turns it into Properties and + * extracts optional values (which can also be provided overridden as System properties in + * case the file doesn't exist): + *

    + *
  • {@code loader.path}: a comma-separated list of directories (containing file + * resources and/or nested archives in *.jar or *.zip or archives) or archives to append + * to the classpath. {@code BOOT-INF/classes,BOOT-INF/lib} in the application archive are + * always used
  • + *
  • {@code loader.main}: the main method to delegate execution to once the class loader + * is set up. No default, but will fall back to looking for a {@code Start-Class} in a + * {@code MANIFEST.MF}, if there is one in ${loader.home}/META-INF.
  • + *
+ * + * @author Dave Syer + * @author Janne Valkealahti + * @author Andy Wilkinson + * @author Phillip Webb + * @since 3.2.0 + */ +public class PropertiesLauncher extends Launcher { + + /** + * Properties key for main class. As a manifest entry can also be specified as + * {@code Start-Class}. + */ + public static final String MAIN = "loader.main"; + + /** + * Properties key for classpath entries (directories possibly containing jars or + * jars). Multiple entries can be specified using a comma-separated list. {@code + * BOOT-INF/classes,BOOT-INF/lib} in the application archive are always used. + */ + public static final String PATH = "loader.path"; + + /** + * Properties key for home directory. This is the location of external configuration + * if not on classpath, and also the base path for any relative paths in the + * {@link #PATH loader path}. Defaults to current working directory ( + * ${user.dir}). + */ + public static final String HOME = "loader.home"; + + /** + * Properties key for default command line arguments. These arguments (if present) are + * prepended to the main method arguments before launching. + */ + public static final String ARGS = "loader.args"; + + /** + * Properties key for name of external configuration file (excluding suffix). Defaults + * to "application". Ignored if {@link #CONFIG_LOCATION loader config location} is + * provided instead. + */ + public static final String CONFIG_NAME = "loader.config.name"; + + /** + * Properties key for config file location (including optional classpath:, file: or + * URL prefix). + */ + public static final String CONFIG_LOCATION = "loader.config.location"; + + /** + * Properties key for boolean flag (default false) which, if set, will cause the + * external configuration properties to be copied to System properties (assuming that + * is allowed by Java security). + */ + public static final String SET_SYSTEM_PROPERTIES = "loader.system"; + + private static final URL[] NO_URLS = new URL[0]; + + private static final Pattern WORD_SEPARATOR = Pattern.compile("\\W+"); + + private static final String NESTED_ARCHIVE_SEPARATOR = "!" + File.separator; + + private static final String JAR_FILE_PREFIX = "jar:file:"; + + private static final DebugLogger debug = DebugLogger.get(PropertiesLauncher.class); + + private final Archive archive; + + private final File homeDirectory; + + private final List paths; + + private final Properties properties = new Properties(); + + public PropertiesLauncher() throws Exception { + this(Archive.create(Launcher.class)); + } + + PropertiesLauncher(Archive archive) throws Exception { + this.archive = archive; + this.homeDirectory = getHomeDirectory(); + initializeProperties(); + this.paths = getPaths(); + } + + protected File getHomeDirectory() throws Exception { + return new File(getPropertyWithDefault(HOME, "${user.dir}")); + } + + private void initializeProperties() throws Exception { + List configs = new ArrayList<>(); + if (getProperty(CONFIG_LOCATION) != null) { + configs.add(getProperty(CONFIG_LOCATION)); + } + else { + String[] names = getPropertyWithDefault(CONFIG_NAME, "loader").split(","); + for (String name : names) { + String propertiesFile = name + ".properties"; + configs.add("file:" + this.homeDirectory + "/" + propertiesFile); + configs.add("classpath:" + propertiesFile); + configs.add("classpath:BOOT-INF/classes/" + propertiesFile); + } + } + for (String config : configs) { + try (InputStream resource = getResource(config)) { + if (resource == null) { + debug.log("Not found: %s", config); + continue; + } + debug.log("Found: %s", config); + loadResource(resource); + return; // Load the first one we find + } + } + } + + private InputStream getResource(String config) throws Exception { + if (config.startsWith("classpath:")) { + return getClasspathResource(config.substring("classpath:".length())); + } + config = handleUrl(config); + if (isUrl(config)) { + return getURLResource(config); + } + return getFileResource(config); + } + + private InputStream getClasspathResource(String config) { + config = stripLeadingSlashes(config); + config = "/" + config; + debug.log("Trying classpath: %s", config); + return getClass().getResourceAsStream(config); + } + + private String handleUrl(String path) { + if (path.startsWith("jar:file:") || path.startsWith("file:")) { + path = URLDecoder.decode(path, StandardCharsets.UTF_8); + if (path.startsWith("file:")) { + path = path.substring("file:".length()); + if (path.startsWith("//")) { + path = path.substring(2); + } + } + } + return path; + } + + private boolean isUrl(String config) { + return config.contains("://"); + } + + private InputStream getURLResource(String config) throws Exception { + URL url = new URL(config); + if (exists(url)) { + URLConnection connection = url.openConnection(); + try { + return connection.getInputStream(); + } + catch (IOException ex) { + disconnect(connection); + throw ex; + } + } + return null; + } + + private boolean exists(URL url) throws IOException { + URLConnection connection = url.openConnection(); + try { + connection.setUseCaches(connection.getClass().getSimpleName().startsWith("JNLP")); + if (connection instanceof HttpURLConnection httpConnection) { + httpConnection.setRequestMethod("HEAD"); + int responseCode = httpConnection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + return true; + } + if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return false; + } + } + return (connection.getContentLength() >= 0); + } + finally { + disconnect(connection); + } + } + + private void disconnect(URLConnection connection) { + if (connection instanceof HttpURLConnection httpConnection) { + httpConnection.disconnect(); + } + } + + private InputStream getFileResource(String config) throws Exception { + File file = new File(config); + debug.log("Trying file: %s", config); + return (!file.canRead()) ? null : new FileInputStream(file); + } + + private void loadResource(InputStream resource) throws Exception { + this.properties.load(resource); + resolvePropertyPlaceholders(); + if ("true".equalsIgnoreCase(getProperty(SET_SYSTEM_PROPERTIES))) { + addToSystemProperties(); + } + } + + private void resolvePropertyPlaceholders() { + for (String name : this.properties.stringPropertyNames()) { + String value = this.properties.getProperty(name); + String resolved = SystemPropertyUtils.resolvePlaceholders(this.properties, value); + if (resolved != null) { + this.properties.put(name, resolved); + } + } + } + + private void addToSystemProperties() { + debug.log("Adding resolved properties to System properties"); + for (String name : this.properties.stringPropertyNames()) { + String value = this.properties.getProperty(name); + System.setProperty(name, value); + } + } + + private List getPaths() throws Exception { + String path = getProperty(PATH); + List paths = (path != null) ? parsePathsProperty(path) : Collections.emptyList(); + debug.log("Nested archive paths: %s", this.paths); + return paths; + } + + private List parsePathsProperty(String commaSeparatedPaths) { + List paths = new ArrayList<>(); + for (String path : commaSeparatedPaths.split(",")) { + path = cleanupPath(path); + // "" means the user wants root of archive but not current directory + path = (path.isEmpty()) ? "/" : path; + paths.add(path); + } + if (paths.isEmpty()) { + paths.add("lib"); + } + return paths; + } + + private String cleanupPath(String path) { + path = path.trim(); + // No need for current dir path + if (path.startsWith("./")) { + path = path.substring(2); + } + if (isArchive(path)) { + return path; + } + if (path.endsWith("/*")) { + return path.substring(0, path.length() - 1); + } + // It's a directory + return (!path.endsWith("/") && !path.equals(".")) ? path + "/" : path; + } + + @Override + protected ClassLoader createClassLoader(Collection urls) throws Exception { + String loaderClassName = getProperty("loader.classLoader"); + if (loaderClassName == null) { + return super.createClassLoader(urls); + } + ClassLoader parent = getClass().getClassLoader(); + ClassLoader classLoader = new LaunchedClassLoader(false, urls.toArray(new URL[0]), parent); + debug.log("Classpath for custom loader: %s", urls); + classLoader = wrapWithCustomClassLoader(classLoader, loaderClassName); + debug.log("Using custom class loader: %s", loaderClassName); + return classLoader; + } + + private ClassLoader wrapWithCustomClassLoader(ClassLoader parent, String loaderClassName) throws Exception { + Instantiator instantiator = new Instantiator<>(parent, loaderClassName); + ClassLoader loader = instantiator.declaredConstructor(ClassLoader.class).newInstance(parent); + loader = (loader != null) ? loader + : instantiator.declaredConstructor(URL[].class, ClassLoader.class).newInstance(NO_URLS, parent); + loader = (loader != null) ? loader : instantiator.constructWithoutParameters(); + if (loader != null) { + return loader; + } + throw new IllegalStateException("Unable to create class loader for " + loaderClassName); + } + + @Override + protected Archive getArchive() { + return null; // We don't have a single archive and are not exploded. + } + + @Override + protected String getMainClass() throws Exception { + String mainClass = getProperty(MAIN, "Start-Class"); + if (mainClass == null) { + throw new IllegalStateException("No '%s' or 'Start-Class' specified".formatted(MAIN)); + } + return mainClass; + } + + protected String[] getArgs(String... args) throws Exception { + String loaderArgs = getProperty(ARGS); + return (loaderArgs != null) ? merge(loaderArgs.split("\\s+"), args) : args; + } + + private String[] merge(String[] a1, String[] a2) { + String[] result = new String[a1.length + a2.length]; + System.arraycopy(a1, 0, result, 0, a1.length); + System.arraycopy(a2, 0, result, a1.length, a2.length); + return result; + } + + private String getProperty(String name) throws Exception { + return getProperty(name, null, null); + } + + private String getProperty(String name, String manifestKey) throws Exception { + return getProperty(name, manifestKey, null); + } + + private String getPropertyWithDefault(String name, String defaultValue) throws Exception { + return getProperty(name, null, defaultValue); + } + + private String getProperty(String name, String manifestKey, String defaultValue) throws Exception { + manifestKey = (manifestKey != null) ? manifestKey : toCamelCase(name.replace('.', '-')); + String value = SystemPropertyUtils.getProperty(name); + if (value != null) { + return getResolvedProperty(name, manifestKey, value, "environment"); + } + if (this.properties.containsKey(name)) { + value = this.properties.getProperty(name); + return getResolvedProperty(name, manifestKey, value, "properties"); + } + // Prefer home dir for MANIFEST if there is one + if (this.homeDirectory != null) { + try { + try (ExplodedArchive explodedArchive = new ExplodedArchive(this.homeDirectory)) { + value = getManifestValue(explodedArchive, manifestKey); + if (value != null) { + return getResolvedProperty(name, manifestKey, value, "home directory manifest"); + } + } + } + catch (IllegalStateException ex) { + // Ignore + } + } + // Otherwise try the root archive + value = getManifestValue(this.archive, manifestKey); + if (value != null) { + return getResolvedProperty(name, manifestKey, value, "manifest"); + } + return SystemPropertyUtils.resolvePlaceholders(this.properties, defaultValue); + } + + String getManifestValue(Archive archive, String manifestKey) throws Exception { + Manifest manifest = archive.getManifest(); + return (manifest != null) ? manifest.getMainAttributes().getValue(manifestKey) : null; + } + + private String getResolvedProperty(String name, String manifestKey, String value, String from) { + value = SystemPropertyUtils.resolvePlaceholders(this.properties, value); + String altName = (manifestKey != null && !manifestKey.equals(name)) ? "[%s] ".formatted(manifestKey) : ""; + debug.log("Property '%s'%s from %s: %s", name, altName, from, value); + return value; + + } + + void close() throws Exception { + if (this.archive != null) { + this.archive.close(); + } + } + + public static String toCamelCase(CharSequence string) { + if (string == null) { + return null; + } + StringBuilder result = new StringBuilder(); + Matcher matcher = WORD_SEPARATOR.matcher(string); + int pos = 0; + while (matcher.find()) { + result.append(capitalize(string.subSequence(pos, matcher.end()).toString())); + pos = matcher.end(); + } + result.append(capitalize(string.subSequence(pos, string.length()).toString())); + return result.toString(); + } + + private static String capitalize(String str) { + return Character.toUpperCase(str.charAt(0)) + str.substring(1); + } + + @Override + protected Set getClassPathUrls() throws Exception { + Set urls = new LinkedHashSet<>(); + for (String path : getPaths()) { + path = cleanupPath(handleUrl(path)); + urls.addAll(getClassPathUrlsForPath(path)); + } + urls.addAll(getClassPathUrlsForRoot()); + debug.log("Using class path URLs %s", urls); + return urls; + } + + private Set getClassPathUrlsForPath(String path) throws Exception { + File file = (!isAbsolutePath(path)) ? new File(this.homeDirectory, path) : new File(path); + Set urls = new LinkedHashSet<>(); + if (!"/".equals(path)) { + if (file.isDirectory()) { + try (ExplodedArchive explodedArchive = new ExplodedArchive(file)) { + debug.log("Adding classpath entries from directory %s", file); + urls.add(file.toURI().toURL()); + urls.addAll(explodedArchive.getClassPathUrls(this::isArchive)); + } + } + } + if (!file.getPath().contains(NESTED_ARCHIVE_SEPARATOR) && isArchive(file.getName())) { + debug.log("Adding classpath entries from jar/zip archive %s", path); + urls.add(file.toURI().toURL()); + } + Set nested = getClassPathUrlsForNested(path); + if (!nested.isEmpty()) { + debug.log("Adding classpath entries from nested %s", path); + urls.addAll(nested); + } + return urls; + } + + private Set getClassPathUrlsForNested(String path) throws Exception { + boolean isJustArchive = isArchive(path); + if (!path.equals("/") && path.startsWith("/") + || (this.archive.isExploded() && this.archive.getRootDirectory().equals(this.homeDirectory))) { + return Collections.emptySet(); + } + File file = null; + if (isJustArchive) { + File candidate = new File(this.homeDirectory, path); + if (candidate.exists()) { + file = candidate; + path = ""; + } + } + int separatorIndex = path.indexOf('!'); + if (separatorIndex != -1) { + file = (!path.startsWith(JAR_FILE_PREFIX)) ? new File(this.homeDirectory, path.substring(0, separatorIndex)) + : new File(path.substring(JAR_FILE_PREFIX.length(), separatorIndex)); + path = path.substring(separatorIndex + 1); + path = stripLeadingSlashes(path); + } + if (path.equals("/") || path.equals("./") || path.equals(".")) { + // The prefix for nested jars is actually empty if it's at the root + path = ""; + } + Archive archive = (file != null) ? new JarFileArchive(file) : this.archive; + try { + Set urls = new LinkedHashSet<>(archive.getClassPathUrls(includeByPrefix(path))); + if (!isJustArchive && file != null && path.isEmpty()) { + urls.add(JarUrl.create(file)); + } + return urls; + } + finally { + if (archive != this.archive) { + archive.close(); + } + } + } + + private Set getClassPathUrlsForRoot() throws IOException { + debug.log("Adding classpath entries from root archive %s", this.archive); + return this.archive.getClassPathUrls(JarLauncher::isLibraryFileOrClassesDirectory); + } + + private Predicate includeByPrefix(String prefix) { + return (entry) -> (entry.isDirectory() && entry.name().equals(prefix)) + || (isArchive(entry) && entry.name().startsWith(prefix)); + } + + private boolean isArchive(Entry entry) { + return isArchive(entry.name()); + } + + private boolean isArchive(String name) { + name = name.toLowerCase(Locale.ENGLISH); + return name.endsWith(".jar") || name.endsWith(".zip"); + } + + private boolean isAbsolutePath(String root) { + // Windows contains ":" others start with "/" + return root.contains(":") || root.startsWith("/"); + } + + private String stripLeadingSlashes(String string) { + while (string.startsWith("/")) { + string = string.substring(1); + } + return string; + } + + public static void main(String[] args) throws Exception { + PropertiesLauncher launcher = new PropertiesLauncher(); + args = launcher.getArgs(args); + launcher.launch(args); + } + + /** + * Utility to help instantiate objects. + */ + private record Instantiator(ClassLoader parent, Class type) { + + Instantiator(ClassLoader parent, String className) throws ClassNotFoundException { + this(parent, Class.forName(className, true, parent)); + } + + T constructWithoutParameters() throws Exception { + return declaredConstructor().newInstance(); + } + + Using declaredConstructor(Class... parameterTypes) { + return new Using<>(this, parameterTypes); + } + + private record Using(Instantiator instantiator, Class... parameterTypes) { + + @SuppressWarnings("unchecked") + T newInstance(Object... initargs) throws Exception { + try { + Constructor constructor = this.instantiator.type().getDeclaredConstructor(this.parameterTypes); + constructor.setAccessible(true); + return (T) constructor.newInstance(initargs); + } + catch (NoSuchMethodException ex) { + return null; + } + } + + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.java new file mode 100644 index 000000000000..5efb96f3540c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.java @@ -0,0 +1,151 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; + +/** + * Internal helper class adapted from Spring Framework for resolving placeholders in + * texts. + * + * @author Juergen Hoeller + * @author Rob Harrop + * @author Dave Syer + * @author Phillip Webb + */ +final class SystemPropertyUtils { + + private static final String PLACEHOLDER_PREFIX = "${"; + + private static final String PLACEHOLDER_SUFFIX = "}"; + + private static final String VALUE_SEPARATOR = ":"; + + private static final String SIMPLE_PREFIX = PLACEHOLDER_PREFIX.substring(1); + + private SystemPropertyUtils() { + } + + static String resolvePlaceholders(Properties properties, String text) { + return (text != null) ? parseStringValue(properties, text, text, new HashSet<>()) : null; + } + + private static String parseStringValue(Properties properties, String value, String current, + Set visitedPlaceholders) { + StringBuilder result = new StringBuilder(current); + int startIndex = current.indexOf(PLACEHOLDER_PREFIX); + while (startIndex != -1) { + int endIndex = findPlaceholderEndIndex(result, startIndex); + if (endIndex == -1) { + startIndex = -1; + continue; + } + String placeholder = result.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex); + String originalPlaceholder = placeholder; + if (!visitedPlaceholders.add(originalPlaceholder)) { + throw new IllegalArgumentException( + "Circular placeholder reference '" + originalPlaceholder + "' in property definitions"); + } + placeholder = parseStringValue(properties, value, placeholder, visitedPlaceholders); + String propertyValue = resolvePlaceholder(properties, value, placeholder); + if (propertyValue == null) { + int separatorIndex = placeholder.indexOf(VALUE_SEPARATOR); + if (separatorIndex != -1) { + String actualPlaceholder = placeholder.substring(0, separatorIndex); + String defaultValue = placeholder.substring(separatorIndex + VALUE_SEPARATOR.length()); + propertyValue = resolvePlaceholder(properties, value, actualPlaceholder); + propertyValue = (propertyValue != null) ? propertyValue : defaultValue; + } + } + if (propertyValue != null) { + propertyValue = parseStringValue(properties, value, propertyValue, visitedPlaceholders); + result.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propertyValue); + startIndex = result.indexOf(PLACEHOLDER_PREFIX, startIndex + propertyValue.length()); + } + else { + startIndex = result.indexOf(PLACEHOLDER_PREFIX, endIndex + PLACEHOLDER_SUFFIX.length()); + } + visitedPlaceholders.remove(originalPlaceholder); + } + return result.toString(); + } + + private static String resolvePlaceholder(Properties properties, String text, String placeholderName) { + String propertyValue = getProperty(placeholderName, null, text); + if (propertyValue != null) { + return propertyValue; + } + return (properties != null) ? properties.getProperty(placeholderName) : null; + } + + static String getProperty(String key) { + return getProperty(key, null, ""); + } + + private static String getProperty(String key, String defaultValue, String text) { + try { + String value = System.getProperty(key); + value = (value != null) ? value : System.getenv(key); + value = (value != null) ? value : System.getenv(key.replace('.', '_')); + value = (value != null) ? value : System.getenv(key.toUpperCase(Locale.ENGLISH).replace('.', '_')); + return (value != null) ? value : defaultValue; + } + catch (Throwable ex) { + System.err.println("Could not resolve key '" + key + "' in '" + text + + "' as system property or in environment: " + ex); + return defaultValue; + } + } + + private static int findPlaceholderEndIndex(CharSequence buf, int startIndex) { + int index = startIndex + PLACEHOLDER_PREFIX.length(); + int withinNestedPlaceholder = 0; + while (index < buf.length()) { + if (substringMatch(buf, index, PLACEHOLDER_SUFFIX)) { + if (withinNestedPlaceholder > 0) { + withinNestedPlaceholder--; + index = index + PLACEHOLDER_SUFFIX.length(); + } + else { + return index; + } + } + else if (substringMatch(buf, index, SIMPLE_PREFIX)) { + withinNestedPlaceholder++; + index = index + SIMPLE_PREFIX.length(); + } + else { + index++; + } + } + return -1; + } + + private static boolean substringMatch(CharSequence str, int index, CharSequence substring) { + for (int j = 0; j < substring.length(); j++) { + int i = index + j; + if (i >= str.length() || str.charAt(i) != substring.charAt(j)) { + return false; + } + } + return true; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java new file mode 100644 index 000000000000..38318ba222c8 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +/** + * {@link Launcher} for WAR based archives. This launcher for standard WAR archives. + * Supports dependencies in {@code WEB-INF/lib} as well as {@code WEB-INF/lib-provided}, + * classes are loaded from {@code WEB-INF/classes}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick + * @since 3.2.0 + */ +public class WarLauncher extends ExecutableArchiveLauncher { + + public WarLauncher() throws Exception { + } + + protected WarLauncher(Archive archive) throws Exception { + super(archive); + } + + @Override + public boolean isIncludedOnClassPath(Archive.Entry entry) { + return isLibraryFileOrClassesDirectory(entry); + } + + @Override + protected String getEntryPathPrefix() { + return "WEB-INF/"; + } + + static boolean isLibraryFileOrClassesDirectory(Archive.Entry entry) { + String name = entry.name(); + if (entry.isDirectory()) { + return name.equals("WEB-INF/classes/"); + } + return name.startsWith("WEB-INF/lib/") || name.startsWith("WEB-INF/lib-provided/"); + } + + public static void main(String[] args) throws Exception { + new WarLauncher().launch(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java new file mode 100644 index 000000000000..5c5115bf0e34 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright 2012-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. + */ + +/** + * System that allows self-contained JAR/WAR archives to be launched using + * {@code java -jar}. Archives can include nested packaged dependency JARs (there is no + * need to create shade style jars) and are executed without unpacking. The only + * constraint is that nested JARs must be stored in the archive uncompressed. + * + * @see org.springframework.boot.loader.launch.JarLauncher + * @see org.springframework.boot.loader.launch.WarLauncher + */ +package org.springframework.boot.loader.launch; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java new file mode 100644 index 000000000000..417a9c5a4b7d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java @@ -0,0 +1,152 @@ +/* + * Copyright 2012-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.boot.loader.log; + +/** + * Simple logger class used for {@link System#err} debugging. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public abstract sealed class DebugLogger { + + private static final String ENABLED_PROPERTY = "loader.debug"; + + private static final DebugLogger disabled; + static { + disabled = Boolean.getBoolean(ENABLED_PROPERTY) ? null : new DisabledDebugLogger(); + } + + /** + * Log a message. + * @param message the message to log + */ + public abstract void log(String message); + + /** + * Log a formatted message. + * @param message the message to log + * @param arg1 the first format argument + */ + public abstract void log(String message, Object arg1); + + /** + * Log a formatted message. + * @param message the message to log + * @param arg1 the first format argument + * @param arg2 the second format argument + */ + public abstract void log(String message, Object arg1, Object arg2); + + /** + * Log a formatted message. + * @param message the message to log + * @param arg1 the first format argument + * @param arg2 the second format argument + * @param arg3 the third format argument + */ + public abstract void log(String message, Object arg1, Object arg2, Object arg3); + + /** + * Log a formatted message. + * @param message the message to log + * @param arg1 the first format argument + * @param arg2 the second format argument + * @param arg3 the third format argument + * @param arg4 the fourth format argument + */ + public abstract void log(String message, Object arg1, Object arg2, Object arg3, Object arg4); + + /** + * Get a {@link DebugLogger} to log messages for the given source class. + * @param sourceClass the source class + * @return a {@link DebugLogger} instance + */ + public static DebugLogger get(Class sourceClass) { + return (disabled != null) ? disabled : new SystemErrDebugLogger(sourceClass); + } + + /** + * {@link DebugLogger} used for disabled logging that does nothing. + */ + private static final class DisabledDebugLogger extends DebugLogger { + + @Override + public void log(String message) { + } + + @Override + public void log(String message, Object arg1) { + } + + @Override + public void log(String message, Object arg1, Object arg2) { + } + + @Override + public void log(String message, Object arg1, Object arg2, Object arg3) { + } + + @Override + public void log(String message, Object arg1, Object arg2, Object arg3, Object arg4) { + } + + } + + /** + * {@link DebugLogger} that prints messages to {@link System#err}. + */ + private static final class SystemErrDebugLogger extends DebugLogger { + + private final String prefix; + + SystemErrDebugLogger(Class sourceClass) { + this.prefix = "LOADER: " + sourceClass + " : "; + } + + @Override + public void log(String message) { + print(message); + } + + @Override + public void log(String message, Object arg1) { + print(message.formatted(arg1)); + } + + @Override + public void log(String message, Object arg1, Object arg2) { + print(message.formatted(arg1, arg2)); + } + + @Override + public void log(String message, Object arg1, Object arg2, Object arg3) { + print(message.formatted(arg1, arg2, arg3)); + } + + @Override + public void log(String message, Object arg1, Object arg2, Object arg3, Object arg4) { + print(message.formatted(arg1, arg2, arg3, arg4)); + } + + private void print(String message) { + System.err.println(this.prefix + message); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/package-info.java new file mode 100644 index 000000000000..c94baf14b30a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Debug {@link java.lang.System#err} logging support. + */ +package org.springframework.boot.loader.log; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java new file mode 100644 index 000000000000..781daeaf0f0a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol; + +import java.net.URL; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; + +/** + * Utility used to register loader {@link URLStreamHandler URL handlers}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class Handlers { + + private static final String PROTOCOL_HANDLER_PACKAGES = "java.protocol.handler.pkgs"; + + private static final String PACKAGE = Handlers.class.getPackageName(); + + private Handlers() { + } + + /** + * Register a {@literal 'java.protocol.handler.pkgs'} property so that a + * {@link URLStreamHandler} will be located to deal with jar URLs. + */ + public static void register() { + String packages = System.getProperty(PROTOCOL_HANDLER_PACKAGES, ""); + packages = (!packages.isEmpty() && !packages.contains(PACKAGE)) ? packages + "|" + PACKAGE : PACKAGE; + System.setProperty(PROTOCOL_HANDLER_PACKAGES, packages); + resetCachedUrlHandlers(); + } + + /** + * Reset any cached handlers just in case a jar protocol has already been used. We + * reset the handler by trying to set a null {@link URLStreamHandlerFactory} which + * should have no effect other than clearing the handlers cache. + */ + private static void resetCachedUrlHandlers() { + try { + URL.setURLStreamHandlerFactory(null); + } + catch (Error ex) { + // Ignore + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java new file mode 100644 index 000000000000..209160c3e7ed --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +/** + * Internal utility used by the {@link Handler} to canonicalize paths. This implementation + * should behave the same as the canonicalization functions in + * {@code sun.net.www.protocol.jar.Handler}. + * + * @author Phillip Webb + */ +final class Canonicalizer { + + private Canonicalizer() { + } + + static String canonicalizeAfter(String path, int pos) { + int pathLength = path.length(); + boolean noDotSlash = path.indexOf("./", pos) == -1; + if (pos >= pathLength || (noDotSlash && path.charAt(pathLength - 1) != '.')) { + return path; + } + String before = path.substring(0, pos); + String after = path.substring(pos); + return before + canonicalize(after); + } + + static String canonicalize(String path) { + path = removeEmbeddedSlashDotDotSlash(path); + path = removeEmbeddedSlashDotSlash(path); + path = removeTrailingSlashDotDot(path); + path = removeTrailingSlashDot(path); + return path; + } + + private static String removeEmbeddedSlashDotDotSlash(String path) { + int index; + while ((index = path.indexOf("/../")) >= 0) { + int priorSlash = path.lastIndexOf('/', index - 1); + String after = path.substring(index + 3); + path = (priorSlash >= 0) ? path.substring(0, priorSlash) + after : after; + } + return path; + } + + private static String removeEmbeddedSlashDotSlash(String path) { + int index; + while ((index = path.indexOf("/./")) >= 0) { + String before = path.substring(0, index); + String after = path.substring(index + 2); + path = before + after; + } + return path; + } + + private static String removeTrailingSlashDot(String path) { + return (!path.endsWith("/.")) ? path : path.substring(0, path.length() - 1); + } + + private static String removeTrailingSlashDotDot(String path) { + int index; + while (path.endsWith("/..")) { + index = path.indexOf("/.."); + int priorSlash = path.lastIndexOf('/', index - 1); + path = (priorSlash >= 0) ? path.substring(0, priorSlash + 1) : path.substring(0, index); + } + return path; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java new file mode 100644 index 000000000000..facc6fe6a5d8 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java @@ -0,0 +1,190 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * {@link URLStreamHandler} alternative to {@code sun.net.www.protocol.jar.Handler} with + * optimized support for nested jars. + * + * @author Phillip Webb + * @since 3.2.0 + * @see org.springframework.boot.loader.net.protocol.Handlers + */ +public class Handler extends URLStreamHandler { + + // NOTE: in order to be found as a URL protocol handler, this class must be public, + // must be named Handler and must be in a package ending '.jar' + + private static final String PROTOCOL = "jar"; + + private static final String SEPARATOR = "!/"; + + static final Handler INSTANCE = new Handler(); + + @Override + protected URLConnection openConnection(URL url) throws IOException { + return JarUrlConnection.open(url); + } + + @Override + protected void parseURL(URL url, String spec, int start, int limit) { + if (spec.regionMatches(true, start, "jar:", 0, 4)) { + throw new IllegalStateException("Nested JAR URLs are not supported"); + } + int anchorIndex = spec.indexOf('#', limit); + String path = extractPath(url, spec, start, limit, anchorIndex); + String ref = (anchorIndex != -1) ? spec.substring(anchorIndex + 1) : null; + setURL(url, PROTOCOL, "", -1, null, null, path, null, ref); + } + + private String extractPath(URL url, String spec, int start, int limit, int anchorIndex) { + if (anchorIndex == start) { + return extractAnchorOnlyPath(url); + } + if (spec.length() >= 4 && spec.regionMatches(true, 0, "jar:", 0, 4)) { + return extractAbsolutePath(spec, start, limit); + } + return extractRelativePath(url, spec, start, limit); + } + + private String extractAnchorOnlyPath(URL url) { + return url.getPath(); + } + + private String extractAbsolutePath(String spec, int start, int limit) { + int indexOfSeparator = indexOfSeparator(spec, start, limit); + if (indexOfSeparator == -1) { + throw new IllegalStateException("no !/ in spec"); + } + String innerUrl = spec.substring(start, indexOfSeparator); + assertInnerUrlIsNotMalformed(spec, innerUrl); + return spec.substring(start, limit); + } + + private String extractRelativePath(URL url, String spec, int start, int limit) { + String contextPath = extractContextPath(url, spec, start); + String path = contextPath + spec.substring(start, limit); + return Canonicalizer.canonicalizeAfter(path, indexOfSeparator(path) + 1); + } + + private String extractContextPath(URL url, String spec, int start) { + String contextPath = url.getPath(); + if (spec.regionMatches(false, start, "/", 0, 1)) { + int indexOfContextPathSeparator = indexOfSeparator(contextPath); + if (indexOfContextPathSeparator == -1) { + throw new IllegalStateException("malformed context url:%s: no !/".formatted(url)); + } + return contextPath.substring(0, indexOfContextPathSeparator + 1); + } + int lastSlash = contextPath.lastIndexOf('/'); + if (lastSlash == -1) { + throw new IllegalStateException("malformed context url:%s".formatted(url)); + } + return contextPath.substring(0, lastSlash + 1); + } + + private void assertInnerUrlIsNotMalformed(String spec, String innerUrl) { + if (innerUrl.startsWith("nested:")) { + org.springframework.boot.loader.net.protocol.nested.Handler.assertUrlIsNotMalformed(innerUrl); + return; + } + try { + new URL(innerUrl); + } + catch (MalformedURLException ex) { + throw new IllegalStateException("invalid url: %s (%s)".formatted(spec, ex)); + } + } + + @Override + protected int hashCode(URL url) { + String protocol = url.getProtocol(); + int hash = (protocol != null) ? protocol.hashCode() : 0; + String file = url.getFile(); + int indexOfSeparator = file.indexOf(SEPARATOR); + if (indexOfSeparator == -1) { + return hash + file.hashCode(); + } + String fileWithoutEntry = file.substring(0, indexOfSeparator); + try { + hash += new URL(fileWithoutEntry).hashCode(); + } + catch (MalformedURLException ex) { + hash += fileWithoutEntry.hashCode(); + } + String entry = file.substring(indexOfSeparator + 2); + return hash + entry.hashCode(); + } + + @Override + protected boolean sameFile(URL url1, URL url2) { + if (!url1.getProtocol().equals(PROTOCOL) || !url2.getProtocol().equals(PROTOCOL)) { + return false; + } + String file1 = url1.getFile(); + String file2 = url2.getFile(); + int indexOfSeparator1 = file1.indexOf(SEPARATOR); + int indexOfSeparator2 = file2.indexOf(SEPARATOR); + if (indexOfSeparator1 == -1 || indexOfSeparator2 == -1) { + return super.sameFile(url1, url2); + } + String entry1 = file1.substring(indexOfSeparator1 + 2); + String entry2 = file2.substring(indexOfSeparator2 + 2); + if (!entry1.equals(entry2)) { + return false; + } + try { + URL innerUrl1 = new URL(file1.substring(0, indexOfSeparator1)); + URL innerUrl2 = new URL(file2.substring(0, indexOfSeparator2)); + if (!super.sameFile(innerUrl1, innerUrl2)) { + return false; + } + } + catch (MalformedURLException unused) { + return super.sameFile(url1, url2); + } + return true; + } + + static int indexOfSeparator(String spec) { + return indexOfSeparator(spec, 0, spec.length()); + } + + static int indexOfSeparator(String spec, int start, int limit) { + for (int i = limit - 1; i >= start; i--) { + if (spec.charAt(i) == '!' && (i + 1) < limit && spec.charAt(i + 1) == '/') { + return i; + } + } + return -1; + } + + /** + * Clear any internal caches. + */ + public static void clearCache() { + JarFileUrlKey.clearCache(); + JarUrlConnection.clearCache(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.java new file mode 100644 index 000000000000..e8ce0f503db1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.lang.ref.SoftReference; +import java.net.URL; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Utility to generate a string key from a jar file {@link URL} that can be used as a + * cache key. + * + * @author Phillip Webb + */ +final class JarFileUrlKey { + + private static volatile SoftReference> cache; + + private JarFileUrlKey() { + } + + /** + * Get the {@link JarFileUrlKey} for the given URL. + * @param url the source URL + * @return a {@link JarFileUrlKey} instance + */ + static String get(URL url) { + Map cache = (JarFileUrlKey.cache != null) ? JarFileUrlKey.cache.get() : null; + if (cache == null) { + cache = new ConcurrentHashMap<>(); + JarFileUrlKey.cache = new SoftReference<>(cache); + } + return cache.computeIfAbsent(url, JarFileUrlKey::create); + } + + private static String create(URL url) { + StringBuilder value = new StringBuilder(); + String protocol = url.getProtocol(); + String host = url.getHost(); + int port = (url.getPort() != -1) ? url.getPort() : url.getDefaultPort(); + String file = url.getFile(); + value.append(protocol.toLowerCase()); + value.append(":"); + if (host != null && !host.isEmpty()) { + value.append(host.toLowerCase()); + value.append((port != -1) ? ":" + port : ""); + } + value.append((file != null) ? file : ""); + if ("runtime".equals(url.getRef())) { + value.append("#runtime"); + } + return value.toString(); + } + + static void clearCache() { + cache = null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.java new file mode 100644 index 000000000000..ca2230d36d7b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.jar.JarEntry; + +/** + * Utility class with factory methods that can be used to create JAR URLs. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class JarUrl { + + private JarUrl() { + } + + /** + * Create a new jar URL. + * @param file the jar file + * @return a jar file URL + */ + public static URL create(File file) { + return create(file, (String) null); + } + + /** + * Create a new jar URL. + * @param file the jar file + * @param nestedEntry the nested entry or {@code null} + * @return a jar file URL + */ + public static URL create(File file, JarEntry nestedEntry) { + return create(file, (nestedEntry != null) ? nestedEntry.getName() : null); + } + + /** + * Create a new jar URL. + * @param file the jar file + * @param nestedEntryName the nested entry name or {@code null} + * @return a jar file URL + */ + public static URL create(File file, String nestedEntryName) { + return create(file, nestedEntryName, null); + } + + /** + * Create a new jar URL. + * @param file the jar file + * @param nestedEntryName the nested entry name or {@code null} + * @param path the path within the jar or nested jar + * @return a jar file URL + */ + public static URL create(File file, String nestedEntryName, String path) { + try { + path = (path != null) ? path : ""; + return new URL(null, "jar:" + getJarReference(file, nestedEntryName) + "!/" + path, Handler.INSTANCE); + } + catch (MalformedURLException ex) { + throw new IllegalStateException("Unable to create JarFileArchive URL", ex); + } + } + + private static String getJarReference(File file, String nestedEntryName) { + String jarFilePath = file.toURI().getRawPath().replace("!", "%21"); + return (nestedEntryName != null) ? "nested:" + jarFilePath + "/!" + nestedEntryName : "file:" + jarFilePath; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java new file mode 100644 index 000000000000..bf2aadc218e9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java @@ -0,0 +1,290 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.IOException; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.URLConnection; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.jar.JarFile; + +import org.springframework.boot.loader.jar.NestedJarFile; +import org.springframework.boot.loader.launch.LaunchedClassLoader; + +/** + * {@link URLClassLoader} with optimized support for Jar URLs. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 3.2.0 + */ +public abstract class JarUrlClassLoader extends URLClassLoader { + + private final URL[] urls; + + private final boolean hasJarUrls; + + private final Map jarFiles = new ConcurrentHashMap<>(); + + private final Set undefinablePackages = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + /** + * Create a new {@link LaunchedClassLoader} instance. + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + */ + public JarUrlClassLoader(URL[] urls, ClassLoader parent) { + super(urls, parent); + this.urls = urls; + this.hasJarUrls = Arrays.stream(urls).anyMatch(this::isJarUrl); + } + + @Override + public URL findResource(String name) { + if (!this.hasJarUrls) { + return super.findResource(name); + } + Optimizations.enable(false); + try { + return super.findResource(name); + } + finally { + Optimizations.disable(); + } + } + + @Override + public Enumeration findResources(String name) throws IOException { + if (!this.hasJarUrls) { + return super.findResources(name); + } + Optimizations.enable(false); + try { + return new OptimizedEnumeration(super.findResources(name)); + } + finally { + Optimizations.disable(); + } + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!this.hasJarUrls) { + return super.loadClass(name, resolve); + } + Optimizations.enable(true); + try { + try { + definePackageIfNecessary(name); + } + catch (IllegalArgumentException ex) { + tolerateRaceConditionDueToBeingParallelCapable(ex, name); + } + return super.loadClass(name, resolve); + } + finally { + Optimizations.disable(); + } + } + + /** + * Define a package before a {@code findClass} call is made. This is necessary to + * ensure that the appropriate manifest for nested JARs is associated with the + * package. + * @param className the class name being found + */ + protected final void definePackageIfNecessary(String className) { + if (className.startsWith("java.")) { + return; + } + int lastDot = className.lastIndexOf('.'); + if (lastDot >= 0) { + String packageName = className.substring(0, lastDot); + if (getDefinedPackage(packageName) == null) { + try { + definePackage(className, packageName); + } + catch (IllegalArgumentException ex) { + tolerateRaceConditionDueToBeingParallelCapable(ex, packageName); + } + } + } + } + + private void definePackage(String className, String packageName) { + if (this.undefinablePackages.contains(packageName)) { + return; + } + String packageEntryName = packageName.replace('.', '/') + "/"; + String classEntryName = className.replace('.', '/') + ".class"; + for (URL url : this.urls) { + try { + JarFile jarFile = getJarFile(url); + if (jarFile != null) { + if (hasEntry(jarFile, classEntryName) && hasEntry(jarFile, packageEntryName) + && jarFile.getManifest() != null) { + definePackage(packageName, jarFile.getManifest(), url); + return; + } + } + } + catch (IOException ex) { + // Ignore + } + } + this.undefinablePackages.add(packageName); + } + + private void tolerateRaceConditionDueToBeingParallelCapable(IllegalArgumentException ex, String packageName) + throws AssertionError { + if (getDefinedPackage(packageName) == null) { + // This should never happen as the IllegalArgumentException indicates that the + // package has already been defined and, therefore, getDefinedPackage(name) + // should not have returned null. + throw new AssertionError( + "Package %s has already been defined but it could not be found".formatted(packageName), ex); + } + } + + private boolean hasEntry(JarFile jarFile, String name) { + return (jarFile instanceof NestedJarFile nestedJarFile) ? nestedJarFile.hasEntry(name) + : jarFile.getEntry(name) != null; + } + + private JarFile getJarFile(URL url) throws IOException { + JarFile jarFile = this.jarFiles.get(url); + if (jarFile != null) { + return jarFile; + } + URLConnection connection = url.openConnection(); + if (!(connection instanceof JarURLConnection)) { + return null; + } + connection.setUseCaches(false); + jarFile = ((JarURLConnection) connection).getJarFile(); + synchronized (this.jarFiles) { + JarFile previous = this.jarFiles.putIfAbsent(url, jarFile); + if (previous != null) { + jarFile.close(); + jarFile = previous; + } + } + return jarFile; + } + + /** + * Clear any caches. This method is called reflectively by + * {@code ClearCachesApplicationListener}. + */ + public void clearCache() { + Handler.clearCache(); + org.springframework.boot.loader.net.protocol.nested.Handler.clearCache(); + try { + clearJarFiles(); + } + catch (IOException ex) { + // Ignore + } + for (URL url : this.urls) { + if (isJarUrl(url)) { + clearCache(url); + } + } + } + + private void clearCache(URL url) { + try { + URLConnection connection = url.openConnection(); + if (connection instanceof JarURLConnection jarUrlConnection) { + clearCache(jarUrlConnection); + } + } + catch (IOException ex) { + // Ignore + } + } + + private void clearCache(JarURLConnection connection) throws IOException { + JarFile jarFile = connection.getJarFile(); + if (jarFile instanceof NestedJarFile nestedJarFile) { + nestedJarFile.clearCache(); + } + } + + private boolean isJarUrl(URL url) { + return "jar".equals(url.getProtocol()); + } + + @Override + public void close() throws IOException { + super.close(); + clearJarFiles(); + } + + private void clearJarFiles() throws IOException { + synchronized (this.jarFiles) { + for (JarFile jarFile : this.jarFiles.values()) { + jarFile.close(); + } + this.jarFiles.clear(); + } + } + + /** + * {@link Enumeration} that uses fast connections. + */ + private static class OptimizedEnumeration implements Enumeration { + + private final Enumeration delegate; + + OptimizedEnumeration(Enumeration delegate) { + this.delegate = delegate; + } + + @Override + public boolean hasMoreElements() { + Optimizations.enable(false); + try { + return this.delegate.hasMoreElements(); + } + finally { + Optimizations.disable(); + } + + } + + @Override + public URL nextElement() { + Optimizations.enable(false); + try { + return this.delegate.nextElement(); + } + finally { + Optimizations.disable(); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java new file mode 100644 index 000000000000..edc18cd44eb1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java @@ -0,0 +1,411 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.net.protocol.jar; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.security.Permission; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.springframework.boot.loader.jar.NestedJarFile; +import org.springframework.boot.loader.net.util.UrlDecoder; + +/** + * {@link java.net.JarURLConnection} alternative to + * {@code sun.net.www.protocol.jar.JarURLConnection} with optimized support for nested + * jars. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Rostyslav Dudka + */ +final class JarUrlConnection extends java.net.JarURLConnection { + + static final UrlJarFiles jarFiles = new UrlJarFiles(); + + static final InputStream emptyInputStream = new ByteArrayInputStream(new byte[0]); + + static final FileNotFoundException FILE_NOT_FOUND_EXCEPTION = new FileNotFoundException( + "Jar file or entry not found"); + + private static final URL NOT_FOUND_URL; + + static final JarUrlConnection NOT_FOUND_CONNECTION; + static { + try { + NOT_FOUND_URL = new URL("jar:", null, 0, "nested:!/", new EmptyUrlStreamHandler()); + NOT_FOUND_CONNECTION = new JarUrlConnection(() -> FILE_NOT_FOUND_EXCEPTION); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private final String entryName; + + private final Supplier notFound; + + private JarFile jarFile; + + private URLConnection jarFileConnection; + + private JarEntry jarEntry; + + private String contentType; + + private JarUrlConnection(URL url) throws IOException { + super(url); + this.entryName = getEntryName(); + this.notFound = null; + this.jarFileConnection = getJarFileURL().openConnection(); + this.jarFileConnection.setUseCaches(this.useCaches); + } + + private JarUrlConnection(Supplier notFound) throws IOException { + super(NOT_FOUND_URL); + this.entryName = null; + this.notFound = notFound; + } + + @Override + public JarFile getJarFile() throws IOException { + connect(); + return this.jarFile; + } + + @Override + public JarEntry getJarEntry() throws IOException { + connect(); + return this.jarEntry; + } + + @Override + public int getContentLength() { + long contentLength = getContentLengthLong(); + return (contentLength <= Integer.MAX_VALUE) ? (int) contentLength : -1; + } + + @Override + public long getContentLengthLong() { + try { + connect(); + return (this.jarEntry != null) ? this.jarEntry.getSize() : this.jarFileConnection.getContentLengthLong(); + } + catch (IOException ex) { + return -1; + } + } + + @Override + public String getContentType() { + if (this.contentType == null) { + this.contentType = deduceContentType(); + } + return this.contentType; + } + + private String deduceContentType() { + String type = (this.entryName != null) ? null : "x-java/jar"; + type = (type != null) ? type : deduceContentTypeFromStream(); + type = (type != null) ? type : deduceContentTypeFromEntryName(); + return (type != null) ? type : "content/unknown"; + } + + private String deduceContentTypeFromStream() { + try { + connect(); + try (InputStream in = this.jarFile.getInputStream(this.jarEntry)) { + return guessContentTypeFromStream(new BufferedInputStream(in)); + } + } + catch (IOException ex) { + return null; + } + } + + private String deduceContentTypeFromEntryName() { + return guessContentTypeFromName(this.entryName); + } + + @Override + public long getLastModified() { + return (this.jarFileConnection != null) ? this.jarFileConnection.getLastModified() : super.getLastModified(); + } + + @Override + public String getHeaderField(String name) { + return (this.jarFileConnection != null) ? this.jarFileConnection.getHeaderField(name) : null; + } + + @Override + public Object getContent() throws IOException { + connect(); + return (this.entryName != null) ? super.getContent() : this.jarFile; + } + + @Override + public Permission getPermission() throws IOException { + return this.jarFileConnection.getPermission(); + } + + @Override + public InputStream getInputStream() throws IOException { + if (this.notFound != null) { + throwFileNotFound(); + } + URL jarFileURL = getJarFileURL(); + if (this.entryName == null && !UrlJarFileFactory.isNestedUrl(jarFileURL)) { + throw new IOException("no entry name specified"); + } + if (!getUseCaches() && Optimizations.isEnabled(false) && this.entryName != null) { + JarFile cached = jarFiles.getCached(jarFileURL); + if (cached != null) { + if (cached.getEntry(this.entryName) != null) { + return emptyInputStream; + } + } + } + connect(); + if (this.jarEntry == null) { + if (this.jarFile instanceof NestedJarFile nestedJarFile) { + // In order to work with Tomcat's TLD scanning and WarURLConnection we + // return the raw zip data rather than failing because there is no entry. + // See gh-38047 for details. + return nestedJarFile.getRawZipDataInputStream(); + } + throwFileNotFound(); + } + return new ConnectionInputStream(); + } + + @Override + public boolean getAllowUserInteraction() { + return (this.jarFileConnection != null) && this.jarFileConnection.getAllowUserInteraction(); + } + + @Override + public void setAllowUserInteraction(boolean allowuserinteraction) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setAllowUserInteraction(allowuserinteraction); + } + } + + @Override + public boolean getUseCaches() { + return (this.jarFileConnection == null) || this.jarFileConnection.getUseCaches(); + } + + @Override + public void setUseCaches(boolean usecaches) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setUseCaches(usecaches); + } + } + + @Override + public boolean getDefaultUseCaches() { + return (this.jarFileConnection == null) || this.jarFileConnection.getDefaultUseCaches(); + } + + @Override + public void setDefaultUseCaches(boolean defaultusecaches) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setDefaultUseCaches(defaultusecaches); + } + } + + @Override + public void setIfModifiedSince(long ifModifiedSince) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setIfModifiedSince(ifModifiedSince); + } + } + + @Override + public String getRequestProperty(String key) { + return (this.jarFileConnection != null) ? this.jarFileConnection.getRequestProperty(key) : null; + } + + @Override + public void setRequestProperty(String key, String value) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setRequestProperty(key, value); + } + } + + @Override + public void addRequestProperty(String key, String value) { + if (this.jarFileConnection != null) { + this.jarFileConnection.addRequestProperty(key, value); + } + } + + @Override + public Map> getRequestProperties() { + return (this.jarFileConnection != null) ? this.jarFileConnection.getRequestProperties() + : Collections.emptyMap(); + } + + @Override + public void connect() throws IOException { + if (this.connected) { + return; + } + if (this.notFound != null) { + throwFileNotFound(); + } + boolean useCaches = getUseCaches(); + URL jarFileURL = getJarFileURL(); + if (this.entryName != null && Optimizations.isEnabled()) { + assertCachedJarFileHasEntry(jarFileURL, this.entryName); + } + this.jarFile = jarFiles.getOrCreate(useCaches, jarFileURL); + this.jarEntry = getJarEntry(jarFileURL); + boolean addedToCache = jarFiles.cacheIfAbsent(useCaches, jarFileURL, this.jarFile); + if (addedToCache) { + this.jarFileConnection = jarFiles.reconnect(this.jarFile, this.jarFileConnection); + } + this.connected = true; + } + + /** + * The {@link URLClassLoader} connects often to check if a resource exists, we can + * save some object allocations by using the cached copy if we have one. + * @param jarFileURL the jar file to check + * @param entryName the entry name to check + * @throws FileNotFoundException on a missing entry + */ + private void assertCachedJarFileHasEntry(URL jarFileURL, String entryName) throws FileNotFoundException { + JarFile cachedJarFile = jarFiles.getCached(jarFileURL); + if (cachedJarFile != null && cachedJarFile.getJarEntry(entryName) == null) { + throw FILE_NOT_FOUND_EXCEPTION; + } + } + + private JarEntry getJarEntry(URL jarFileUrl) throws IOException { + if (this.entryName == null) { + return null; + } + JarEntry jarEntry = this.jarFile.getJarEntry(this.entryName); + if (jarEntry == null) { + jarFiles.closeIfNotCached(jarFileUrl, this.jarFile); + throwFileNotFound(); + } + return jarEntry; + } + + private void throwFileNotFound() throws FileNotFoundException { + if (Optimizations.isEnabled()) { + throw FILE_NOT_FOUND_EXCEPTION; + } + if (this.notFound != null) { + throw this.notFound.get(); + } + throw new FileNotFoundException("JAR entry " + this.entryName + " not found in " + this.jarFile.getName()); + } + + static JarUrlConnection open(URL url) throws IOException { + String spec = url.getFile(); + if (spec.startsWith("nested:")) { + int separator = spec.indexOf("!/"); + boolean specHasEntry = (separator != -1) && (separator + 2 != spec.length()); + if (specHasEntry) { + URL jarFileUrl = new URL(spec.substring(0, separator)); + if ("runtime".equals(url.getRef())) { + jarFileUrl = new URL(jarFileUrl, "#runtime"); + } + String entryName = UrlDecoder.decode(spec.substring(separator + 2)); + JarFile jarFile = jarFiles.getOrCreate(true, jarFileUrl); + jarFiles.cacheIfAbsent(true, jarFileUrl, jarFile); + if (!hasEntry(jarFile, entryName)) { + return notFoundConnection(jarFile.getName(), entryName); + } + } + } + return new JarUrlConnection(url); + } + + private static boolean hasEntry(JarFile jarFile, String name) { + return (jarFile instanceof NestedJarFile nestedJarFile) ? nestedJarFile.hasEntry(name) + : jarFile.getEntry(name) != null; + } + + private static JarUrlConnection notFoundConnection(String jarFileName, String entryName) throws IOException { + if (Optimizations.isEnabled()) { + return NOT_FOUND_CONNECTION; + } + return new JarUrlConnection( + () -> new FileNotFoundException("JAR entry " + entryName + " not found in " + jarFileName)); + } + + static void clearCache() { + jarFiles.clearCache(); + } + + /** + * Connection {@link InputStream}. This is not a {@link FilterInputStream} since + * {@link URLClassLoader} often creates streams that it doesn't call and we want to be + * lazy about getting the underlying {@link InputStream}. + */ + class ConnectionInputStream extends LazyDelegatingInputStream { + + @Override + public void close() throws IOException { + try { + super.close(); + } + finally { + if (!getUseCaches()) { + JarUrlConnection.this.jarFile.close(); + } + } + } + + @Override + protected InputStream getDelegateInputStream() throws IOException { + return JarUrlConnection.this.jarFile.getInputStream(JarUrlConnection.this.jarEntry); + } + + } + + /** + * Empty {@link URLStreamHandler} used to prevent the wrong JAR Handler from being + * Instantiated and cached. + */ + private static final class EmptyUrlStreamHandler extends URLStreamHandler { + + @Override + protected URLConnection openConnection(URL url) { + return null; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java new file mode 100644 index 000000000000..95e5cc3c14a7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java @@ -0,0 +1,110 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.IOException; +import java.io.InputStream; + +/** + * {@link InputStream} that delegates lazily to another {@link InputStream}. + * + * @author Phillip Webb + */ +abstract class LazyDelegatingInputStream extends InputStream { + + private volatile InputStream in; + + @Override + public int read() throws IOException { + return in().read(); + } + + @Override + public int read(byte[] b) throws IOException { + return in().read(b); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return in().read(b, off, len); + } + + @Override + public long skip(long n) throws IOException { + return in().skip(n); + } + + @Override + public int available() throws IOException { + return in().available(); + } + + @Override + public boolean markSupported() { + try { + return in().markSupported(); + } + catch (IOException ex) { + return false; + } + } + + @Override + public synchronized void mark(int readlimit) { + try { + in().mark(readlimit); + } + catch (IOException ex) { + // Ignore + } + } + + @Override + public synchronized void reset() throws IOException { + in().reset(); + } + + private InputStream in() throws IOException { + InputStream in = this.in; + if (in == null) { + synchronized (this) { + in = this.in; + if (in == null) { + in = getDelegateInputStream(); + this.in = in; + } + } + } + return in; + } + + @Override + public void close() throws IOException { + InputStream in = this.in; + if (in != null) { + synchronized (this) { + in = this.in; + if (in != null) { + in.close(); + } + } + } + } + + protected abstract InputStream getDelegateInputStream() throws IOException; + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Optimizations.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Optimizations.java new file mode 100644 index 000000000000..138e8e45e086 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Optimizations.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +/** + * {@link ThreadLocal} state for {@link Handler} optimizations. + * + * @author Phillip Webb + */ +final class Optimizations { + + private static final ThreadLocal status = new ThreadLocal<>(); + + private Optimizations() { + } + + static void enable(boolean readContents) { + status.set(readContents); + } + + static void disable() { + status.remove(); + } + + static boolean isEnabled() { + return status.get() != null; + } + + static boolean isEnabled(boolean readContents) { + return Boolean.valueOf(readContents).equals(status.get()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java new file mode 100644 index 000000000000..5c2b100cf201 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.IOException; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.zip.ZipEntry; + +/** + * A {@link JarEntry} returned from a {@link UrlJarFile} or {@link UrlNestedJarFile}. + * + * @author Phillip Webb + */ +final class UrlJarEntry extends JarEntry { + + private final UrlJarManifest manifest; + + private UrlJarEntry(JarEntry entry, UrlJarManifest manifest) { + super(entry); + this.manifest = manifest; + } + + @Override + public Attributes getAttributes() throws IOException { + return this.manifest.getEntryAttributes(this); + } + + static UrlJarEntry of(ZipEntry entry, UrlJarManifest manifest) { + return (entry != null) ? new UrlJarEntry((JarEntry) entry, manifest) : null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java new file mode 100644 index 000000000000..e70af3081a90 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.io.IOException; +import java.util.function.Consumer; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.springframework.boot.loader.ref.Cleaner; + +/** + * A {@link JarFile} subclass returned from a {@link JarUrlConnection}. + * + * @author Phillip Webb + */ +class UrlJarFile extends JarFile { + + private final UrlJarManifest manifest; + + private final Consumer closeAction; + + UrlJarFile(File file, Runtime.Version version, Consumer closeAction) throws IOException { + super(file, true, ZipFile.OPEN_READ, version); + // Registered only for test cleanup since parent class is JarFile + Cleaner.instance.register(this, null); + this.manifest = new UrlJarManifest(super::getManifest); + this.closeAction = closeAction; + } + + @Override + public ZipEntry getEntry(String name) { + return UrlJarEntry.of(super.getEntry(name), this.manifest); + } + + @Override + public Manifest getManifest() throws IOException { + return this.manifest.get(); + } + + @Override + public void close() throws IOException { + if (this.closeAction != null) { + this.closeAction.accept(this); + } + super.close(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java new file mode 100644 index 000000000000..208c5e9478fa --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java @@ -0,0 +1,123 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.Runtime.Version; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.function.Consumer; +import java.util.jar.JarFile; + +import org.springframework.boot.loader.net.protocol.nested.NestedLocation; +import org.springframework.boot.loader.net.util.UrlDecoder; + +/** + * Factory used by {@link UrlJarFiles} to create {@link JarFile} instances. + * + * @author Phillip Webb + * @see UrlJarFile + * @see UrlNestedJarFile + */ +class UrlJarFileFactory { + + /** + * Create a new {@link UrlJarFile} or {@link UrlNestedJarFile} instance. + * @param jarFileUrl the jar file URL + * @param closeAction the action to call when the file is closed + * @return a new {@link JarFile} instance + * @throws IOException on I/O error + */ + JarFile createJarFile(URL jarFileUrl, Consumer closeAction) throws IOException { + Runtime.Version version = getVersion(jarFileUrl); + if (isLocalFileUrl(jarFileUrl)) { + return createJarFileForLocalFile(jarFileUrl, version, closeAction); + } + if (isNestedUrl(jarFileUrl)) { + return createJarFileForNested(jarFileUrl, version, closeAction); + } + return createJarFileForStream(jarFileUrl, version, closeAction); + } + + private Runtime.Version getVersion(URL url) { + // The standard JDK handler uses #runtime to indicate that the runtime version + // should be used. This unfortunately doesn't work for us as + // jdk.internal.loader.URLClassPath only adds the runtime fragment when the URL + // is using the internal JDK handler. We need to flip the default to use + // the runtime version. See gh-38050 + return "base".equals(url.getRef()) ? JarFile.baseVersion() : JarFile.runtimeVersion(); + } + + private boolean isLocalFileUrl(URL url) { + return url.getProtocol().equalsIgnoreCase("file") && isLocal(url.getHost()); + } + + private boolean isLocal(String host) { + return host == null || host.isEmpty() || host.equals("~") || host.equalsIgnoreCase("localhost"); + } + + private JarFile createJarFileForLocalFile(URL url, Runtime.Version version, Consumer closeAction) + throws IOException { + String path = UrlDecoder.decode(url.getPath()); + return new UrlJarFile(new File(path), version, closeAction); + } + + private JarFile createJarFileForNested(URL url, Runtime.Version version, Consumer closeAction) + throws IOException { + NestedLocation location = NestedLocation.fromUrl(url); + return new UrlNestedJarFile(location.path().toFile(), location.nestedEntryName(), version, closeAction); + } + + private JarFile createJarFileForStream(URL url, Version version, Consumer closeAction) throws IOException { + try (InputStream in = url.openStream()) { + return createJarFileForStream(in, version, closeAction); + } + } + + private JarFile createJarFileForStream(InputStream in, Version version, Consumer closeAction) + throws IOException { + Path local = Files.createTempFile("jar_cache", null); + try { + Files.copy(in, local, StandardCopyOption.REPLACE_EXISTING); + JarFile jarFile = new UrlJarFile(local.toFile(), version, closeAction); + local.toFile().deleteOnExit(); + return jarFile; + } + catch (Throwable ex) { + deleteIfPossible(local, ex); + throw ex; + } + } + + private void deleteIfPossible(Path local, Throwable cause) { + try { + Files.delete(local); + } + catch (IOException ex) { + cause.addSuppressed(ex); + } + } + + static boolean isNestedUrl(URL url) { + return url.getProtocol().equalsIgnoreCase("nested"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java new file mode 100644 index 000000000000..18fbd899b769 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java @@ -0,0 +1,217 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.net.protocol.jar; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.util.HashMap; +import java.util.Map; +import java.util.jar.JarFile; + +/** + * Provides access to {@link UrlJarFile} and {@link UrlNestedJarFile} instances taking + * care of caching concerns when necessary. + *

+ * This class is thread-safe and designed to be shared by all {@link JarUrlConnection} + * instances. + * + * @author Phillip Webb + */ +class UrlJarFiles { + + private final UrlJarFileFactory factory; + + private final Cache cache = new Cache(); + + /** + * Create a new {@link UrlJarFiles} instance. + */ + UrlJarFiles() { + this(new UrlJarFileFactory()); + } + + /** + * Create a new {@link UrlJarFiles} instance. + * @param factory the {@link UrlJarFileFactory} to use. + */ + UrlJarFiles(UrlJarFileFactory factory) { + this.factory = factory; + } + + /** + * Get an existing {@link JarFile} instance from the cache, or create a new + * {@link JarFile} instance that can be {@link #cacheIfAbsent(boolean, URL, JarFile) + * cached later}. + * @param useCaches if caches can be used + * @param jarFileUrl the jar file URL + * @return a new or existing {@link JarFile} instance + * @throws IOException on I/O error + */ + JarFile getOrCreate(boolean useCaches, URL jarFileUrl) throws IOException { + if (useCaches) { + JarFile cached = getCached(jarFileUrl); + if (cached != null) { + return cached; + } + } + return this.factory.createJarFile(jarFileUrl, this::onClose); + } + + /** + * Return the cached {@link JarFile} if available. + * @param jarFileUrl the jar file URL + * @return the cached jar or {@code null} + */ + JarFile getCached(URL jarFileUrl) { + return this.cache.get(jarFileUrl); + } + + /** + * Cache the given {@link JarFile} if caching can be used and there is no existing + * entry. + * @param useCaches if caches can be used + * @param jarFileUrl the jar file URL + * @param jarFile the jar file + * @return {@code true} if that file was added to the cache + */ + boolean cacheIfAbsent(boolean useCaches, URL jarFileUrl, JarFile jarFile) { + if (!useCaches) { + return false; + } + return this.cache.putIfAbsent(jarFileUrl, jarFile); + } + + /** + * Close the given {@link JarFile} only if it is not contained in the cache. + * @param jarFileUrl the jar file URL + * @param jarFile the jar file + * @throws IOException on I/O error + */ + void closeIfNotCached(URL jarFileUrl, JarFile jarFile) throws IOException { + JarFile cached = getCached(jarFileUrl); + if (cached != jarFile) { + jarFile.close(); + } + } + + /** + * Reconnect to the {@link JarFile}, returning a replacement {@link URLConnection}. + * @param jarFile the jar file + * @param existingConnection the existing connection + * @return a newly opened connection inhering the same {@code useCaches} value as the + * existing connection + * @throws IOException on I/O error + */ + URLConnection reconnect(JarFile jarFile, URLConnection existingConnection) throws IOException { + Boolean useCaches = (existingConnection != null) ? existingConnection.getUseCaches() : null; + URLConnection connection = openConnection(jarFile); + if (useCaches != null && connection != null) { + connection.setUseCaches(useCaches); + } + return connection; + } + + private URLConnection openConnection(JarFile jarFile) throws IOException { + URL url = this.cache.get(jarFile); + return (url != null) ? url.openConnection() : null; + } + + private void onClose(JarFile jarFile) { + this.cache.remove(jarFile); + } + + void clearCache() { + this.cache.clear(); + } + + /** + * Internal cache. + */ + private static final class Cache { + + private final Map jarFileUrlToJarFile = new HashMap<>(); + + private final Map jarFileToJarFileUrl = new HashMap<>(); + + /** + * Get a {@link JarFile} from the cache given a jar file URL. + * @param jarFileUrl the jar file URL + * @return the cached {@link JarFile} or {@code null} + */ + JarFile get(URL jarFileUrl) { + String urlKey = JarFileUrlKey.get(jarFileUrl); + synchronized (this) { + return this.jarFileUrlToJarFile.get(urlKey); + } + } + + /** + * Get a jar file URL from the cache given a jar file. + * @param jarFile the jar file + * @return the cached {@link URL} or {@code null} + */ + URL get(JarFile jarFile) { + synchronized (this) { + return this.jarFileToJarFileUrl.get(jarFile); + } + } + + /** + * Put the given jar file URL and jar file into the cache if they aren't already + * there. + * @param jarFileUrl the jar file URL + * @param jarFile the jar file + * @return {@code true} if the items were added to the cache or {@code false} if + * they were already there + */ + boolean putIfAbsent(URL jarFileUrl, JarFile jarFile) { + String urlKey = JarFileUrlKey.get(jarFileUrl); + synchronized (this) { + JarFile cached = this.jarFileUrlToJarFile.get(urlKey); + if (cached == null) { + this.jarFileUrlToJarFile.put(urlKey, jarFile); + this.jarFileToJarFileUrl.put(jarFile, jarFileUrl); + return true; + } + return false; + } + } + + /** + * Remove the given jar and any related URL file from the cache. + * @param jarFile the jar file to remove + */ + void remove(JarFile jarFile) { + synchronized (this) { + URL removedUrl = this.jarFileToJarFileUrl.remove(jarFile); + if (removedUrl != null) { + this.jarFileUrlToJarFile.remove(JarFileUrlKey.get(removedUrl)); + } + } + } + + void clear() { + synchronized (this) { + this.jarFileToJarFileUrl.clear(); + this.jarFileUrlToJarFile.clear(); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.java new file mode 100644 index 000000000000..70c372855d50 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.IOException; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; + +/** + * Provides access {@link Manifest} content that can be safely returned from + * {@link UrlJarFile} or {@link UrlNestedJarFile}. + * + * @author Phillip Webb + */ +class UrlJarManifest { + + private static final Object NONE = new Object(); + + private final ManifestSupplier supplier; + + private volatile Object supplied; + + UrlJarManifest(ManifestSupplier supplier) { + this.supplier = supplier; + } + + Manifest get() throws IOException { + Manifest manifest = supply(); + if (manifest == null) { + return null; + } + Manifest copy = new Manifest(); + copy.getMainAttributes().putAll((Map) manifest.getMainAttributes().clone()); + manifest.getEntries().forEach((key, value) -> copy.getEntries().put(key, cloneAttributes(value))); + return copy; + } + + Attributes getEntryAttributes(JarEntry entry) throws IOException { + Manifest manifest = supply(); + if (manifest == null) { + return null; + } + Attributes attributes = manifest.getEntries().get(entry.getName()); + return cloneAttributes(attributes); + } + + private Attributes cloneAttributes(Attributes attributes) { + return (attributes != null) ? (Attributes) attributes.clone() : null; + } + + private Manifest supply() throws IOException { + Object supplied = this.supplied; + if (supplied == null) { + supplied = this.supplier.getManifest(); + this.supplied = (supplied != null) ? supplied : NONE; + } + return (supplied != NONE) ? (Manifest) supplied : null; + } + + /** + * Interface used to supply the actual manifest. + */ + @FunctionalInterface + interface ManifestSupplier { + + Manifest getManifest() throws IOException; + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java new file mode 100644 index 000000000000..1f9f62b2a32c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.io.IOException; +import java.lang.Runtime.Version; +import java.util.function.Consumer; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.jar.NestedJarFile; + +/** + * {@link NestedJarFile} subclass returned from a {@link JarUrlConnection}. + * + * @author Phillip Webb + */ +class UrlNestedJarFile extends NestedJarFile { + + private final UrlJarManifest manifest; + + private final Consumer closeAction; + + UrlNestedJarFile(File file, String nestedEntryName, Version version, Consumer closeAction) + throws IOException { + super(file, nestedEntryName, version); + this.manifest = new UrlJarManifest(super::getManifest); + this.closeAction = closeAction; + } + + @Override + public Manifest getManifest() throws IOException { + return this.manifest.get(); + } + + @Override + public JarEntry getEntry(String name) { + return UrlJarEntry.of(super.getEntry(name), this.manifest); + } + + @Override + public void close() throws IOException { + if (this.closeAction != null) { + this.closeAction.accept(this); + } + super.close(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/package-info.java new file mode 100644 index 000000000000..980f4230226f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-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. + */ + +/** + * JAR URL support, including support for nested jars. + * + * @see org.springframework.boot.loader.net.protocol.jar.JarUrl + * @see org.springframework.boot.loader.net.protocol.jar.Handler + */ +package org.springframework.boot.loader.net.protocol.jar; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java new file mode 100644 index 000000000000..0a05596e2831 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.nested; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * {@link URLStreamHandler} to support {@code nested:} URLs. See {@link NestedLocation} + * for details of the URL format. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public class Handler extends URLStreamHandler { + + // NOTE: in order to be found as a URL protocol handler, this class must be public, + // must be named Handler and must be in a package ending '.nested' + + private static final String PREFIX = "nested:"; + + @Override + protected URLConnection openConnection(URL url) throws IOException { + return new NestedUrlConnection(url); + } + + /** + * Assert that the specified URL is a valid "nested" URL. + * @param url the URL to check + */ + public static void assertUrlIsNotMalformed(String url) { + if (url == null || !url.startsWith(PREFIX)) { + throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol"); + } + NestedLocation.parse(url.substring(PREFIX.length())); + } + + /** + * Clear any internal caches. + */ + public static void clearCache() { + NestedLocation.clearCache(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java new file mode 100644 index 000000000000..94e513b64036 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java @@ -0,0 +1,123 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.net.protocol.nested; + +import java.io.File; +import java.net.URI; +import java.net.URL; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.boot.loader.net.util.UrlDecoder; + +/** + * A location obtained from a {@code nested:} {@link URL} consisting of a jar file and an + * optional nested entry. + *

+ * The syntax of a nested JAR URL is:

+ * nestedjar:<path>/!{entry}
+ * 
+ *

+ * for example: + *

+ * {@code nested:/home/example/my.jar/!BOOT-INF/lib/my-nested.jar} + *

+ * or: + *

+ * {@code nested:/home/example/my.jar/!BOOT-INF/classes/} + *

+ * The path must refer to a jar file on the file system. The entry refers to either an + * uncompressed entry that contains the nested jar, or a directory entry. The entry must + * not start with a {@code '/'}. + * + * @param path the path to the zip that contains the nested entry + * @param nestedEntryName the nested entry name + * @author Phillip Webb + * @author Andy Wilkinson + * @since 3.2.0 + */ +public record NestedLocation(Path path, String nestedEntryName) { + + private static final Map cache = new ConcurrentHashMap<>(); + + public NestedLocation(Path path, String nestedEntryName) { + if (path == null) { + throw new IllegalArgumentException("'path' must not be null"); + } + this.path = path; + this.nestedEntryName = (nestedEntryName != null && !nestedEntryName.isEmpty()) ? nestedEntryName : null; + } + + /** + * Create a new {@link NestedLocation} from the given URL. + * @param url the nested URL + * @return a new {@link NestedLocation} instance + * @throws IllegalArgumentException if the URL is not valid + */ + public static NestedLocation fromUrl(URL url) { + if (url == null || !"nested".equalsIgnoreCase(url.getProtocol())) { + throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol"); + } + return parse(UrlDecoder.decode(url.toString().substring(7))); + } + + /** + * Create a new {@link NestedLocation} from the given URI. + * @param uri the nested URI + * @return a new {@link NestedLocation} instance + * @throws IllegalArgumentException if the URI is not valid + */ + public static NestedLocation fromUri(URI uri) { + if (uri == null || !"nested".equalsIgnoreCase(uri.getScheme())) { + throw new IllegalArgumentException("'uri' must not be null and must use 'nested' scheme"); + } + return parse(uri.getSchemeSpecificPart()); + } + + static NestedLocation parse(String path) { + if (path == null || path.isEmpty()) { + throw new IllegalArgumentException("'path' must not be empty"); + } + int index = path.lastIndexOf("/!"); + return cache.computeIfAbsent(path, (l) -> create(index, l)); + } + + private static NestedLocation create(int index, String location) { + String locationPath = (index != -1) ? location.substring(0, index) : location; + if (isWindows() && !isUncPath(location)) { + while (locationPath.startsWith("/")) { + locationPath = locationPath.substring(1, locationPath.length()); + } + } + String nestedEntryName = (index != -1) ? location.substring(index + 2) : null; + return new NestedLocation((!locationPath.isEmpty()) ? Path.of(locationPath) : null, nestedEntryName); + } + + private static boolean isWindows() { + return File.separatorChar == '\\'; + } + + private static boolean isUncPath(String input) { + return !input.contains(":"); + } + + static void clearCache() { + cache.clear(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java new file mode 100644 index 000000000000..0409fe6e3d99 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java @@ -0,0 +1,231 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.nested; + +import java.io.File; +import java.io.FilePermission; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.ref.Cleaner.Cleanable; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.security.Permission; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.springframework.boot.loader.ref.Cleaner; + +/** + * {@link URLConnection} to support {@code nested:} URLs. See {@link NestedLocation} for + * details of the URL format. + * + * @author Phillip Webb + */ +class NestedUrlConnection extends URLConnection { + + private static final DateTimeFormatter RFC_1123_DATE_TIME = DateTimeFormatter.RFC_1123_DATE_TIME + .withZone(ZoneId.of("GMT")); + + private static final String CONTENT_TYPE = "x-java/jar"; + + private final NestedUrlConnectionResources resources; + + private final Cleanable cleanup; + + private long lastModified = -1; + + private FilePermission permission; + + private Map> headerFields; + + NestedUrlConnection(URL url) throws MalformedURLException { + this(url, Cleaner.instance); + } + + NestedUrlConnection(URL url, Cleaner cleaner) throws MalformedURLException { + super(url); + NestedLocation location = parseNestedLocation(url); + this.resources = new NestedUrlConnectionResources(location); + this.cleanup = cleaner.register(this, this.resources); + } + + private NestedLocation parseNestedLocation(URL url) throws MalformedURLException { + try { + return NestedLocation.parse(url.getPath()); + } + catch (IllegalArgumentException ex) { + throw new MalformedURLException(ex.getMessage()); + } + } + + @Override + public String getHeaderField(String name) { + List values = getHeaderFields().get(name); + return (values != null && !values.isEmpty()) ? values.get(0) : null; + } + + @Override + public String getHeaderField(int n) { + Entry> entry = getHeaderEntry(n); + List values = (entry != null) ? entry.getValue() : null; + return (values != null && !values.isEmpty()) ? values.get(0) : null; + } + + @Override + public String getHeaderFieldKey(int n) { + Entry> entry = getHeaderEntry(n); + return (entry != null) ? entry.getKey() : null; + } + + private Entry> getHeaderEntry(int n) { + Iterator>> iterator = getHeaderFields().entrySet().iterator(); + Entry> entry = null; + for (int i = 0; i < n; i++) { + entry = (!iterator.hasNext()) ? null : iterator.next(); + } + return entry; + } + + @Override + public Map> getHeaderFields() { + try { + connect(); + } + catch (IOException ex) { + return Collections.emptyMap(); + } + Map> headerFields = this.headerFields; + if (headerFields == null) { + headerFields = new LinkedHashMap<>(); + long contentLength = getContentLengthLong(); + long lastModified = getLastModified(); + if (contentLength > 0) { + headerFields.put("content-length", List.of(String.valueOf(contentLength))); + } + if (getLastModified() > 0) { + headerFields.put("last-modified", + List.of(RFC_1123_DATE_TIME.format(Instant.ofEpochMilli(lastModified)))); + } + headerFields = Collections.unmodifiableMap(headerFields); + this.headerFields = headerFields; + } + return headerFields; + } + + @Override + public int getContentLength() { + long contentLength = getContentLengthLong(); + return (contentLength <= Integer.MAX_VALUE) ? (int) contentLength : -1; + } + + @Override + public long getContentLengthLong() { + try { + connect(); + return this.resources.getContentLength(); + } + catch (IOException ex) { + return -1; + } + } + + @Override + public String getContentType() { + return CONTENT_TYPE; + } + + @Override + public long getLastModified() { + if (this.lastModified == -1) { + try { + this.lastModified = Files.getLastModifiedTime(this.resources.getLocation().path()).toMillis(); + } + catch (IOException ex) { + this.lastModified = 0; + } + } + return this.lastModified; + } + + @Override + public Permission getPermission() throws IOException { + if (this.permission == null) { + File file = this.resources.getLocation().path().toFile(); + this.permission = new FilePermission(file.getCanonicalPath(), "read"); + } + return this.permission; + } + + @Override + public InputStream getInputStream() throws IOException { + connect(); + return new ConnectionInputStream(this.resources.getInputStream()); + } + + @Override + public void connect() throws IOException { + if (this.connected) { + return; + } + this.resources.connect(); + this.connected = true; + } + + /** + * Connection {@link InputStream}. + */ + class ConnectionInputStream extends FilterInputStream { + + private volatile boolean closing; + + ConnectionInputStream(InputStream in) { + super(in); + } + + @Override + public void close() throws IOException { + if (this.closing) { + return; + } + this.closing = true; + try { + super.close(); + } + finally { + try { + NestedUrlConnection.this.cleanup.clean(); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java new file mode 100644 index 000000000000..5806c2392882 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.nested; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; + +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.zip.CloseableDataBlock; +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Resources created managed and cleaned by a {@link NestedUrlConnection} instance and + * suitable for registration with a {@link Cleaner}. + * + * @author Phillip Webb + */ +class NestedUrlConnectionResources implements Runnable { + + private final NestedLocation location; + + private volatile ZipContent zipContent; + + private volatile long size = -1; + + private volatile InputStream inputStream; + + NestedUrlConnectionResources(NestedLocation location) { + this.location = location; + } + + NestedLocation getLocation() { + return this.location; + } + + void connect() throws IOException { + synchronized (this) { + if (this.zipContent == null) { + this.zipContent = ZipContent.open(this.location.path(), this.location.nestedEntryName()); + try { + connectData(); + } + catch (IOException | RuntimeException ex) { + this.zipContent.close(); + this.zipContent = null; + throw ex; + } + } + } + } + + private void connectData() throws IOException { + CloseableDataBlock data = this.zipContent.openRawZipData(); + try { + this.size = data.size(); + this.inputStream = data.asInputStream(); + } + catch (IOException | RuntimeException ex) { + data.close(); + } + } + + InputStream getInputStream() throws IOException { + synchronized (this) { + if (this.inputStream == null) { + throw new IOException("Nested location not found " + this.location); + } + return this.inputStream; + } + } + + long getContentLength() { + return this.size; + } + + @Override + public void run() { + releaseAll(); + } + + private void releaseAll() { + synchronized (this) { + if (this.zipContent != null) { + IOException exceptionChain = null; + try { + this.inputStream.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + try { + this.zipContent.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + this.size = -1; + if (exceptionChain != null) { + throw new UncheckedIOException(exceptionChain); + } + } + } + } + + private IOException addToExceptionChain(IOException exceptionChain, IOException ex) { + if (exceptionChain != null) { + exceptionChain.addSuppressed(ex); + return exceptionChain; + } + return ex; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/package-info.java new file mode 100644 index 000000000000..1e0426e2a976 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-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. + */ + +/** + * Nested URL support. + * + * @see org.springframework.boot.loader.net.protocol.nested.NestedLocation + * @see org.springframework.boot.loader.net.protocol.nested.Handler + */ +package org.springframework.boot.loader.net.protocol.nested; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/package-info.java new file mode 100644 index 000000000000..fa1a2cfb7a4c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * {@link java.net.URL} protocol support. + */ +package org.springframework.boot.loader.net.protocol; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java new file mode 100644 index 000000000000..999c55140e04 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java @@ -0,0 +1,109 @@ +/* + * Copyright 2012-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.boot.loader.net.util; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +/** + * Utility to decode URL strings. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class UrlDecoder { + + private UrlDecoder() { + } + + /** + * Decode the given string by decoding URL {@code '%'} escapes. This method should be + * identical in behavior to the {@code decode} method in the internal + * {@code sun.net.www.ParseUtil} JDK class. + * @param string the string to decode + * @return the decoded string + */ + public static String decode(String string) { + int length = string.length(); + if ((length == 0) || (string.indexOf('%') < 0)) { + return string; + } + StringBuilder result = new StringBuilder(length); + ByteBuffer byteBuffer = ByteBuffer.allocate(length); + CharBuffer charBuffer = CharBuffer.allocate(length); + CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + int index = 0; + while (index < length) { + char ch = string.charAt(index); + if (ch != '%') { + result.append(ch); + if (index + 1 >= length) { + return result.toString(); + } + index++; + continue; + } + index = fillByteBuffer(byteBuffer, string, index, length); + decodeToCharBuffer(byteBuffer, charBuffer, decoder); + result.append(charBuffer.flip()); + + } + return result.toString(); + } + + private static int fillByteBuffer(ByteBuffer byteBuffer, String string, int index, int length) { + byteBuffer.clear(); + while (true) { + byteBuffer.put(unescape(string, index)); + index += 3; + if (index >= length || string.charAt(index) != '%') { + break; + } + } + byteBuffer.flip(); + return index; + } + + private static byte unescape(String string, int index) { + try { + return (byte) Integer.parseInt(string, index + 1, index + 3, 16); + } + catch (NumberFormatException ex) { + throw new IllegalArgumentException(); + } + } + + private static void decodeToCharBuffer(ByteBuffer byteBuffer, CharBuffer charBuffer, CharsetDecoder decoder) { + decoder.reset(); + charBuffer.clear(); + assertNoError(decoder.decode(byteBuffer, charBuffer, true)); + assertNoError(decoder.flush(charBuffer)); + } + + private static void assertNoError(CoderResult result) { + if (result.isError()) { + throw new IllegalArgumentException("Error decoding percent encoded characters"); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java new file mode 100644 index 000000000000..231571bee07a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Net utilities. + */ +package org.springframework.boot.loader.net.util; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedByteChannel.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedByteChannel.java new file mode 100644 index 000000000000..3e51924ed350 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedByteChannel.java @@ -0,0 +1,181 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.ref.Cleaner.Cleanable; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.NonWritableChannelException; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Path; + +import org.springframework.boot.loader.net.protocol.nested.NestedLocation; +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.zip.CloseableDataBlock; +import org.springframework.boot.loader.zip.DataBlock; +import org.springframework.boot.loader.zip.ZipContent; + +/** + * {@link SeekableByteChannel} implementation for {@link NestedLocation nested} jar files. + * + * @author Phillip Webb + * @see NestedFileSystemProvider + */ +class NestedByteChannel implements SeekableByteChannel { + + private long position; + + private final Resources resources; + + private final Cleanable cleanup; + + private final long size; + + private volatile boolean closed; + + NestedByteChannel(Path path, String nestedEntryName) throws IOException { + this(path, nestedEntryName, Cleaner.instance); + } + + NestedByteChannel(Path path, String nestedEntryName, Cleaner cleaner) throws IOException { + this.resources = new Resources(path, nestedEntryName); + this.cleanup = cleaner.register(this, this.resources); + this.size = this.resources.getData().size(); + } + + @Override + public boolean isOpen() { + return !this.closed; + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + try { + this.cleanup.clean(); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + @Override + public int read(ByteBuffer dst) throws IOException { + assertNotClosed(); + int total = 0; + while (dst.remaining() > 0) { + int count = this.resources.getData().read(dst, this.position); + if (count <= 0) { + return (total != 0) ? 0 : count; + } + total += count; + this.position += count; + } + return total; + } + + @Override + public int write(ByteBuffer src) throws IOException { + throw new NonWritableChannelException(); + } + + @Override + public long position() throws IOException { + assertNotClosed(); + return this.position; + } + + @Override + public SeekableByteChannel position(long position) throws IOException { + assertNotClosed(); + if (position < 0 || position >= this.size) { + throw new IllegalArgumentException("Position must be in bounds"); + } + this.position = position; + return this; + } + + @Override + public long size() throws IOException { + assertNotClosed(); + return this.size; + } + + @Override + public SeekableByteChannel truncate(long size) throws IOException { + throw new NonWritableChannelException(); + } + + private void assertNotClosed() throws ClosedChannelException { + if (this.closed) { + throw new ClosedChannelException(); + } + } + + /** + * Resources used by the channel and suitable for registration with a {@link Cleaner}. + */ + static class Resources implements Runnable { + + private final ZipContent zipContent; + + private final CloseableDataBlock data; + + Resources(Path path, String nestedEntryName) throws IOException { + this.zipContent = ZipContent.open(path, nestedEntryName); + this.data = this.zipContent.openRawZipData(); + } + + DataBlock getData() { + return this.data; + } + + @Override + public void run() { + releaseAll(); + } + + private void releaseAll() { + IOException exception = null; + try { + this.data.close(); + } + catch (IOException ex) { + exception = ex; + } + try { + this.zipContent.close(); + } + catch (IOException ex) { + if (exception != null) { + ex.addSuppressed(exception); + } + exception = ex; + } + if (exception != null) { + throw new UncheckedIOException(exception); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileStore.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileStore.java new file mode 100644 index 000000000000..c5a7edb559eb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileStore.java @@ -0,0 +1,106 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.attribute.FileAttributeView; +import java.nio.file.attribute.FileStoreAttributeView; + +import org.springframework.boot.loader.net.protocol.nested.NestedLocation; + +/** + * {@link FileStore} implementation for {@link NestedLocation nested} jar files. + * + * @author Phillip Webb + * @see NestedFileSystemProvider + */ +class NestedFileStore extends FileStore { + + private final NestedFileSystem fileSystem; + + NestedFileStore(NestedFileSystem fileSystem) { + this.fileSystem = fileSystem; + } + + @Override + public String name() { + return this.fileSystem.toString(); + } + + @Override + public String type() { + return "nestedfs"; + } + + @Override + public boolean isReadOnly() { + return this.fileSystem.isReadOnly(); + } + + @Override + public long getTotalSpace() throws IOException { + return 0; + } + + @Override + public long getUsableSpace() throws IOException { + return 0; + } + + @Override + public long getUnallocatedSpace() throws IOException { + return 0; + } + + @Override + public boolean supportsFileAttributeView(Class type) { + return getJarPathFileStore().supportsFileAttributeView(type); + } + + @Override + public boolean supportsFileAttributeView(String name) { + return getJarPathFileStore().supportsFileAttributeView(name); + } + + @Override + public V getFileStoreAttributeView(Class type) { + return getJarPathFileStore().getFileStoreAttributeView(type); + } + + @Override + public Object getAttribute(String attribute) throws IOException { + try { + return getJarPathFileStore().getAttribute(attribute); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + protected FileStore getJarPathFileStore() { + try { + return Files.getFileStore(this.fileSystem.getJarPath()); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystem.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystem.java new file mode 100644 index 000000000000..31dd3905f1dd --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystem.java @@ -0,0 +1,229 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.nio.file; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.ClosedFileSystemException; +import java.nio.file.FileStore; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.WatchService; +import java.nio.file.attribute.UserPrincipalLookupService; +import java.nio.file.spi.FileSystemProvider; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.loader.net.protocol.nested.NestedLocation; + +/** + * {@link FileSystem} implementation for {@link NestedLocation nested} jar files. + * + * @author Phillip Webb + * @see NestedFileSystemProvider + */ +class NestedFileSystem extends FileSystem { + + private static final Set SUPPORTED_FILE_ATTRIBUTE_VIEWS = Set.of("basic"); + + private static final String FILE_SYSTEMS_CLASS_NAME = FileSystems.class.getName(); + + private static final Object EXISTING_FILE_SYSTEM = new Object(); + + private final NestedFileSystemProvider provider; + + private final Path jarPath; + + private volatile boolean closed; + + private final Map zipFileSystems = new HashMap<>(); + + NestedFileSystem(NestedFileSystemProvider provider, Path jarPath) { + if (provider == null || jarPath == null) { + throw new IllegalArgumentException("Provider and JarPath must not be null"); + } + this.provider = provider; + this.jarPath = jarPath; + } + + void installZipFileSystemIfNecessary(String nestedEntryName) { + try { + boolean seen; + synchronized (this.zipFileSystems) { + seen = this.zipFileSystems.putIfAbsent(nestedEntryName, EXISTING_FILE_SYSTEM) != null; + } + if (!seen) { + URI uri = new URI("jar:nested:" + this.jarPath.toUri().getPath() + "/!" + nestedEntryName); + if (!hasFileSystem(uri)) { + FileSystem zipFileSystem = FileSystems.newFileSystem(uri, Collections.emptyMap()); + synchronized (this.zipFileSystems) { + this.zipFileSystems.put(nestedEntryName, zipFileSystem); + } + } + } + } + catch (Exception ex) { + // Ignore + } + } + + private boolean hasFileSystem(URI uri) { + try { + FileSystems.getFileSystem(uri); + return true; + } + catch (FileSystemNotFoundException ex) { + return isCreatingNewFileSystem(); + } + } + + private boolean isCreatingNewFileSystem() { + StackTraceElement[] stack = Thread.currentThread().getStackTrace(); + if (stack != null) { + for (StackTraceElement element : stack) { + if (FILE_SYSTEMS_CLASS_NAME.equals(element.getClassName())) { + return "newFileSystem".equals(element.getMethodName()); + } + } + } + return false; + } + + @Override + public FileSystemProvider provider() { + return this.provider; + } + + Path getJarPath() { + return this.jarPath; + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + synchronized (this.zipFileSystems) { + this.zipFileSystems.values() + .stream() + .filter(FileSystem.class::isInstance) + .map(FileSystem.class::cast) + .forEach(this::closeZipFileSystem); + } + this.provider.removeFileSystem(this); + } + + private void closeZipFileSystem(FileSystem zipFileSystem) { + try { + zipFileSystem.close(); + } + catch (Exception ex) { + } + } + + @Override + public boolean isOpen() { + return !this.closed; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String getSeparator() { + return "/!"; + } + + @Override + public Iterable getRootDirectories() { + assertNotClosed(); + return Collections.emptySet(); + } + + @Override + public Iterable getFileStores() { + assertNotClosed(); + return Collections.emptySet(); + } + + @Override + public Set supportedFileAttributeViews() { + assertNotClosed(); + return SUPPORTED_FILE_ATTRIBUTE_VIEWS; + } + + @Override + public Path getPath(String first, String... more) { + assertNotClosed(); + if (more.length != 0) { + throw new IllegalArgumentException("Nested paths must contain a single element"); + } + return new NestedPath(this, first); + } + + @Override + public PathMatcher getPathMatcher(String syntaxAndPattern) { + throw new UnsupportedOperationException("Nested paths do not support path matchers"); + } + + @Override + public UserPrincipalLookupService getUserPrincipalLookupService() { + throw new UnsupportedOperationException("Nested paths do not have a user principal lookup service"); + } + + @Override + public WatchService newWatchService() throws IOException { + throw new UnsupportedOperationException("Nested paths do not support the WatchService"); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + NestedFileSystem other = (NestedFileSystem) obj; + return this.jarPath.equals(other.jarPath); + } + + @Override + public int hashCode() { + return this.jarPath.hashCode(); + } + + @Override + public String toString() { + return this.jarPath.toAbsolutePath().toString(); + } + + private void assertNotClosed() { + if (this.closed) { + throw new ClosedFileSystemException(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystemProvider.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystemProvider.java new file mode 100644 index 000000000000..ca136748df8c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedFileSystemProvider.java @@ -0,0 +1,186 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.IOException; +import java.net.URI; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.AccessMode; +import java.nio.file.CopyOption; +import java.nio.file.DirectoryStream; +import java.nio.file.DirectoryStream.Filter; +import java.nio.file.FileStore; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemAlreadyExistsException; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.LinkOption; +import java.nio.file.NotDirectoryException; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.ReadOnlyFileSystemException; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.FileAttributeView; +import java.nio.file.spi.FileSystemProvider; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.loader.net.protocol.nested.NestedLocation; + +/** + * {@link FileSystemProvider} implementation for {@link NestedLocation nested} jar files. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public class NestedFileSystemProvider extends FileSystemProvider { + + private Map fileSystems = new HashMap<>(); + + @Override + public String getScheme() { + return "nested"; + } + + @Override + public FileSystem newFileSystem(URI uri, Map env) throws IOException { + NestedLocation location = NestedLocation.fromUri(uri); + Path jarPath = location.path(); + synchronized (this.fileSystems) { + if (this.fileSystems.containsKey(jarPath)) { + throw new FileSystemAlreadyExistsException(); + } + NestedFileSystem fileSystem = new NestedFileSystem(this, location.path()); + this.fileSystems.put(location.path(), fileSystem); + return fileSystem; + } + } + + @Override + public FileSystem getFileSystem(URI uri) { + NestedLocation location = NestedLocation.fromUri(uri); + synchronized (this.fileSystems) { + NestedFileSystem fileSystem = this.fileSystems.get(location.path()); + if (fileSystem == null) { + throw new FileSystemNotFoundException(); + } + return fileSystem; + } + } + + @Override + public Path getPath(URI uri) { + NestedLocation location = NestedLocation.fromUri(uri); + synchronized (this.fileSystems) { + NestedFileSystem fileSystem = this.fileSystems.computeIfAbsent(location.path(), + (path) -> new NestedFileSystem(this, path)); + fileSystem.installZipFileSystemIfNecessary(location.nestedEntryName()); + return fileSystem.getPath(location.nestedEntryName()); + } + } + + void removeFileSystem(NestedFileSystem fileSystem) { + synchronized (this.fileSystems) { + this.fileSystems.remove(fileSystem.getJarPath()); + } + } + + @Override + public SeekableByteChannel newByteChannel(Path path, Set options, FileAttribute... attrs) + throws IOException { + NestedPath nestedPath = NestedPath.cast(path); + return new NestedByteChannel(nestedPath.getJarPath(), nestedPath.getNestedEntryName()); + } + + @Override + public DirectoryStream newDirectoryStream(Path dir, Filter filter) throws IOException { + throw new NotDirectoryException(NestedPath.cast(dir).toString()); + } + + @Override + public void createDirectory(Path dir, FileAttribute... attrs) throws IOException { + throw new ReadOnlyFileSystemException(); + } + + @Override + public void delete(Path path) throws IOException { + throw new ReadOnlyFileSystemException(); + } + + @Override + public void copy(Path source, Path target, CopyOption... options) throws IOException { + throw new ReadOnlyFileSystemException(); + } + + @Override + public void move(Path source, Path target, CopyOption... options) throws IOException { + throw new ReadOnlyFileSystemException(); + } + + @Override + public boolean isSameFile(Path path, Path path2) throws IOException { + return path.equals(path2); + } + + @Override + public boolean isHidden(Path path) throws IOException { + return false; + } + + @Override + public FileStore getFileStore(Path path) throws IOException { + NestedPath nestedPath = NestedPath.cast(path); + nestedPath.assertExists(); + return new NestedFileStore(nestedPath.getFileSystem()); + } + + @Override + public void checkAccess(Path path, AccessMode... modes) throws IOException { + Path jarPath = getJarPath(path); + jarPath.getFileSystem().provider().checkAccess(jarPath, modes); + } + + @Override + public V getFileAttributeView(Path path, Class type, LinkOption... options) { + Path jarPath = getJarPath(path); + return jarPath.getFileSystem().provider().getFileAttributeView(jarPath, type, options); + } + + @Override + public A readAttributes(Path path, Class type, LinkOption... options) + throws IOException { + Path jarPath = getJarPath(path); + return jarPath.getFileSystem().provider().readAttributes(jarPath, type, options); + } + + @Override + public Map readAttributes(Path path, String attributes, LinkOption... options) throws IOException { + Path jarPath = getJarPath(path); + return jarPath.getFileSystem().provider().readAttributes(jarPath, attributes, options); + } + + protected Path getJarPath(Path path) { + return NestedPath.cast(path).getJarPath(); + } + + @Override + public void setAttribute(Path path, String attribute, Object value, LinkOption... options) throws IOException { + throw new ReadOnlyFileSystemException(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedPath.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedPath.java new file mode 100644 index 000000000000..c544a8048310 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/NestedPath.java @@ -0,0 +1,228 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.IOError; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.ProviderMismatchException; +import java.nio.file.WatchEvent.Kind; +import java.nio.file.WatchEvent.Modifier; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.Objects; + +import org.springframework.boot.loader.net.protocol.nested.NestedLocation; +import org.springframework.boot.loader.zip.ZipContent; + +/** + * {@link Path} implementation for {@link NestedLocation nested} jar files. + * + * @author Phillip Webb + * @see NestedFileSystemProvider + */ +final class NestedPath implements Path { + + private final NestedFileSystem fileSystem; + + private final String nestedEntryName; + + private volatile Boolean entryExists; + + NestedPath(NestedFileSystem fileSystem, String nestedEntryName) { + if (fileSystem == null) { + throw new IllegalArgumentException("'filesSystem' must not be null"); + } + this.fileSystem = fileSystem; + this.nestedEntryName = (nestedEntryName != null && !nestedEntryName.isBlank()) ? nestedEntryName : null; + } + + Path getJarPath() { + return this.fileSystem.getJarPath(); + } + + String getNestedEntryName() { + return this.nestedEntryName; + } + + @Override + public NestedFileSystem getFileSystem() { + return this.fileSystem; + } + + @Override + public boolean isAbsolute() { + return true; + } + + @Override + public Path getRoot() { + return null; + } + + @Override + public Path getFileName() { + return this; + } + + @Override + public Path getParent() { + return null; + } + + @Override + public int getNameCount() { + return 1; + } + + @Override + public Path getName(int index) { + if (index != 0) { + throw new IllegalArgumentException("Nested paths only have a single element"); + } + return this; + } + + @Override + public Path subpath(int beginIndex, int endIndex) { + if (beginIndex != 0 || endIndex != 1) { + throw new IllegalArgumentException("Nested paths only have a single element"); + } + return this; + } + + @Override + public boolean startsWith(Path other) { + return equals(other); + } + + @Override + public boolean endsWith(Path other) { + return equals(other); + } + + @Override + public Path normalize() { + return this; + } + + @Override + public Path resolve(Path other) { + throw new UnsupportedOperationException("Unable to resolve nested path"); + } + + @Override + public Path relativize(Path other) { + throw new UnsupportedOperationException("Unable to relativize nested path"); + } + + @Override + public URI toUri() { + try { + String uri = "nested:" + this.fileSystem.getJarPath().toUri().getPath(); + if (this.nestedEntryName != null) { + uri += "/!" + this.nestedEntryName; + } + return new URI(uri); + } + catch (URISyntaxException ex) { + throw new IOError(ex); + } + } + + @Override + public Path toAbsolutePath() { + return this; + } + + @Override + public Path toRealPath(LinkOption... options) throws IOException { + return this; + } + + @Override + public WatchKey register(WatchService watcher, Kind[] events, Modifier... modifiers) throws IOException { + throw new UnsupportedOperationException("Nested paths cannot be watched"); + } + + @Override + public int compareTo(Path other) { + NestedPath otherNestedPath = cast(other); + return this.nestedEntryName.compareTo(otherNestedPath.nestedEntryName); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + NestedPath other = (NestedPath) obj; + return Objects.equals(this.fileSystem, other.fileSystem) + && Objects.equals(this.nestedEntryName, other.nestedEntryName); + } + + @Override + public int hashCode() { + return Objects.hash(this.fileSystem, this.nestedEntryName); + } + + @Override + public String toString() { + String string = this.fileSystem.getJarPath().toString(); + if (this.nestedEntryName != null) { + string += this.fileSystem.getSeparator() + this.nestedEntryName; + } + return string; + } + + void assertExists() throws NoSuchFileException { + if (!Files.isRegularFile(getJarPath())) { + throw new NoSuchFileException(toString()); + } + Boolean entryExists = this.entryExists; + if (entryExists == null) { + try { + try (ZipContent content = ZipContent.open(getJarPath(), this.nestedEntryName)) { + entryExists = true; + } + } + catch (IOException ex) { + entryExists = false; + } + this.entryExists = entryExists; + } + if (!entryExists) { + throw new NoSuchFileException(toString()); + } + } + + static NestedPath cast(Path path) { + if (path instanceof NestedPath nestedPath) { + return nestedPath; + } + throw new ProviderMismatchException(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/package-info.java new file mode 100644 index 000000000000..6431f845345c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/nio/file/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Non-blocking IO {@link java.nio.file.FileSystem} implementation for nested suppoprt. + */ +package org.springframework.boot.loader.nio.file; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/Cleaner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/Cleaner.java new file mode 100644 index 000000000000..4b053b78d9fb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/Cleaner.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-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.boot.loader.ref; + +import java.lang.ref.Cleaner.Cleanable; + +/** + * Wrapper for {@link java.lang.ref.Cleaner} providing registration support. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public interface Cleaner { + + /** + * Provides access to the default clean instance which delegates to + * {@link java.lang.ref.Cleaner}. + */ + Cleaner instance = DefaultCleaner.instance; + + /** + * Registers an object and the clean action to run when the object becomes phantom + * reachable. + * @param obj the object to monitor + * @param action the cleanup action to run + * @return a {@link Cleanable} instance + * @see java.lang.ref.Cleaner#register(Object, Runnable) + */ + Cleanable register(Object obj, Runnable action); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java new file mode 100644 index 000000000000..01c6817a38a0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-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.boot.loader.ref; + +import java.lang.ref.Cleaner.Cleanable; +import java.util.function.BiConsumer; + +/** + * Default {@link Cleaner} implementation that delegates to {@link java.lang.ref.Cleaner}. + * + * @author Phillip Webb + */ +class DefaultCleaner implements Cleaner { + + static final DefaultCleaner instance = new DefaultCleaner(); + + static BiConsumer tracker; + + private final java.lang.ref.Cleaner cleaner = java.lang.ref.Cleaner.create(); + + @Override + public Cleanable register(Object obj, Runnable action) { + Cleanable cleanable = (action != null) ? this.cleaner.register(obj, action) : null; + if (tracker != null) { + tracker.accept(obj, cleanable); + } + return cleanable; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/package-info.java new file mode 100644 index 000000000000..4cb63bb4a64d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Support for {@link java.lang.ref.Cleaner}. + */ +package org.springframework.boot.loader.ref; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ByteArrayDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ByteArrayDataBlock.java new file mode 100644 index 000000000000..3c1d4b41389b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ByteArrayDataBlock.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * {@link DataBlock} backed by a byte array . + * + * @author Phillip Webb + */ +class ByteArrayDataBlock implements CloseableDataBlock { + + private final byte[] bytes; + + private final int maxReadSize; + + /** + * Create a new {@link ByteArrayDataBlock} backed by the given bytes. + * @param bytes the bytes to use + */ + ByteArrayDataBlock(byte... bytes) { + this(bytes, -1); + } + + ByteArrayDataBlock(byte[] bytes, int maxReadSize) { + this.bytes = bytes; + this.maxReadSize = maxReadSize; + } + + @Override + public long size() throws IOException { + return this.bytes.length; + } + + @Override + public int read(ByteBuffer dst, long pos) throws IOException { + return read(dst, (int) pos); + } + + private int read(ByteBuffer dst, int pos) { + int remaining = dst.remaining(); + int length = Math.min(this.bytes.length - pos, remaining); + if (this.maxReadSize > 0 && length > this.maxReadSize) { + length = this.maxReadSize; + } + dst.put(this.bytes, pos, length); + return length; + } + + @Override + public void close() throws IOException { + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/CloseableDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/CloseableDataBlock.java new file mode 100644 index 000000000000..6303daf4dcd6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/CloseableDataBlock.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.Closeable; + +/** + * A {@link Closeable} {@link DataBlock}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public interface CloseableDataBlock extends DataBlock, Closeable { + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java new file mode 100644 index 000000000000..b37cad6a82d1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; + +/** + * Provides read access to a block of data contained somewhere in a zip file. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public interface DataBlock { + + /** + * Return the size of this block. + * @return the block size + * @throws IOException on I/O error + */ + long size() throws IOException; + + /** + * Read a sequence of bytes from this channel into the given buffer, starting at the + * given block position. + * @param dst the buffer into which bytes are to be transferred + * @param pos the position within the block at which the transfer is to begin + * @return the number of bytes read, possibly zero, or {@code -1} if the given + * position is greater than or equal to the block size + * @throws IOException on I/O error + * @see #readFully(ByteBuffer, long) + * @see FileChannel#read(ByteBuffer, long) + */ + int read(ByteBuffer dst, long pos) throws IOException; + + /** + * Fully read a sequence of bytes from this channel into the given buffer, starting at + * the given block position and filling {@link ByteBuffer#remaining() remaining} bytes + * in the buffer. + * @param dst the buffer into which bytes are to be transferred + * @param pos the position within the block at which the transfer is to begin + * @throws EOFException if an attempt is made to read past the end of the block + * @throws IOException on I/O error + */ + default void readFully(ByteBuffer dst, long pos) throws IOException { + do { + int count = read(dst, pos); + if (count <= 0) { + throw new EOFException(); + } + pos += count; + } + while (dst.hasRemaining()); + } + + /** + * Return this {@link DataBlock} as an {@link InputStream}. + * @return an {@link InputStream} to read the data block content + * @throws IOException on IO error + */ + default InputStream asInputStream() throws IOException { + return new DataBlockInputStream(this); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java new file mode 100644 index 000000000000..3f9b0275bed4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java @@ -0,0 +1,104 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * {@link InputStream} backed by a {@link DataBlock}. + * + * @author Phillip Webb + */ +class DataBlockInputStream extends InputStream { + + private final DataBlock dataBlock; + + private long pos; + + private long remaining; + + private volatile boolean closed; + + DataBlockInputStream(DataBlock dataBlock) throws IOException { + this.dataBlock = dataBlock; + this.remaining = dataBlock.size(); + } + + @Override + public int read() throws IOException { + byte[] b = new byte[1]; + return (read(b, 0, 1) == 1) ? b[0] & 0xFF : -1; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + ensureOpen(); + ByteBuffer dst = ByteBuffer.wrap(b, off, len); + int count = this.dataBlock.read(dst, this.pos); + if (count > 0) { + this.pos += count; + this.remaining -= count; + } + return count; + } + + @Override + public long skip(long n) throws IOException { + long count = (n > 0) ? maxForwardSkip(n) : maxBackwardSkip(n); + this.pos += count; + this.remaining -= count; + return count; + } + + private long maxForwardSkip(long n) { + boolean willCauseOverflow = (this.pos + n) < 0; + return (willCauseOverflow || n > this.remaining) ? this.remaining : n; + } + + private long maxBackwardSkip(long n) { + return Math.max(-this.pos, n); + } + + @Override + public int available() { + if (this.closed) { + return 0; + } + return (this.remaining < Integer.MAX_VALUE) ? (int) this.remaining : Integer.MAX_VALUE; + } + + private void ensureOpen() throws IOException { + if (this.closed) { + throw new IOException("InputStream closed"); + } + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + if (this.dataBlock instanceof Closeable closeable) { + closeable.close(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.java new file mode 100644 index 000000000000..788841ea308b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.java @@ -0,0 +1,288 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedByInterruptException; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.function.Supplier; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * Reference counted {@link DataBlock} implementation backed by a {@link FileChannel} with + * support for slicing. + * + * @author Phillip Webb + */ +class FileChannelDataBlock implements CloseableDataBlock { + + private static final DebugLogger debug = DebugLogger.get(FileChannelDataBlock.class); + + static Tracker tracker; + + private final ManagedFileChannel channel; + + private final long offset; + + private final long size; + + FileChannelDataBlock(Path path) throws IOException { + this.channel = new ManagedFileChannel(path); + this.offset = 0; + this.size = Files.size(path); + } + + FileChannelDataBlock(ManagedFileChannel channel, long offset, long size) { + this.channel = channel; + this.offset = offset; + this.size = size; + } + + @Override + public long size() throws IOException { + return this.size; + } + + @Override + public int read(ByteBuffer dst, long pos) throws IOException { + if (pos < 0) { + throw new IllegalArgumentException("Position must not be negative"); + } + ensureOpen(ClosedChannelException::new); + int remaining = (int) (this.size - pos); + if (remaining <= 0) { + return -1; + } + int originalDestinationLimit = -1; + if (dst.remaining() > remaining) { + originalDestinationLimit = dst.limit(); + dst.limit(dst.position() + remaining); + } + int result = this.channel.read(dst, this.offset + pos); + if (originalDestinationLimit != -1) { + dst.limit(originalDestinationLimit); + } + return result; + } + + /** + * Open a connection to this block, increasing the reference count and re-opening the + * underlying file channel if necessary. + * @throws IOException on I/O error + */ + void open() throws IOException { + this.channel.open(); + } + + /** + * Close a connection to this block, decreasing the reference count and closing the + * underlying file channel if necessary. + * @throws IOException on I/O error + */ + @Override + public void close() throws IOException { + this.channel.close(); + } + + /** + * Ensure that the underlying file channel is currently open. + * @param exceptionSupplier a supplier providing the exception to throw + * @param the exception type + * @throws E if the channel is closed + */ + void ensureOpen(Supplier exceptionSupplier) throws E { + this.channel.ensureOpen(exceptionSupplier); + } + + /** + * Return a new {@link FileChannelDataBlock} slice providing access to a subset of the + * data. The caller is responsible for calling {@link #open()} and {@link #close()} on + * the returned block. + * @param offset the start offset for the slice relative to this block + * @return a new {@link FileChannelDataBlock} instance + * @throws IOException on I/O error + */ + FileChannelDataBlock slice(long offset) throws IOException { + return slice(offset, this.size - offset); + } + + /** + * Return a new {@link FileChannelDataBlock} slice providing access to a subset of the + * data. The caller is responsible for calling {@link #open()} and {@link #close()} on + * the returned block. + * @param offset the start offset for the slice relative to this block + * @param size the size of the new slice + * @return a new {@link FileChannelDataBlock} instance + */ + FileChannelDataBlock slice(long offset, long size) { + if (offset == 0 && size == this.size) { + return this; + } + if (offset < 0) { + throw new IllegalArgumentException("Offset must not be negative"); + } + if (size < 0 || offset + size > this.size) { + throw new IllegalArgumentException("Size must not be negative and must be within bounds"); + } + debug.log("Slicing %s at %s with size %s", this.channel, offset, size); + return new FileChannelDataBlock(this.channel, this.offset + offset, size); + } + + /** + * Manages access to underlying {@link FileChannel}. + */ + static class ManagedFileChannel { + + static final int BUFFER_SIZE = 1024 * 10; + + private final Path path; + + private int referenceCount; + + private FileChannel fileChannel; + + private ByteBuffer buffer; + + private long bufferPosition = -1; + + private int bufferSize; + + private final Object lock = new Object(); + + ManagedFileChannel(Path path) { + if (!Files.isRegularFile(path)) { + throw new IllegalArgumentException(path + " must be a regular file"); + } + this.path = path; + } + + int read(ByteBuffer dst, long position) throws IOException { + synchronized (this.lock) { + if (position < this.bufferPosition || position >= this.bufferPosition + this.bufferSize) { + fillBuffer(position); + } + if (this.bufferSize <= 0) { + return this.bufferSize; + } + int offset = (int) (position - this.bufferPosition); + int length = Math.min(this.bufferSize - offset, dst.remaining()); + dst.put(dst.position(), this.buffer, offset, length); + dst.position(dst.position() + length); + return length; + } + } + + private void fillBuffer(long position) throws IOException { + for (int i = 0; i < 10; i++) { + boolean interrupted = (i != 0) ? Thread.interrupted() : false; + try { + this.buffer.clear(); + this.bufferSize = this.fileChannel.read(this.buffer, position); + this.bufferPosition = position; + return; + } + catch (ClosedByInterruptException ex) { + repairFileChannel(); + } + finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + throw new ClosedByInterruptException(); + } + + private void repairFileChannel() throws IOException { + if (tracker != null) { + tracker.closedFileChannel(this.path, this.fileChannel); + } + this.fileChannel = FileChannel.open(this.path, StandardOpenOption.READ); + if (tracker != null) { + tracker.openedFileChannel(this.path, this.fileChannel); + } + } + + void open() throws IOException { + synchronized (this.lock) { + if (this.referenceCount == 0) { + debug.log("Opening '%s'", this.path); + this.fileChannel = FileChannel.open(this.path, StandardOpenOption.READ); + this.buffer = ByteBuffer.allocateDirect(BUFFER_SIZE); + if (tracker != null) { + tracker.openedFileChannel(this.path, this.fileChannel); + } + } + this.referenceCount++; + debug.log("Reference count for '%s' incremented to %s", this.path, this.referenceCount); + } + } + + void close() throws IOException { + synchronized (this.lock) { + if (this.referenceCount == 0) { + return; + } + this.referenceCount--; + if (this.referenceCount == 0) { + debug.log("Closing '%s'", this.path); + this.buffer = null; + this.bufferPosition = -1; + this.bufferSize = 0; + this.fileChannel.close(); + if (tracker != null) { + tracker.closedFileChannel(this.path, this.fileChannel); + } + this.fileChannel = null; + } + debug.log("Reference count for '%s' decremented to %s", this.path, this.referenceCount); + } + } + + void ensureOpen(Supplier exceptionSupplier) throws E { + synchronized (this.lock) { + if (this.referenceCount == 0 || !this.fileChannel.isOpen()) { + throw exceptionSupplier.get(); + } + } + } + + @Override + public String toString() { + return this.path.toString(); + } + + } + + /** + * Internal tracker used to check open and closing of files in tests. + */ + interface Tracker { + + void openedFileChannel(Path path, FileChannel fileChannel); + + void closedFileChannel(Path path, FileChannel fileChannel); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java new file mode 100644 index 000000000000..d3014448c570 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.util.BitSet; + +/** + * Tracks entries that have a name that should be offset by a specific amount. This class + * is used with nested directory zip files so that entries under the directory are offset + * correctly. META-INF entries are copied directly and have no offset. + * + * @author Phillip Webb + */ +class NameOffsetLookups { + + public static final NameOffsetLookups NONE = new NameOffsetLookups(0, 0); + + private final int offset; + + private final BitSet enabled; + + NameOffsetLookups(int offset, int size) { + this.offset = offset; + this.enabled = (size != 0) ? new BitSet(size) : null; + } + + void swap(int i, int j) { + if (this.enabled != null) { + boolean temp = this.enabled.get(i); + this.enabled.set(i, this.enabled.get(j)); + this.enabled.set(j, temp); + } + } + + int get(int index) { + return isEnabled(index) ? this.offset : 0; + } + + int enable(int index, boolean enable) { + if (this.enabled != null) { + this.enabled.set(index, enable); + } + return (!enable) ? 0 : this.offset; + } + + boolean isEnabled(int index) { + return (this.enabled != null && this.enabled.get(index)); + } + + boolean hasAnyEnabled() { + return this.enabled != null && this.enabled.cardinality() > 0; + } + + NameOffsetLookups emptyCopy() { + return new NameOffsetLookups(this.offset, this.enabled.size()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java new file mode 100644 index 000000000000..e8d8838700ca --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java @@ -0,0 +1,92 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Collection; +import java.util.List; + +/** + * A virtual {@link DataBlock} build from a collection of other {@link DataBlock} + * instances. + * + * @author Phillip Webb + */ +class VirtualDataBlock implements DataBlock { + + private List parts; + + private long size; + + /** + * Create a new {@link VirtualDataBlock} instance. The {@link #setParts(Collection)} + * method must be called before the data block can be used. + */ + protected VirtualDataBlock() { + } + + /** + * Create a new {@link VirtualDataBlock} backed by the given parts. + * @param parts the parts that make up the virtual data block + * @throws IOException in I/O error + */ + VirtualDataBlock(Collection parts) throws IOException { + setParts(parts); + } + + /** + * Set the parts that make up the virtual data block. + * @param parts the data block parts + * @throws IOException on I/O error + */ + protected void setParts(Collection parts) throws IOException { + this.parts = List.copyOf(parts); + long size = 0; + for (DataBlock part : parts) { + size += part.size(); + } + this.size = size; + } + + @Override + public long size() throws IOException { + return this.size; + } + + @Override + public int read(ByteBuffer dst, long pos) throws IOException { + if (pos < 0 || pos >= this.size) { + return -1; + } + long offset = 0; + int result = 0; + for (DataBlock part : this.parts) { + while (pos >= offset && pos < offset + part.size()) { + int count = part.read(dst, pos - offset); + result += Math.max(count, 0); + if (count <= 0 || !dst.hasRemaining()) { + return result; + } + pos += count; + } + offset += part.size(); + } + return result; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java new file mode 100644 index 000000000000..7b2541f4e072 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java @@ -0,0 +1,148 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.FileSystem; +import java.util.ArrayList; +import java.util.List; + +/** + * {@link DataBlock} that creates a virtual zip. This class allows us to create virtual + * zip files that can be parsed by regular JDK classes such as the zip {@link FileSystem}. + * + * @author Phillip Webb + */ +class VirtualZipDataBlock extends VirtualDataBlock implements CloseableDataBlock { + + private final CloseableDataBlock data; + + /** + * Create a new {@link VirtualZipDataBlock} for the given entries. + * @param data the source zip data + * @param nameOffsetLookups the name offsets to apply + * @param centralRecords the records that should be copied to the virtual zip + * @param centralRecordPositions the record positions in the data block. + * @throws IOException on I/O error + */ + VirtualZipDataBlock(CloseableDataBlock data, NameOffsetLookups nameOffsetLookups, + ZipCentralDirectoryFileHeaderRecord[] centralRecords, long[] centralRecordPositions) throws IOException { + this.data = data; + List parts = new ArrayList<>(); + List centralParts = new ArrayList<>(); + long offset = 0; + long sizeOfCentralDirectory = 0; + for (int i = 0; i < centralRecords.length; i++) { + ZipCentralDirectoryFileHeaderRecord centralRecord = centralRecords[i]; + int nameOffset = nameOffsetLookups.get(i); + long centralRecordPos = centralRecordPositions[i]; + DataBlock name = new DataPart( + centralRecordPos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + nameOffset, + Short.toUnsignedLong(centralRecord.fileNameLength()) - nameOffset); + long localRecordPos = Integer.toUnsignedLong(centralRecord.offsetToLocalHeader()); + ZipLocalFileHeaderRecord localRecord = ZipLocalFileHeaderRecord.load(this.data, localRecordPos); + DataBlock content = new DataPart(localRecordPos + localRecord.size(), centralRecord.compressedSize()); + boolean hasDescriptorRecord = ZipDataDescriptorRecord.isPresentBasedOnFlag(centralRecord); + ZipDataDescriptorRecord dataDescriptorRecord = (!hasDescriptorRecord) ? null + : ZipDataDescriptorRecord.load(data, localRecordPos + localRecord.size() + content.size()); + sizeOfCentralDirectory += addToCentral(centralParts, centralRecord, centralRecordPos, name, (int) offset); + offset += addToLocal(parts, centralRecord, localRecord, dataDescriptorRecord, name, content); + } + parts.addAll(centralParts); + ZipEndOfCentralDirectoryRecord eocd = new ZipEndOfCentralDirectoryRecord((short) centralRecords.length, + (int) sizeOfCentralDirectory, (int) offset); + parts.add(new ByteArrayDataBlock(eocd.asByteArray())); + setParts(parts); + } + + private long addToCentral(List parts, ZipCentralDirectoryFileHeaderRecord originalRecord, + long originalRecordPos, DataBlock name, int offsetToLocalHeader) throws IOException { + ZipCentralDirectoryFileHeaderRecord record = originalRecord.withFileNameLength((short) (name.size() & 0xFFFF)) + .withOffsetToLocalHeader(offsetToLocalHeader); + int originalExtraFieldLength = Short.toUnsignedInt(originalRecord.extraFieldLength()); + int originalFileCommentLength = Short.toUnsignedInt(originalRecord.fileCommentLength()); + DataBlock extraFieldAndComment = new DataPart( + originalRecordPos + originalRecord.size() - originalExtraFieldLength - originalFileCommentLength, + originalExtraFieldLength + originalFileCommentLength); + parts.add(new ByteArrayDataBlock(record.asByteArray())); + parts.add(name); + parts.add(extraFieldAndComment); + return record.size(); + } + + private long addToLocal(List parts, ZipCentralDirectoryFileHeaderRecord centralRecord, + ZipLocalFileHeaderRecord originalRecord, ZipDataDescriptorRecord dataDescriptorRecord, DataBlock name, + DataBlock content) throws IOException { + ZipLocalFileHeaderRecord record = originalRecord.withFileNameLength((short) (name.size() & 0xFFFF)); + long originalRecordPos = Integer.toUnsignedLong(centralRecord.offsetToLocalHeader()); + int extraFieldLength = Short.toUnsignedInt(originalRecord.extraFieldLength()); + parts.add(new ByteArrayDataBlock(record.asByteArray())); + parts.add(name); + parts.add(new DataPart(originalRecordPos + originalRecord.size() - extraFieldLength, extraFieldLength)); + parts.add(content); + if (dataDescriptorRecord != null) { + parts.add(new ByteArrayDataBlock(dataDescriptorRecord.asByteArray())); + } + return record.size() + content.size() + ((dataDescriptorRecord != null) ? dataDescriptorRecord.size() : 0); + } + + @Override + public void close() throws IOException { + this.data.close(); + } + + /** + * {@link DataBlock} that points to part of the original data block. + */ + final class DataPart implements DataBlock { + + private final long offset; + + private final long size; + + DataPart(long offset, long size) { + this.offset = offset; + this.size = size; + } + + @Override + public long size() throws IOException { + return this.size; + } + + @Override + public int read(ByteBuffer dst, long pos) throws IOException { + int remaining = (int) (this.size - pos); + if (remaining <= 0) { + return -1; + } + int originalLimit = -1; + if (dst.remaining() > remaining) { + originalLimit = dst.limit(); + dst.limit(dst.position() + remaining); + } + int result = VirtualZipDataBlock.this.data.read(dst, this.offset + pos); + if (originalLimit != -1) { + dst.limit(originalLimit); + } + return result; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java new file mode 100644 index 000000000000..078c5ad81d95 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A Zip64 end of central directory locator. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @param pos the position where this record begins in the source {@link DataBlock} + * @param numberOfThisDisk the number of the disk with the start of the zip64 end of + * central directory + * @param offsetToZip64EndOfCentralDirectoryRecord the relative offset of the zip64 end of + * central directory record + * @param totalNumberOfDisks the total number of disks + * @see Chapter + * 4.3.15 of the Zip File Format Specification + */ +record Zip64EndOfCentralDirectoryLocator(long pos, int numberOfThisDisk, long offsetToZip64EndOfCentralDirectoryRecord, + int totalNumberOfDisks) { + + private static final DebugLogger debug = DebugLogger.get(Zip64EndOfCentralDirectoryLocator.class); + + private static final int SIGNATURE = 0x07064b50; + + /** + * The size of this record. + */ + static final int SIZE = 20; + + /** + * Return the {@link Zip64EndOfCentralDirectoryLocator} or {@code null} if this is not + * a Zip64 file. + * @param dataBlock the source data block + * @param endOfCentralDirectoryPos the {@link ZipEndOfCentralDirectoryRecord} position + * @return a {@link Zip64EndOfCentralDirectoryLocator} instance or null + * @throws IOException on I/O error + */ + static Zip64EndOfCentralDirectoryLocator find(DataBlock dataBlock, long endOfCentralDirectoryPos) + throws IOException { + debug.log("Finding Zip64EndOfCentralDirectoryLocator from EOCD at %s", endOfCentralDirectoryPos); + long pos = endOfCentralDirectoryPos - SIZE; + if (pos < 0) { + debug.log("No Zip64EndOfCentralDirectoryLocator due to negative position %s", pos); + return null; + } + ByteBuffer buffer = ByteBuffer.allocate(SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + dataBlock.read(buffer, pos); + buffer.rewind(); + int signature = buffer.getInt(); + if (signature != SIGNATURE) { + debug.log("Found incorrect Zip64EndOfCentralDirectoryLocator signature %s at position %s", signature, pos); + return null; + } + debug.log("Found Zip64EndOfCentralDirectoryLocator at position %s", pos); + return new Zip64EndOfCentralDirectoryLocator(pos, buffer.getInt(), buffer.getLong(), buffer.getInt()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java new file mode 100644 index 000000000000..c593624ff94d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A Zip64 end of central directory record. + * + * @author Phillip Webb + * @param size the size of this record + * @param sizeOfZip64EndOfCentralDirectoryRecord the size of zip64 end of central + * directory record + * @param versionMadeBy the version that made the zip + * @param versionNeededToExtract the version needed to extract the zip + * @param numberOfThisDisk the number of this disk + * @param diskWhereCentralDirectoryStarts the disk where central directory starts + * @param numberOfCentralDirectoryEntriesOnThisDisk the number of central directory + * entries on this disk + * @param totalNumberOfCentralDirectoryEntries the total number of central directory + * entries + * @param sizeOfCentralDirectory the size of central directory (bytes) + * @param offsetToStartOfCentralDirectory the offset of start of central directory, + * relative to start of archive + * @see Chapter + * 4.3.14 of the Zip File Format Specification + */ +record Zip64EndOfCentralDirectoryRecord(long size, long sizeOfZip64EndOfCentralDirectoryRecord, short versionMadeBy, + short versionNeededToExtract, int numberOfThisDisk, int diskWhereCentralDirectoryStarts, + long numberOfCentralDirectoryEntriesOnThisDisk, long totalNumberOfCentralDirectoryEntries, + long sizeOfCentralDirectory, long offsetToStartOfCentralDirectory) { + + private static final DebugLogger debug = DebugLogger.get(Zip64EndOfCentralDirectoryRecord.class); + + private static final int SIGNATURE = 0x06064b50; + + private static final int MINIMUM_SIZE = 56; + + /** + * Load the {@link Zip64EndOfCentralDirectoryRecord} from the given data block based + * on the offset given in the locator. + * @param dataBlock the source data block + * @param locator the {@link Zip64EndOfCentralDirectoryLocator} or {@code null} + * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance or {@code null} + * if the locator is {@code null} + * @throws IOException on I/O error + */ + static Zip64EndOfCentralDirectoryRecord load(DataBlock dataBlock, Zip64EndOfCentralDirectoryLocator locator) + throws IOException { + if (locator == null) { + return null; + } + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + long size = locator.pos() - locator.offsetToZip64EndOfCentralDirectoryRecord(); + long pos = locator.pos() - size; + debug.log("Loading Zip64EndOfCentralDirectoryRecord from position %s size %s", pos, size); + dataBlock.readFully(buffer, pos); + buffer.rewind(); + int signature = buffer.getInt(); + if (signature != SIGNATURE) { + debug.log("Found incorrect Zip64EndOfCentralDirectoryRecord signature %s at position %s", signature, pos); + throw new IOException("Zip64 'End Of Central Directory Record' not found at position " + pos + + ". Zip file is corrupt or includes prefixed bytes which are not supported with Zip64 files"); + } + return new Zip64EndOfCentralDirectoryRecord(size, buffer.getLong(), buffer.getShort(), buffer.getShort(), + buffer.getInt(), buffer.getInt(), buffer.getLong(), buffer.getLong(), buffer.getLong(), + buffer.getLong()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java new file mode 100644 index 000000000000..b69b74fb1d0c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java @@ -0,0 +1,211 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.ValueRange; +import java.util.zip.ZipEntry; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A ZIP File "Central directory file header record" (CDFH). + * + * @author Phillip Webb + * @param versionMadeBy the version that made the zip + * @param versionNeededToExtract the version needed to extract the zip + * @param generalPurposeBitFlag the general purpose bit flag + * @param compressionMethod the compression method used for this entry + * @param lastModFileTime the last modified file time + * @param lastModFileDate the last modified file date + * @param crc32 the CRC32 checksum + * @param compressedSize the size of the entry when compressed + * @param uncompressedSize the size of the entry when uncompressed + * @param fileNameLength the file name length + * @param extraFieldLength the extra field length + * @param fileCommentLength the comment length + * @param diskNumberStart the disk number where the entry starts + * @param internalFileAttributes the internal file attributes + * @param externalFileAttributes the external file attributes + * @param offsetToLocalHeader the relative offset to the local file header + * @see Chapter + * 4.3.12 of the Zip File Format Specification + */ +record ZipCentralDirectoryFileHeaderRecord(short versionMadeBy, short versionNeededToExtract, + short generalPurposeBitFlag, short compressionMethod, short lastModFileTime, short lastModFileDate, int crc32, + int compressedSize, int uncompressedSize, short fileNameLength, short extraFieldLength, short fileCommentLength, + short diskNumberStart, short internalFileAttributes, int externalFileAttributes, int offsetToLocalHeader) { + + private static final DebugLogger debug = DebugLogger.get(ZipCentralDirectoryFileHeaderRecord.class); + + private static final int SIGNATURE = 0x02014b50; + + private static final int MINIMUM_SIZE = 46; + + /** + * The offset of the file name relative to the record start position. + */ + static final int FILE_NAME_OFFSET = MINIMUM_SIZE; + + /** + * Return the size of this record. + * @return the record size + */ + long size() { + return MINIMUM_SIZE + fileNameLength() + extraFieldLength() + fileCommentLength(); + } + + /** + * Copy values from this block to the given {@link ZipEntry}. + * @param dataBlock the source data block + * @param pos the position of this {@link ZipCentralDirectoryFileHeaderRecord} + * @param zipEntry the destination zip entry + * @throws IOException on I/O error + */ + void copyTo(DataBlock dataBlock, long pos, ZipEntry zipEntry) throws IOException { + int fileNameLength = Short.toUnsignedInt(fileNameLength()); + int extraLength = Short.toUnsignedInt(extraFieldLength()); + int commentLength = Short.toUnsignedInt(fileCommentLength()); + zipEntry.setMethod(Short.toUnsignedInt(compressionMethod())); + zipEntry.setTime(decodeMsDosFormatDateTime(lastModFileDate(), lastModFileTime())); + zipEntry.setCrc(Integer.toUnsignedLong(crc32())); + zipEntry.setCompressedSize(Integer.toUnsignedLong(compressedSize())); + zipEntry.setSize(Integer.toUnsignedLong(uncompressedSize())); + if (extraLength > 0) { + long extraPos = pos + MINIMUM_SIZE + fileNameLength; + ByteBuffer buffer = ByteBuffer.allocate(extraLength); + dataBlock.readFully(buffer, extraPos); + zipEntry.setExtra(buffer.array()); + } + if (commentLength > 0) { + long commentPos = pos + MINIMUM_SIZE + fileNameLength + extraLength; + zipEntry.setComment(ZipString.readString(dataBlock, commentPos, commentLength)); + } + } + + /** + * Decode MS-DOS Date Time details. See + * Microsoft's documentation for more details of the format. + * @param date the date + * @param time the time + * @return the date and time as milliseconds since the epoch + */ + private long decodeMsDosFormatDateTime(short date, short time) { + int year = getChronoValue(((date >> 9) & 0x7f) + 1980, ChronoField.YEAR); + int month = getChronoValue((date >> 5) & 0x0f, ChronoField.MONTH_OF_YEAR); + int day = getChronoValue(date & 0x1f, ChronoField.DAY_OF_MONTH); + int hour = getChronoValue((time >> 11) & 0x1f, ChronoField.HOUR_OF_DAY); + int minute = getChronoValue((time >> 5) & 0x3f, ChronoField.MINUTE_OF_HOUR); + int second = getChronoValue((time << 1) & 0x3e, ChronoField.SECOND_OF_MINUTE); + return ZonedDateTime.of(year, month, day, hour, minute, second, 0, ZoneId.systemDefault()) + .toInstant() + .truncatedTo(ChronoUnit.SECONDS) + .toEpochMilli(); + } + + private static int getChronoValue(long value, ChronoField field) { + ValueRange range = field.range(); + return Math.toIntExact(Math.min(Math.max(value, range.getMinimum()), range.getMaximum())); + } + + /** + * Return a new {@link ZipCentralDirectoryFileHeaderRecord} with a new + * {@link #fileNameLength()}. + * @param fileNameLength the new file name length + * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance + */ + ZipCentralDirectoryFileHeaderRecord withFileNameLength(short fileNameLength) { + return (this.fileNameLength != fileNameLength) ? new ZipCentralDirectoryFileHeaderRecord(this.versionMadeBy, + this.versionNeededToExtract, this.generalPurposeBitFlag, this.compressionMethod, this.lastModFileTime, + this.lastModFileDate, this.crc32, this.compressedSize, this.uncompressedSize, fileNameLength, + this.extraFieldLength, this.fileCommentLength, this.diskNumberStart, this.internalFileAttributes, + this.externalFileAttributes, this.offsetToLocalHeader) : this; + } + + /** + * Return a new {@link ZipCentralDirectoryFileHeaderRecord} with a new + * {@link #offsetToLocalHeader()}. + * @param offsetToLocalHeader the new offset to local header + * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance + */ + ZipCentralDirectoryFileHeaderRecord withOffsetToLocalHeader(int offsetToLocalHeader) { + return (this.offsetToLocalHeader != offsetToLocalHeader) ? new ZipCentralDirectoryFileHeaderRecord( + this.versionMadeBy, this.versionNeededToExtract, this.generalPurposeBitFlag, this.compressionMethod, + this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize, this.uncompressedSize, + this.fileNameLength, this.extraFieldLength, this.fileCommentLength, this.diskNumberStart, + this.internalFileAttributes, this.externalFileAttributes, offsetToLocalHeader) : this; + } + + /** + * Return the contents of this record as a byte array suitable for writing to a zip. + * @return the record as a byte array + */ + byte[] asByteArray() { + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(SIGNATURE); + buffer.putShort(this.versionMadeBy); + buffer.putShort(this.versionNeededToExtract); + buffer.putShort(this.generalPurposeBitFlag); + buffer.putShort(this.compressionMethod); + buffer.putShort(this.lastModFileTime); + buffer.putShort(this.lastModFileDate); + buffer.putInt(this.crc32); + buffer.putInt(this.compressedSize); + buffer.putInt(this.uncompressedSize); + buffer.putShort(this.fileNameLength); + buffer.putShort(this.extraFieldLength); + buffer.putShort(this.fileCommentLength); + buffer.putShort(this.diskNumberStart); + buffer.putShort(this.internalFileAttributes); + buffer.putInt(this.externalFileAttributes); + buffer.putInt(this.offsetToLocalHeader); + return buffer.array(); + } + + /** + * Load the {@link ZipCentralDirectoryFileHeaderRecord} from the given data block. + * @param dataBlock the source data block + * @param pos the position of the record + * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance + * @throws IOException on I/O error + */ + static ZipCentralDirectoryFileHeaderRecord load(DataBlock dataBlock, long pos) throws IOException { + debug.log("Loading CentralDirectoryFileHeaderRecord from position %s", pos); + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + dataBlock.readFully(buffer, pos); + buffer.rewind(); + int signature = buffer.getInt(); + if (signature != SIGNATURE) { + debug.log("Found incorrect CentralDirectoryFileHeaderRecord signature %s at position %s", signature, pos); + throw new IOException("Zip 'Central Directory File Header Record' not found at position " + pos); + } + return new ZipCentralDirectoryFileHeaderRecord(buffer.getShort(), buffer.getShort(), buffer.getShort(), + buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getInt(), buffer.getInt(), + buffer.getInt(), buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getShort(), + buffer.getShort(), buffer.getInt(), buffer.getInt()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java new file mode 100644 index 000000000000..cdc99d50117a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java @@ -0,0 +1,847 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.zip; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.ref.Cleaner.Cleanable; +import java.lang.ref.SoftReference; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.zip.ZipEntry; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * Provides raw access to content from a regular or nested zip file. This class performs + * the low level parsing of a zip file and provide access to raw entry data that it + * contains. Unlike {@link java.util.zip.ZipFile}, this implementation can load content + * from a zip file nested inside another file as long as the entry is not compressed. + *

+ * In order to reduce memory consumption, this implementation stores only the hash of the + * entry names, the central directory offsets and the original positions. Entries are + * stored internally in {@code hashCode} order so that a binary search can be used to + * quickly find an entry by name or determine if the zip file doesn't have a given entry. + *

+ * {@link ZipContent} for a typical Spring Boot application JAR will have somewhere in the + * region of 10,500 entries which should consume about 122K. + *

+ * {@link ZipContent} results are cached and it is assumed that zip content will not + * change once loaded. Entries and Strings are not cached and will be recreated on each + * access which may produce a lot of garbage. + *

+ * This implementation does not use {@link Cleanable} so care must be taken to release + * {@link ZipContent} resources. The {@link #close()} method should be called explicitly + * or by try-with-resources. Care must be take to only call close once. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 3.2.0 + */ +public final class ZipContent implements Closeable { + + private static final String META_INF = "META-INF/"; + + private static final byte[] SIGNATURE_SUFFIX = ".DSA".getBytes(StandardCharsets.UTF_8); + + private static final DebugLogger debug = DebugLogger.get(ZipContent.class); + + private static final Map cache = new ConcurrentHashMap<>(); + + private final Source source; + + private final Kind kind; + + private final FileChannelDataBlock data; + + private final long centralDirectoryPos; + + private final long commentPos; + + private final long commentLength; + + private final int[] lookupIndexes; + + private final int[] nameHashLookups; + + private final int[] relativeCentralDirectoryOffsetLookups; + + private final NameOffsetLookups nameOffsetLookups; + + private final boolean hasJarSignatureFile; + + private SoftReference virtualData; + + private SoftReference, Object>> info; + + private ZipContent(Source source, Kind kind, FileChannelDataBlock data, long centralDirectoryPos, long commentPos, + long commentLength, int[] lookupIndexes, int[] nameHashLookups, int[] relativeCentralDirectoryOffsetLookups, + NameOffsetLookups nameOffsetLookups, boolean hasJarSignatureFile) { + this.source = source; + this.kind = kind; + this.data = data; + this.centralDirectoryPos = centralDirectoryPos; + this.commentPos = commentPos; + this.commentLength = commentLength; + this.lookupIndexes = lookupIndexes; + this.nameHashLookups = nameHashLookups; + this.relativeCentralDirectoryOffsetLookups = relativeCentralDirectoryOffsetLookups; + this.nameOffsetLookups = nameOffsetLookups; + this.hasJarSignatureFile = hasJarSignatureFile; + } + + /** + * Return the kind of content that was loaded. + * @return the content kind + * @since 3.2.2 + */ + public Kind getKind() { + return this.kind; + } + + /** + * Open a {@link DataBlock} containing the raw zip data. For container zip files, this + * may be smaller than the original file since additional bytes are permitted at the + * front of a zip file. For nested zip files, this will be only the contents of the + * nest zip. + *

+ * For nested directory zip files, a virtual data block will be created containing + * only the relevant content. + *

+ * To release resources, the {@link #close()} method of the data block should be + * called explicitly or by try-with-resources. + *

+ * The returned data block should not be accessed once {@link #close()} has been + * called. + * @return the zip data + * @throws IOException on I/O error + */ + public CloseableDataBlock openRawZipData() throws IOException { + this.data.open(); + return (!this.nameOffsetLookups.hasAnyEnabled()) ? this.data : getVirtualData(); + } + + private CloseableDataBlock getVirtualData() throws IOException { + CloseableDataBlock virtualData = (this.virtualData != null) ? this.virtualData.get() : null; + if (virtualData != null) { + return virtualData; + } + virtualData = createVirtualData(); + this.virtualData = new SoftReference<>(virtualData); + return virtualData; + } + + private CloseableDataBlock createVirtualData() throws IOException { + int size = size(); + NameOffsetLookups nameOffsetLookups = this.nameOffsetLookups.emptyCopy(); + ZipCentralDirectoryFileHeaderRecord[] centralRecords = new ZipCentralDirectoryFileHeaderRecord[size]; + long[] centralRecordPositions = new long[size]; + for (int i = 0; i < size; i++) { + int lookupIndex = ZipContent.this.lookupIndexes[i]; + long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex); + nameOffsetLookups.enable(i, this.nameOffsetLookups.isEnabled(lookupIndex)); + centralRecords[i] = ZipCentralDirectoryFileHeaderRecord.load(this.data, pos); + centralRecordPositions[i] = pos; + } + return new VirtualZipDataBlock(this.data, nameOffsetLookups, centralRecords, centralRecordPositions); + } + + /** + * Returns the number of entries in the ZIP file. + * @return the number of entries + */ + public int size() { + return this.lookupIndexes.length; + } + + /** + * Return the zip comment, if any. + * @return the comment or {@code null} + */ + public String getComment() { + try { + return ZipString.readString(this.data, this.commentPos, this.commentLength); + } + catch (UncheckedIOException ex) { + if (ex.getCause() instanceof ClosedChannelException) { + throw new IllegalStateException("Zip content closed", ex); + } + throw ex; + } + } + + /** + * Return the entry with the given name, if any. + * @param name the name of the entry to find + * @return the entry or {@code null} + */ + public Entry getEntry(CharSequence name) { + return getEntry(null, name); + } + + /** + * Return the entry with the given name, if any. + * @param namePrefix an optional prefix for the name + * @param name the name of the entry to find + * @return the entry or {@code null} + */ + public Entry getEntry(CharSequence namePrefix, CharSequence name) { + int nameHash = nameHash(namePrefix, name); + int lookupIndex = getFirstLookupIndex(nameHash); + int size = size(); + while (lookupIndex >= 0 && lookupIndex < size && this.nameHashLookups[lookupIndex] == nameHash) { + long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex); + ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos); + if (hasName(lookupIndex, centralRecord, pos, namePrefix, name)) { + return new Entry(lookupIndex, centralRecord); + } + lookupIndex++; + } + return null; + } + + /** + * Return if an entry with the given name exists. + * @param namePrefix an optional prefix for the name + * @param name the name of the entry to find + * @return the entry or {@code null} + */ + public boolean hasEntry(CharSequence namePrefix, CharSequence name) { + int nameHash = nameHash(namePrefix, name); + int lookupIndex = getFirstLookupIndex(nameHash); + int size = size(); + while (lookupIndex >= 0 && lookupIndex < size && this.nameHashLookups[lookupIndex] == nameHash) { + long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex); + ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos); + if (hasName(lookupIndex, centralRecord, pos, namePrefix, name)) { + return true; + } + lookupIndex++; + } + return false; + } + + /** + * Return the entry at the specified index. + * @param index the entry index + * @return the entry + * @throws IndexOutOfBoundsException if the index is out of bounds + */ + public Entry getEntry(int index) { + int lookupIndex = ZipContent.this.lookupIndexes[index]; + long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex); + ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos); + return new Entry(lookupIndex, centralRecord); + } + + private ZipCentralDirectoryFileHeaderRecord loadZipCentralDirectoryFileHeaderRecord(long pos) { + try { + return ZipCentralDirectoryFileHeaderRecord.load(this.data, pos); + } + catch (IOException ex) { + if (ex instanceof ClosedChannelException) { + throw new IllegalStateException("Zip content closed", ex); + } + throw new UncheckedIOException(ex); + } + } + + private int nameHash(CharSequence namePrefix, CharSequence name) { + int nameHash = 0; + nameHash = (namePrefix != null) ? ZipString.hash(nameHash, namePrefix, false) : nameHash; + nameHash = ZipString.hash(nameHash, name, true); + return nameHash; + } + + private int getFirstLookupIndex(int nameHash) { + int lookupIndex = Arrays.binarySearch(this.nameHashLookups, 0, this.nameHashLookups.length, nameHash); + if (lookupIndex < 0) { + return -1; + } + while (lookupIndex > 0 && this.nameHashLookups[lookupIndex - 1] == nameHash) { + lookupIndex--; + } + return lookupIndex; + } + + private long getCentralDirectoryFileHeaderRecordPos(int lookupIndex) { + return this.centralDirectoryPos + this.relativeCentralDirectoryOffsetLookups[lookupIndex]; + } + + private boolean hasName(int lookupIndex, ZipCentralDirectoryFileHeaderRecord centralRecord, long pos, + CharSequence namePrefix, CharSequence name) { + int offset = this.nameOffsetLookups.get(lookupIndex); + pos += ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + offset; + int len = centralRecord.fileNameLength() - offset; + ByteBuffer buffer = ByteBuffer.allocate(ZipString.BUFFER_SIZE); + if (namePrefix != null) { + int startsWithNamePrefix = ZipString.startsWith(buffer, this.data, pos, len, namePrefix); + if (startsWithNamePrefix == -1) { + return false; + } + pos += startsWithNamePrefix; + len -= startsWithNamePrefix; + } + return ZipString.matches(buffer, this.data, pos, len, name, true); + } + + /** + * Get or compute information based on the {@link ZipContent}. + * @param the info type to get or compute + * @param type the info type to get or compute + * @param function the function used to compute the information + * @return the computed or existing information + */ + @SuppressWarnings("unchecked") + public I getInfo(Class type, Function function) { + Map, Object> info = (this.info != null) ? this.info.get() : null; + if (info == null) { + info = new ConcurrentHashMap<>(); + this.info = new SoftReference<>(info); + } + return (I) info.computeIfAbsent(type, (key) -> { + debug.log("Getting %s info from zip '%s'", type.getName(), this); + return function.apply(this); + }); + } + + /** + * Returns {@code true} if this zip contains a jar signature file + * ({@code META-INF/*.DSA}). + * @return if the zip contains a jar signature file + */ + public boolean hasJarSignatureFile() { + return this.hasJarSignatureFile; + } + + /** + * Close this jar file, releasing the underlying file if this was the last reference. + * @see java.io.Closeable#close() + */ + @Override + public void close() throws IOException { + this.data.close(); + } + + @Override + public String toString() { + return this.source.toString(); + } + + /** + * Open {@link ZipContent} from the specified path. The resulting {@link ZipContent} + * must be {@link #close() closed} by the caller. + * @param path the zip path + * @return a {@link ZipContent} instance + * @throws IOException on I/O error + */ + public static ZipContent open(Path path) throws IOException { + return open(new Source(path.toAbsolutePath(), null)); + } + + /** + * Open nested {@link ZipContent} from the specified path. The resulting + * {@link ZipContent} must be {@link #close() closed} by the caller. + * @param path the zip path + * @param nestedEntryName the nested entry name to open + * @return a {@link ZipContent} instance + * @throws IOException on I/O error + */ + public static ZipContent open(Path path, String nestedEntryName) throws IOException { + return open(new Source(path.toAbsolutePath(), nestedEntryName)); + } + + private static ZipContent open(Source source) throws IOException { + ZipContent zipContent = cache.get(source); + if (zipContent != null) { + debug.log("Opening existing cached zip content for %s", zipContent); + zipContent.data.open(); + return zipContent; + } + debug.log("Loading zip content from %s", source); + zipContent = Loader.load(source); + ZipContent previouslyCached = cache.putIfAbsent(source, zipContent); + if (previouslyCached != null) { + debug.log("Closing zip content from %s since cache was populated from another thread", source); + zipContent.close(); + previouslyCached.data.open(); + return previouslyCached; + } + return zipContent; + } + + /** + * Zip content kinds. + * + * @since 3.2.2 + */ + public enum Kind { + + /** + * Content from a standard zip file. + */ + ZIP, + + /** + * Content from nested zip content. + */ + NESTED_ZIP, + + /** + * Content from a nested zip directory. + */ + NESTED_DIRECTORY + + } + + /** + * The source of {@link ZipContent}. Used as a cache key. + * + * @param path the path of the zip or container zip + * @param nestedEntryName the name of the nested entry to use or {@code null} + */ + private record Source(Path path, String nestedEntryName) { + + /** + * Return if this is the source of a nested zip. + * @return if this is for a nested zip + */ + boolean isNested() { + return this.nestedEntryName != null; + } + + @Override + public String toString() { + return (!isNested()) ? path().toString() : path() + "[" + nestedEntryName() + "]"; + } + + } + + /** + * Internal class used to load the zip content create a new {@link ZipContent} + * instance. + */ + private static final class Loader { + + private final ByteBuffer buffer = ByteBuffer.allocate(ZipString.BUFFER_SIZE); + + private final Source source; + + private final FileChannelDataBlock data; + + private final long centralDirectoryPos; + + private final int[] index; + + private int[] nameHashLookups; + + private int[] relativeCentralDirectoryOffsetLookups; + + private final NameOffsetLookups nameOffsetLookups; + + private int cursor; + + private Loader(Source source, Entry directoryEntry, FileChannelDataBlock data, long centralDirectoryPos, + int maxSize) { + this.source = source; + this.data = data; + this.centralDirectoryPos = centralDirectoryPos; + this.index = new int[maxSize]; + this.nameHashLookups = new int[maxSize]; + this.relativeCentralDirectoryOffsetLookups = new int[maxSize]; + this.nameOffsetLookups = (directoryEntry != null) + ? new NameOffsetLookups(directoryEntry.getName().length(), maxSize) : NameOffsetLookups.NONE; + } + + private void add(ZipCentralDirectoryFileHeaderRecord centralRecord, long pos, boolean enableNameOffset) + throws IOException { + int nameOffset = this.nameOffsetLookups.enable(this.cursor, enableNameOffset); + int hash = ZipString.hash(this.buffer, this.data, + pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + nameOffset, + centralRecord.fileNameLength() - nameOffset, true); + this.nameHashLookups[this.cursor] = hash; + this.relativeCentralDirectoryOffsetLookups[this.cursor] = (int) ((pos - this.centralDirectoryPos)); + this.index[this.cursor] = this.cursor; + this.cursor++; + } + + private ZipContent finish(Kind kind, long commentPos, long commentLength, boolean hasJarSignatureFile) { + if (this.cursor != this.nameHashLookups.length) { + this.nameHashLookups = Arrays.copyOf(this.nameHashLookups, this.cursor); + this.relativeCentralDirectoryOffsetLookups = Arrays.copyOf(this.relativeCentralDirectoryOffsetLookups, + this.cursor); + } + int size = this.nameHashLookups.length; + sort(0, size - 1); + int[] lookupIndexes = new int[size]; + for (int i = 0; i < size; i++) { + lookupIndexes[this.index[i]] = i; + } + return new ZipContent(this.source, kind, this.data, this.centralDirectoryPos, commentPos, commentLength, + lookupIndexes, this.nameHashLookups, this.relativeCentralDirectoryOffsetLookups, + this.nameOffsetLookups, hasJarSignatureFile); + } + + private void sort(int left, int right) { + // Quick sort algorithm, uses nameHashCode as the source but sorts all arrays + if (left < right) { + int pivot = this.nameHashLookups[left + (right - left) / 2]; + int i = left; + int j = right; + while (i <= j) { + while (this.nameHashLookups[i] < pivot) { + i++; + } + while (this.nameHashLookups[j] > pivot) { + j--; + } + if (i <= j) { + swap(i, j); + i++; + j--; + } + } + if (left < j) { + sort(left, j); + } + if (right > i) { + sort(i, right); + } + } + } + + private void swap(int i, int j) { + swap(this.index, i, j); + swap(this.nameHashLookups, i, j); + swap(this.relativeCentralDirectoryOffsetLookups, i, j); + this.nameOffsetLookups.swap(i, j); + } + + private static void swap(int[] array, int i, int j) { + int temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + + static ZipContent load(Source source) throws IOException { + if (!source.isNested()) { + return loadNonNested(source); + } + try (ZipContent zip = open(source.path())) { + Entry entry = zip.getEntry(source.nestedEntryName()); + if (entry == null) { + throw new IOException("Nested entry '%s' not found in container zip '%s'" + .formatted(source.nestedEntryName(), source.path())); + } + return (!entry.isDirectory()) ? loadNestedZip(source, entry) : loadNestedDirectory(source, zip, entry); + } + } + + private static ZipContent loadNonNested(Source source) throws IOException { + debug.log("Loading non-nested zip '%s'", source.path()); + return openAndLoad(source, Kind.ZIP, new FileChannelDataBlock(source.path())); + } + + private static ZipContent loadNestedZip(Source source, Entry entry) throws IOException { + if (entry.centralRecord.compressionMethod() != ZipEntry.STORED) { + throw new IOException("Nested entry '%s' in container zip '%s' must not be compressed" + .formatted(source.nestedEntryName(), source.path())); + } + debug.log("Loading nested zip entry '%s' from '%s'", source.nestedEntryName(), source.path()); + return openAndLoad(source, Kind.NESTED_ZIP, entry.getContent()); + } + + private static ZipContent openAndLoad(Source source, Kind kind, FileChannelDataBlock data) throws IOException { + try { + data.open(); + return loadContent(source, kind, data); + } + catch (IOException | RuntimeException ex) { + data.close(); + throw ex; + } + } + + private static ZipContent loadContent(Source source, Kind kind, FileChannelDataBlock data) throws IOException { + ZipEndOfCentralDirectoryRecord.Located locatedEocd = ZipEndOfCentralDirectoryRecord.load(data); + ZipEndOfCentralDirectoryRecord eocd = locatedEocd.endOfCentralDirectoryRecord(); + long eocdPos = locatedEocd.pos(); + Zip64EndOfCentralDirectoryLocator zip64Locator = Zip64EndOfCentralDirectoryLocator.find(data, eocdPos); + Zip64EndOfCentralDirectoryRecord zip64Eocd = Zip64EndOfCentralDirectoryRecord.load(data, zip64Locator); + data = data.slice(getStartOfZipContent(data, eocd, zip64Eocd)); + long centralDirectoryPos = (zip64Eocd != null) ? zip64Eocd.offsetToStartOfCentralDirectory() + : Integer.toUnsignedLong(eocd.offsetToStartOfCentralDirectory()); + long numberOfEntries = (zip64Eocd != null) ? zip64Eocd.totalNumberOfCentralDirectoryEntries() + : Short.toUnsignedInt(eocd.totalNumberOfCentralDirectoryEntries()); + if (numberOfEntries < 0) { + throw new IllegalStateException("Invalid number of zip entries in " + source); + } + if (numberOfEntries > Integer.MAX_VALUE) { + throw new IllegalStateException("Too many zip entries in " + source); + } + Loader loader = new Loader(source, null, data, centralDirectoryPos, (int) numberOfEntries); + ByteBuffer signatureNameSuffixBuffer = ByteBuffer.allocate(SIGNATURE_SUFFIX.length); + boolean hasJarSignatureFile = false; + long pos = centralDirectoryPos; + for (int i = 0; i < numberOfEntries; i++) { + ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos); + if (!hasJarSignatureFile) { + long filenamePos = pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET; + if (centralRecord.fileNameLength() > SIGNATURE_SUFFIX.length && ZipString.startsWith(loader.buffer, + data, filenamePos, centralRecord.fileNameLength(), META_INF) >= 0) { + signatureNameSuffixBuffer.clear(); + data.readFully(signatureNameSuffixBuffer, + filenamePos + centralRecord.fileNameLength() - SIGNATURE_SUFFIX.length); + hasJarSignatureFile = Arrays.equals(SIGNATURE_SUFFIX, signatureNameSuffixBuffer.array()); + } + } + loader.add(centralRecord, pos, false); + pos += centralRecord.size(); + } + long commentPos = locatedEocd.pos() + ZipEndOfCentralDirectoryRecord.COMMENT_OFFSET; + return loader.finish(kind, commentPos, eocd.commentLength(), hasJarSignatureFile); + } + + /** + * Returns the location in the data that the archive actually starts. For most + * files the archive data will start at 0, however, it is possible to have + * prefixed bytes (often used for startup scripts) at the beginning of the data. + * @param data the source data + * @param eocd the end of central directory record + * @param zip64Eocd the zip64 end of central directory record or {@code null} + * @return the offset within the data where the archive begins + * @throws IOException on I/O error + */ + private static long getStartOfZipContent(FileChannelDataBlock data, ZipEndOfCentralDirectoryRecord eocd, + Zip64EndOfCentralDirectoryRecord zip64Eocd) throws IOException { + long specifiedOffsetToStartOfCentralDirectory = (zip64Eocd != null) + ? zip64Eocd.offsetToStartOfCentralDirectory() : eocd.offsetToStartOfCentralDirectory(); + long sizeOfCentralDirectoryAndEndRecords = getSizeOfCentralDirectoryAndEndRecords(eocd, zip64Eocd); + long actualOffsetToStartOfCentralDirectory = data.size() - sizeOfCentralDirectoryAndEndRecords; + return actualOffsetToStartOfCentralDirectory - specifiedOffsetToStartOfCentralDirectory; + } + + private static long getSizeOfCentralDirectoryAndEndRecords(ZipEndOfCentralDirectoryRecord eocd, + Zip64EndOfCentralDirectoryRecord zip64Eocd) { + long result = 0; + result += eocd.size(); + if (zip64Eocd != null) { + result += Zip64EndOfCentralDirectoryLocator.SIZE; + result += zip64Eocd.size(); + } + result += (zip64Eocd != null) ? zip64Eocd.sizeOfCentralDirectory() : eocd.sizeOfCentralDirectory(); + return result; + } + + private static ZipContent loadNestedDirectory(Source source, ZipContent zip, Entry directoryEntry) + throws IOException { + debug.log("Loading nested directory entry '%s' from '%s'", source.nestedEntryName(), source.path()); + if (!source.nestedEntryName().endsWith("/")) { + throw new IllegalArgumentException("Nested entry name must end with '/'"); + } + String directoryName = directoryEntry.getName(); + zip.data.open(); + try { + Loader loader = new Loader(source, directoryEntry, zip.data, zip.centralDirectoryPos, zip.size()); + for (int cursor = 0; cursor < zip.size(); cursor++) { + int index = zip.lookupIndexes[cursor]; + if (index != directoryEntry.getLookupIndex()) { + long pos = zip.getCentralDirectoryFileHeaderRecordPos(index); + ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord + .load(zip.data, pos); + long namePos = pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET; + short nameLen = centralRecord.fileNameLength(); + if (ZipString.startsWith(loader.buffer, zip.data, namePos, nameLen, directoryName) != -1) { + loader.add(centralRecord, pos, true); + } + } + } + return loader.finish(Kind.NESTED_DIRECTORY, zip.commentPos, zip.commentLength, zip.hasJarSignatureFile); + } + catch (IOException | RuntimeException ex) { + zip.data.close(); + throw ex; + } + } + + } + + /** + * A single zip content entry. + */ + public class Entry { + + private final int lookupIndex; + + private final ZipCentralDirectoryFileHeaderRecord centralRecord; + + private volatile String name; + + private volatile FileChannelDataBlock content; + + /** + * Create a new {@link Entry} instance. + * @param lookupIndex the lookup index of the entry + * @param centralRecord the {@link ZipCentralDirectoryFileHeaderRecord} for the + * entry + */ + Entry(int lookupIndex, ZipCentralDirectoryFileHeaderRecord centralRecord) { + this.lookupIndex = lookupIndex; + this.centralRecord = centralRecord; + } + + /** + * Return the lookup index of the entry. Each entry has a unique lookup index but + * they aren't the same as the order that the entry was loaded. + * @return the entry lookup index + */ + public int getLookupIndex() { + return this.lookupIndex; + } + + /** + * Return {@code true} if this is a directory entry. + * @return if the entry is a directory + */ + public boolean isDirectory() { + return getName().endsWith("/"); + } + + /** + * Returns {@code true} if this entry has a name starting with the given prefix. + * @param prefix the required prefix + * @return if the entry name starts with the prefix + */ + public boolean hasNameStartingWith(CharSequence prefix) { + String name = this.name; + if (name != null) { + return name.startsWith(prefix.toString()); + } + long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex) + + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET; + return ZipString.startsWith(null, ZipContent.this.data, pos, this.centralRecord.fileNameLength(), + prefix) != -1; + } + + /** + * Return the name of this entry. + * @return the entry name + */ + public String getName() { + String name = this.name; + if (name == null) { + int offset = ZipContent.this.nameOffsetLookups.get(this.lookupIndex); + long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex) + + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + offset; + name = ZipString.readString(ZipContent.this.data, pos, this.centralRecord.fileNameLength() - offset); + this.name = name; + } + return name; + } + + /** + * Return the compression method for this entry. + * @return the compression method + * @see ZipEntry#STORED + * @see ZipEntry#DEFLATED + */ + public int getCompressionMethod() { + return this.centralRecord.compressionMethod(); + } + + /** + * Return the uncompressed size of this entry. + * @return the uncompressed size + */ + public int getUncompressedSize() { + return this.centralRecord.uncompressedSize(); + } + + /** + * Open a {@link DataBlock} providing access to raw contents of the entry (not + * including the local file header). + *

+ * To release resources, the {@link #close()} method of the data block should be + * called explicitly or by try-with-resources. + * @return the contents of the entry + * @throws IOException on I/O error + */ + public CloseableDataBlock openContent() throws IOException { + FileChannelDataBlock content = getContent(); + content.open(); + return content; + } + + private FileChannelDataBlock getContent() throws IOException { + FileChannelDataBlock content = this.content; + if (content == null) { + int pos = this.centralRecord.offsetToLocalHeader(); + checkNotZip64Extended(pos); + ZipLocalFileHeaderRecord localHeader = ZipLocalFileHeaderRecord.load(ZipContent.this.data, pos); + int size = this.centralRecord.compressedSize(); + checkNotZip64Extended(size); + content = ZipContent.this.data.slice(pos + localHeader.size(), size); + this.content = content; + } + return content; + } + + private void checkNotZip64Extended(int value) throws IOException { + if (value == 0xFFFFFFFF) { + throw new IOException("Zip64 extended information extra fields are not supported"); + } + } + + /** + * Adapt the raw entry into a {@link ZipEntry} or {@link ZipEntry} subclass. + * @param the entry type + * @param factory the factory used to create the {@link ZipEntry} + * @return a fully populated zip entry + */ + public E as(Function factory) { + return as((entry, name) -> factory.apply(name)); + } + + /** + * Adapt the raw entry into a {@link ZipEntry} or {@link ZipEntry} subclass. + * @param the entry type + * @param factory the factory used to create the {@link ZipEntry} + * @return a fully populated zip entry + */ + public E as(BiFunction factory) { + try { + E result = factory.apply(this, getName()); + long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex); + this.centralRecord.copyTo(ZipContent.this.data, pos, result); + return result; + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecord.java new file mode 100644 index 000000000000..af3a85027ec8 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecord.java @@ -0,0 +1,120 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A ZIP File "Data Descriptor" record. + * + * @param includeSignature if the signature bytes are written or not (see note in spec) + * @param crc32 the CRC32 checksum + * @param compressedSize the size of the entry when compressed + * @param uncompressedSize the size of the entry when uncompressed + * @author Phillip Webb + * @see Chapter + * 4.3.9 of the Zip File Format Specification + */ +record ZipDataDescriptorRecord(boolean includeSignature, int crc32, int compressedSize, int uncompressedSize) { + + private static final DebugLogger debug = DebugLogger.get(ZipDataDescriptorRecord.class); + + private static final int SIGNATURE = 0x08074b50; + + private static final int DATA_SIZE = 12; + + private static final int SIGNATURE_SIZE = 4; + + long size() { + return (!includeSignature()) ? DATA_SIZE : DATA_SIZE + SIGNATURE_SIZE; + } + + /** + * Return the contents of this record as a byte array suitable for writing to a zip. + * @return the record as a byte array + */ + byte[] asByteArray() { + ByteBuffer buffer = ByteBuffer.allocate((int) size()); + buffer.order(ByteOrder.LITTLE_ENDIAN); + if (this.includeSignature) { + buffer.putInt(SIGNATURE); + } + buffer.putInt(this.crc32); + buffer.putInt(this.compressedSize); + buffer.putInt(this.uncompressedSize); + return buffer.array(); + } + + /** + * Load the {@link ZipDataDescriptorRecord} from the given data block. + * @param dataBlock the source data block + * @param pos the position of the record + * @return a new {@link ZipLocalFileHeaderRecord} instance + * @throws IOException on I/O error + */ + static ZipDataDescriptorRecord load(DataBlock dataBlock, long pos) throws IOException { + debug.log("Loading ZipDataDescriptorRecord from position %s", pos); + ByteBuffer buffer = ByteBuffer.allocate(SIGNATURE_SIZE + DATA_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.limit(SIGNATURE_SIZE); + dataBlock.readFully(buffer, pos); + buffer.rewind(); + int signatureOrCrc = buffer.getInt(); + boolean hasSignature = (signatureOrCrc == SIGNATURE); + buffer.rewind(); + buffer.limit((!hasSignature) ? DATA_SIZE - SIGNATURE_SIZE : DATA_SIZE); + dataBlock.readFully(buffer, pos + SIGNATURE_SIZE); + buffer.rewind(); + return new ZipDataDescriptorRecord(hasSignature, (!hasSignature) ? signatureOrCrc : buffer.getInt(), + buffer.getInt(), buffer.getInt()); + } + + /** + * Return if the {@link ZipDataDescriptorRecord} is present based on the general + * purpose bit flag in the given {@link ZipLocalFileHeaderRecord}. + * @param localRecord the local record to check + * @return if the bit flag is set + */ + static boolean isPresentBasedOnFlag(ZipLocalFileHeaderRecord localRecord) { + return isPresentBasedOnFlag(localRecord.generalPurposeBitFlag()); + } + + /** + * Return if the {@link ZipDataDescriptorRecord} is present based on the general + * purpose bit flag in the given {@link ZipCentralDirectoryFileHeaderRecord}. + * @param centralRecord the central record to check + * @return if the bit flag is set + */ + static boolean isPresentBasedOnFlag(ZipCentralDirectoryFileHeaderRecord centralRecord) { + return isPresentBasedOnFlag(centralRecord.generalPurposeBitFlag()); + } + + /** + * Return if the {@link ZipDataDescriptorRecord} is present based on the given general + * purpose bit flag. + * @param generalPurposeBitFlag the general purpose bit flag to check + * @return if the bit flag is set + */ + static boolean isPresentBasedOnFlag(int generalPurposeBitFlag) { + return (generalPurposeBitFlag & 0b0000_1000) != 0; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java new file mode 100644 index 000000000000..af2d8e57bf85 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java @@ -0,0 +1,160 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A ZIP File "End of central directory record" (EOCD). + * + * @author Phillip Webb + * @param numberOfThisDisk the number of this disk (or 0xffff for Zip64) + * @param diskWhereCentralDirectoryStarts the disk where central directory starts (or + * 0xffff for Zip64) + * @param numberOfCentralDirectoryEntriesOnThisDisk the number of central directory + * entries on this disk (or 0xffff for Zip64) + * @param totalNumberOfCentralDirectoryEntries the total number of central directory + * entries (or 0xffff for Zip64) + * @param sizeOfCentralDirectory the size of central directory (bytes) (or 0xffffffff for + * Zip64) + * @param offsetToStartOfCentralDirectory the offset of start of central directory, + * relative to start of archive (or 0xffffffff for Zip64) + * @param commentLength the length of the comment field + * @see Chapter + * 4.3.16 of the Zip File Format Specification + */ +record ZipEndOfCentralDirectoryRecord(short numberOfThisDisk, short diskWhereCentralDirectoryStarts, + short numberOfCentralDirectoryEntriesOnThisDisk, short totalNumberOfCentralDirectoryEntries, + int sizeOfCentralDirectory, int offsetToStartOfCentralDirectory, short commentLength) { + + ZipEndOfCentralDirectoryRecord(short totalNumberOfCentralDirectoryEntries, int sizeOfCentralDirectory, + int offsetToStartOfCentralDirectory) { + this((short) 0, (short) 0, totalNumberOfCentralDirectoryEntries, totalNumberOfCentralDirectoryEntries, + sizeOfCentralDirectory, offsetToStartOfCentralDirectory, (short) 0); + } + + private static final DebugLogger debug = DebugLogger.get(ZipEndOfCentralDirectoryRecord.class); + + private static final int SIGNATURE = 0x06054b50; + + private static final int MAXIMUM_COMMENT_LENGTH = 0xFFFF; + + private static final int MINIMUM_SIZE = 22; + + private static final int MAXIMUM_SIZE = MINIMUM_SIZE + MAXIMUM_COMMENT_LENGTH; + + static final int BUFFER_SIZE = 256; + + /** + * The offset of the file comment relative to the record start position. + */ + static final int COMMENT_OFFSET = MINIMUM_SIZE; + + /** + * Return the size of this record. + * @return the record size + */ + long size() { + return MINIMUM_SIZE + this.commentLength; + } + + /** + * Return the contents of this record as a byte array suitable for writing to a zip. + * @return the record as a byte array + */ + byte[] asByteArray() { + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(SIGNATURE); + buffer.putShort(this.numberOfThisDisk); + buffer.putShort(this.diskWhereCentralDirectoryStarts); + buffer.putShort(this.numberOfCentralDirectoryEntriesOnThisDisk); + buffer.putShort(this.totalNumberOfCentralDirectoryEntries); + buffer.putInt(this.sizeOfCentralDirectory); + buffer.putInt(this.offsetToStartOfCentralDirectory); + buffer.putShort(this.commentLength); + return buffer.array(); + } + + /** + * Create a new {@link ZipEndOfCentralDirectoryRecord} instance from the specified + * {@link DataBlock} by searching backwards from the end until a valid record is + * located. + * @param dataBlock the source data block + * @return the {@link Located located} {@link ZipEndOfCentralDirectoryRecord} + * @throws IOException if the {@link ZipEndOfCentralDirectoryRecord} cannot be read + */ + static Located load(DataBlock dataBlock) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + long pos = locate(dataBlock, buffer); + return new Located(pos, new ZipEndOfCentralDirectoryRecord(buffer.getShort(), buffer.getShort(), + buffer.getShort(), buffer.getShort(), buffer.getInt(), buffer.getInt(), buffer.getShort())); + } + + private static long locate(DataBlock dataBlock, ByteBuffer buffer) throws IOException { + long endPos = dataBlock.size(); + debug.log("Finding EndOfCentralDirectoryRecord starting at end position %s", endPos); + while (endPos > 0) { + buffer.clear(); + long totalRead = dataBlock.size() - endPos; + if (totalRead > MAXIMUM_SIZE) { + throw new IOException( + "Zip 'End Of Central Directory Record' not found after reading " + totalRead + " bytes"); + } + long startPos = endPos - buffer.limit(); + if (startPos < 0) { + buffer.limit((int) startPos + buffer.limit()); + startPos = 0; + } + debug.log("Finding EndOfCentralDirectoryRecord from %s with limit %s", startPos, buffer.limit()); + dataBlock.readFully(buffer, startPos); + int offset = findInBuffer(buffer); + if (offset >= 0) { + debug.log("Found EndOfCentralDirectoryRecord at %s + %s", startPos, offset); + return startPos + offset; + } + endPos = endPos - BUFFER_SIZE + MINIMUM_SIZE; + } + throw new IOException("Zip 'End Of Central Directory Record' not found after reading entire data block"); + } + + private static int findInBuffer(ByteBuffer buffer) { + for (int pos = buffer.limit() - 4; pos >= 0; pos--) { + buffer.position(pos); + if (buffer.getInt() == SIGNATURE) { + return pos; + } + } + return -1; + } + + /** + * A located {@link ZipEndOfCentralDirectoryRecord}. + * + * @param pos the position of the record + * @param endOfCentralDirectoryRecord the located end of central directory record + */ + record Located(long pos, ZipEndOfCentralDirectoryRecord endOfCentralDirectoryRecord) { + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java new file mode 100644 index 000000000000..daed69afb9b7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A ZIP File "Local file header record" (LFH). + * + * @param versionNeededToExtract the version needed to extract the zip + * @param generalPurposeBitFlag the general purpose bit flag + * @param compressionMethod the compression method used for this entry + * @param lastModFileTime the last modified file time + * @param lastModFileDate the last modified file date + * @param crc32 the CRC32 checksum + * @param compressedSize the size of the entry when compressed + * @param uncompressedSize the size of the entry when uncompressed + * @param fileNameLength the file name length + * @param extraFieldLength the extra field length + * @author Phillip Webb + * @see Chapter + * 4.3.7 of the Zip File Format Specification + */ +record ZipLocalFileHeaderRecord(short versionNeededToExtract, short generalPurposeBitFlag, short compressionMethod, + short lastModFileTime, short lastModFileDate, int crc32, int compressedSize, int uncompressedSize, + short fileNameLength, short extraFieldLength) { + + private static final DebugLogger debug = DebugLogger.get(ZipLocalFileHeaderRecord.class); + + private static final int SIGNATURE = 0x04034b50; + + private static final int MINIMUM_SIZE = 30; + + /** + * Return the size of this record. + * @return the record size + */ + long size() { + return MINIMUM_SIZE + fileNameLength() + extraFieldLength(); + } + + /** + * Return a new {@link ZipLocalFileHeaderRecord} with a new + * {@link #extraFieldLength()}. + * @param extraFieldLength the new extra field length + * @return a new {@link ZipLocalFileHeaderRecord} instance + */ + ZipLocalFileHeaderRecord withExtraFieldLength(short extraFieldLength) { + return new ZipLocalFileHeaderRecord(this.versionNeededToExtract, this.generalPurposeBitFlag, + this.compressionMethod, this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize, + this.uncompressedSize, this.fileNameLength, extraFieldLength); + } + + /** + * Return a new {@link ZipLocalFileHeaderRecord} with a new {@link #fileNameLength()}. + * @param fileNameLength the new file name length + * @return a new {@link ZipLocalFileHeaderRecord} instance + */ + ZipLocalFileHeaderRecord withFileNameLength(short fileNameLength) { + return new ZipLocalFileHeaderRecord(this.versionNeededToExtract, this.generalPurposeBitFlag, + this.compressionMethod, this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize, + this.uncompressedSize, fileNameLength, this.extraFieldLength); + } + + /** + * Return the contents of this record as a byte array suitable for writing to a zip. + * @return the record as a byte array + */ + byte[] asByteArray() { + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(SIGNATURE); + buffer.putShort(this.versionNeededToExtract); + buffer.putShort(this.generalPurposeBitFlag); + buffer.putShort(this.compressionMethod); + buffer.putShort(this.lastModFileTime); + buffer.putShort(this.lastModFileDate); + buffer.putInt(this.crc32); + buffer.putInt(this.compressedSize); + buffer.putInt(this.uncompressedSize); + buffer.putShort(this.fileNameLength); + buffer.putShort(this.extraFieldLength); + return buffer.array(); + } + + /** + * Load the {@link ZipLocalFileHeaderRecord} from the given data block. + * @param dataBlock the source data block + * @param pos the position of the record + * @return a new {@link ZipLocalFileHeaderRecord} instance + * @throws IOException on I/O error + */ + static ZipLocalFileHeaderRecord load(DataBlock dataBlock, long pos) throws IOException { + debug.log("Loading LocalFileHeaderRecord from position %s", pos); + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + dataBlock.readFully(buffer, pos); + buffer.rewind(); + if (buffer.getInt() != SIGNATURE) { + throw new IOException("Zip 'Local File Header Record' not found at position " + pos); + } + return new ZipLocalFileHeaderRecord(buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getShort(), + buffer.getShort(), buffer.getInt(), buffer.getInt(), buffer.getInt(), buffer.getShort(), + buffer.getShort()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java new file mode 100644 index 000000000000..4533f45a51fc --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java @@ -0,0 +1,326 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.EOFException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * Internal utility class for working with the string content of zip records. Provides + * methods that work with raw bytes to save creating temporary strings. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +final class ZipString { + + private static final DebugLogger debug = DebugLogger.get(ZipString.class); + + static final int BUFFER_SIZE = 256; + + private static final int[] INITIAL_BYTE_BITMASK = { 0x7F, 0x1F, 0x0F, 0x07 }; + + private static final int SUBSEQUENT_BYTE_BITMASK = 0x3F; + + private static final int EMPTY_HASH = "".hashCode(); + + private static final int EMPTY_SLASH_HASH = "/".hashCode(); + + private ZipString() { + } + + /** + * Return a hash for a char sequence, optionally appending '/'. + * @param charSequence the source char sequence + * @param addEndSlash if slash should be added to the string if it's not already + * present + * @return the hash + */ + static int hash(CharSequence charSequence, boolean addEndSlash) { + return hash(0, charSequence, addEndSlash); + } + + /** + * Return a hash for a char sequence, optionally appending '/'. + * @param initialHash the initial hash value + * @param charSequence the source char sequence + * @param addEndSlash if slash should be added to the string if it's not already + * present + * @return the hash + */ + static int hash(int initialHash, CharSequence charSequence, boolean addEndSlash) { + if (charSequence == null || charSequence.isEmpty()) { + return (!addEndSlash) ? EMPTY_HASH : EMPTY_SLASH_HASH; + } + boolean endsWithSlash = charSequence.charAt(charSequence.length() - 1) == '/'; + int hash = initialHash; + if (charSequence instanceof String && initialHash == 0) { + // We're compatible with String.hashCode and it might be already calculated + hash = charSequence.hashCode(); + } + else { + for (int i = 0; i < charSequence.length(); i++) { + char ch = charSequence.charAt(i); + hash = 31 * hash + ch; + } + } + hash = (addEndSlash && !endsWithSlash) ? 31 * hash + '/' : hash; + debug.log("%s calculated for charsequence '%s' (addEndSlash=%s)", hash, charSequence, endsWithSlash); + return hash; + } + + /** + * Return a hash for bytes read from a {@link DataBlock}, optionally appending '/'. + * @param buffer the buffer to use or {@code null} + * @param dataBlock the source data block + * @param pos the position in the data block where the string starts + * @param len the number of bytes to read from the block + * @param addEndSlash if slash should be added to the string if it's not already + * present + * @return the hash + * @throws IOException on I/O error + */ + static int hash(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, boolean addEndSlash) throws IOException { + if (len == 0) { + return (!addEndSlash) ? EMPTY_HASH : EMPTY_SLASH_HASH; + } + buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE); + byte[] bytes = buffer.array(); + int hash = 0; + char lastChar = 0; + int codePointSize = 1; + while (len > 0) { + int count = readInBuffer(dataBlock, pos, buffer, len, codePointSize); + for (int byteIndex = 0; byteIndex < count;) { + codePointSize = getCodePointSize(bytes, byteIndex); + if (!hasEnoughBytes(byteIndex, codePointSize, count)) { + break; + } + int codePoint = getCodePoint(bytes, byteIndex, codePointSize); + if (codePoint <= 0xFFFF) { + lastChar = (char) (codePoint & 0xFFFF); + hash = 31 * hash + lastChar; + } + else { + lastChar = 0; + hash = 31 * hash + Character.highSurrogate(codePoint); + hash = 31 * hash + Character.lowSurrogate(codePoint); + } + byteIndex += codePointSize; + pos += codePointSize; + len -= codePointSize; + codePointSize = 1; + } + } + hash = (addEndSlash && lastChar != '/') ? 31 * hash + '/' : hash; + debug.log("%08X calculated for datablock position %s size %s (addEndSlash=%s)", hash, pos, len, addEndSlash); + return hash; + } + + /** + * Return if the bytes read from a {@link DataBlock} matches the give + * {@link CharSequence}. + * @param buffer the buffer to use or {@code null} + * @param dataBlock the source data block + * @param pos the position in the data block where the string starts + * @param len the number of bytes to read from the block + * @param charSequence the char sequence with which to compare + * @param addSlash also accept {@code charSequence + '/'} when it doesn't already end + * with one + * @return true if the contents are considered equal + */ + static boolean matches(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence, + boolean addSlash) { + if (charSequence.isEmpty()) { + return true; + } + buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE); + try { + return compare(buffer, dataBlock, pos, len, charSequence, + (!addSlash) ? CompareType.MATCHES : CompareType.MATCHES_ADDING_SLASH) != -1; + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * Returns if the bytes read from a {@link DataBlock} starts with the given + * {@link CharSequence}. + * @param buffer the buffer to use or {@code null} + * @param dataBlock the source data block + * @param pos the position in the data block where the string starts + * @param len the number of bytes to read from the block + * @param charSequence the required starting chars + * @return {@code -1} if the data block does not start with the char sequence, or a + * positive number indicating the number of bytes that contain the starting chars + */ + static int startsWith(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence) { + if (charSequence.isEmpty()) { + return 0; + } + buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE); + try { + return compare(buffer, dataBlock, pos, len, charSequence, CompareType.STARTS_WITH); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static int compare(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence, + CompareType compareType) throws IOException { + if (charSequence.isEmpty()) { + return 0; + } + boolean addSlash = compareType == CompareType.MATCHES_ADDING_SLASH && !endsWith(charSequence, '/'); + int charSequenceIndex = 0; + int maxCharSequenceLength = (!addSlash) ? charSequence.length() : charSequence.length() + 1; + int result = 0; + byte[] bytes = buffer.array(); + int codePointSize = 1; + while (len > 0) { + int count = readInBuffer(dataBlock, pos, buffer, len, codePointSize); + for (int byteIndex = 0; byteIndex < count;) { + codePointSize = getCodePointSize(bytes, byteIndex); + if (!hasEnoughBytes(byteIndex, codePointSize, count)) { + break; + } + int codePoint = getCodePoint(bytes, byteIndex, codePointSize); + if (codePoint <= 0xFFFF) { + char ch = (char) (codePoint & 0xFFFF); + if (charSequenceIndex >= maxCharSequenceLength + || getChar(charSequence, charSequenceIndex++) != ch) { + return -1; + } + } + else { + char ch = Character.highSurrogate(codePoint); + if (charSequenceIndex >= maxCharSequenceLength + || getChar(charSequence, charSequenceIndex++) != ch) { + return -1; + } + ch = Character.lowSurrogate(codePoint); + if (charSequenceIndex >= charSequence.length() + || getChar(charSequence, charSequenceIndex++) != ch) { + return -1; + } + } + byteIndex += codePointSize; + pos += codePointSize; + len -= codePointSize; + result += codePointSize; + codePointSize = 1; + if (compareType == CompareType.STARTS_WITH && charSequenceIndex >= charSequence.length()) { + return result; + } + } + } + return (charSequenceIndex >= charSequence.length()) ? result : -1; + } + + private static boolean hasEnoughBytes(int byteIndex, int codePointSize, int count) { + return (byteIndex + codePointSize - 1) < count; + } + + private static boolean endsWith(CharSequence charSequence, char ch) { + return !charSequence.isEmpty() && charSequence.charAt(charSequence.length() - 1) == ch; + } + + private static char getChar(CharSequence charSequence, int index) { + return (index != charSequence.length()) ? charSequence.charAt(index) : '/'; + } + + /** + * Read a string value from the given data block. + * @param data the source data + * @param pos the position to read from + * @param len the number of bytes to read + * @return the contents as a string + */ + static String readString(DataBlock data, long pos, long len) { + try { + if (len > Integer.MAX_VALUE) { + throw new IllegalStateException("String is too long to read"); + } + ByteBuffer buffer = ByteBuffer.allocate((int) len); + buffer.order(ByteOrder.LITTLE_ENDIAN); + data.readFully(buffer, pos); + return new String(buffer.array(), StandardCharsets.UTF_8); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static int readInBuffer(DataBlock dataBlock, long pos, ByteBuffer buffer, int maxLen, int minLen) + throws IOException { + buffer.clear(); + if (buffer.remaining() > maxLen) { + buffer.limit(maxLen); + } + int result = 0; + while (result < minLen) { + int count = dataBlock.read(buffer, pos); + if (count <= 0) { + throw new EOFException(); + } + result += count; + pos += count; + } + return result; + } + + private static int getCodePointSize(byte[] bytes, int i) { + int b = Byte.toUnsignedInt(bytes[i]); + if ((b & 0b1_0000000) == 0b0_0000000) { + return 1; + } + if ((b & 0b111_00000) == 0b110_00000) { + return 2; + } + if ((b & 0b1111_0000) == 0b1110_0000) { + return 3; + } + return 4; + } + + private static int getCodePoint(byte[] bytes, int i, int codePointSize) { + int codePoint = Byte.toUnsignedInt(bytes[i]); + codePoint &= INITIAL_BYTE_BITMASK[codePointSize - 1]; + for (int j = 1; j < codePointSize; j++) { + codePoint = (codePoint << 6) + (bytes[i + j] & SUBSEQUENT_BYTE_BITMASK); + } + return codePoint; + } + + /** + * Supported compare types. + */ + private enum CompareType { + + MATCHES, MATCHES_ADDING_SLASH, STARTS_WITH + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java new file mode 100644 index 000000000000..38bd93390b2e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-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. + */ + +/** + * Provides low-level support for handling zip content, including support for nested and + * virtual zip files. + */ +package org.springframework.boot.loader.zip; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/resources/META-INF/services/java.nio.file.spi.FileSystemProvider b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/resources/META-INF/services/java.nio.file.spi.FileSystemProvider new file mode 100644 index 000000000000..425737d36fdd --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/resources/META-INF/services/java.nio.file.spi.FileSystemProvider @@ -0,0 +1 @@ +org.springframework.boot.loader.nio.file.NestedFileSystemProvider diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/ManifestInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/ManifestInfoTests.java new file mode 100644 index 000000000000..619080b175a7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/ManifestInfoTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-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.boot.loader.jar; + +import java.util.jar.Attributes.Name; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ManifestInfo}. + * + * @author Phillip Webb + */ +class ManifestInfoTests { + + @Test + void noneReturnsNoDetails() { + assertThat(ManifestInfo.NONE.getManifest()).isNull(); + assertThat(ManifestInfo.NONE.isMultiRelease()).isFalse(); + } + + @Test + void getManifestReturnsManifest() { + Manifest manifest = new Manifest(); + ManifestInfo info = new ManifestInfo(manifest); + assertThat(info.getManifest()).isSameAs(manifest); + } + + @Test + void isMultiReleaseWhenHasMultiReleaseAttributeReturnsTrue() { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(new Name("Multi-Release"), "true"); + ManifestInfo info = new ManifestInfo(manifest); + assertThat(info.isMultiRelease()).isTrue(); + } + + @Test + void isMultiReleaseWhenHasNoMultiReleaseAttributeReturnsFalse() { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(new Name("Random-Release"), "true"); + ManifestInfo info = new ManifestInfo(manifest); + assertThat(info.isMultiRelease()).isFalse(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java new file mode 100644 index 000000000000..d556c9cbea57 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-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.boot.loader.jar; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.zip.ZipContent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link MetaInfVersionsInfo}. + * + * @author Phillip Webb + */ +class MetaInfVersionsInfoTests { + + @Test + void getParsesVersionsAndEntries() { + List entries = new ArrayList<>(); + entries.add(mockEntry("META-INF/")); + entries.add(mockEntry("META-INF/MANIFEST.MF")); + entries.add(mockEntry("META-INF/versions/")); + entries.add(mockEntry("META-INF/versions/9/")); + entries.add(mockEntry("META-INF/versions/9/Foo.class")); + entries.add(mockEntry("META-INF/versions/11/")); + entries.add(mockEntry("META-INF/versions/11/Foo.class")); + entries.add(mockEntry("META-INF/versions/10/")); + entries.add(mockEntry("META-INF/versions/10/Foo.class")); + MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get); + assertThat(info.versions()).containsExactly(9, 10, 11); + assertThat(info.directories()).containsExactly("META-INF/versions/9/", "META-INF/versions/10/", + "META-INF/versions/11/"); + } + + @Test + void getWhenHasBadEntryParsesGoodVersionsAndEntries() { + List entries = new ArrayList<>(); + entries.add(mockEntry("META-INF/versions/9/Foo.class")); + entries.add(mockEntry("META-INF/versions/0x11/Foo.class")); + MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get); + assertThat(info.versions()).containsExactly(9); + assertThat(info.directories()).containsExactly("META-INF/versions/9/"); + } + + @Test + void getWhenHasNoEntriesReturnsNone() { + List entries = new ArrayList<>(); + MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get); + assertThat(info.versions()).isEmpty(); + assertThat(info.directories()).isEmpty(); + assertThat(info).isSameAs(MetaInfVersionsInfo.NONE); + } + + private ZipContent.Entry mockEntry(String name) { + ZipContent.Entry entry = mock(ZipContent.Entry.class); + given(entry.getName()).willReturn(name); + given(entry.hasNameStartingWith(any())) + .willAnswer((invocation) -> name.startsWith(invocation.getArgument(0, CharSequence.class).toString())); + given(entry.isDirectory()).willAnswer((invocation) -> name.endsWith("/")); + return entry; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java new file mode 100644 index 000000000000..bcbd8a47f581 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java @@ -0,0 +1,429 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.jar; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.Cleaner.Cleanable; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.assertj.core.extractor.Extractors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.ZipContent; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StopWatch; +import org.springframework.util.StreamUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.atMostOnce; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NestedJarFile}. + * + * @author Phillip Webb + * @author Martin Lau + * @author Andy Wilkinson + * @author Madhura Bhave + */ +@AssertFileChannelDataBlocksClosed +class NestedJarFileTests { + + @TempDir + File tempDir; + + private File file; + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.tempDir, "test.jar"); + TestJar.create(this.file); + } + + @Test + void createOpensJar() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + try (JarFile jdkJar = new JarFile(this.file)) { + assertThat(jar.size()).isEqualTo(jdkJar.size()); + assertThat(jar.getComment()).isEqualTo(jdkJar.getComment()); + Enumeration entries = jar.entries(); + Enumeration jdkEntries = jdkJar.entries(); + while (entries.hasMoreElements()) { + assertThat(entries.nextElement().getName()).isEqualTo(jdkEntries.nextElement().getName()); + } + assertThat(jdkEntries.hasMoreElements()).isFalse(); + try (InputStream in = jar.getInputStream(jar.getEntry("1.dat"))) { + assertThat(in.readAllBytes()).containsExactly(new byte[] { 1 }); + } + } + } + } + + @Test + void createWhenNestedJarFileOpensJar() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) { + assertThat(jar.size()).isEqualTo(5); + assertThat(jar.stream().map(JarEntry::getName)).containsExactly("META-INF/", "META-INF/MANIFEST.MF", + "3.dat", "4.dat", "\u00E4.dat"); + } + } + + @Test + void createWhenNestedJarDirectoryOpensJar() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "d/")) { + assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath() + "!/d/"); + assertThat(jar.size()).isEqualTo(1); + assertThat(jar.stream().map(JarEntry::getName)).containsExactly("9.dat"); + } + } + + @Test + void getManifestWhenNestedJarReturnsManifestOfNestedJar() throws Exception { + try (JarFile jar = new JarFile(this.file)) { + try (NestedJarFile nestedJar = new NestedJarFile(this.file, "nested.jar")) { + Manifest manifest = nestedJar.getManifest(); + assertThat(manifest).isNotEqualTo(jar.getManifest()); + assertThat(manifest.getMainAttributes().getValue("Built-By")).isEqualTo("j2"); + } + } + } + + @Test + void getManifestWhenNestedJarDirectoryReturnsManifestOfParent() throws Exception { + try (JarFile jar = new JarFile(this.file)) { + try (NestedJarFile nestedJar = new NestedJarFile(this.file, "d/")) { + assertThat(nestedJar.getManifest()).isEqualTo(jar.getManifest()); + } + } + } + + @Test + void createWhenJarHasFrontMatterOpensJar() throws IOException { + File file = new File(this.tempDir, "frontmatter.jar"); + InputStream sourceJarContent = new FileInputStream(this.file); + FileOutputStream outputStream = new FileOutputStream(file); + StreamUtils.copy("#/bin/bash", Charset.defaultCharset(), outputStream); + FileCopyUtils.copy(sourceJarContent, outputStream); + try (NestedJarFile jar = new NestedJarFile(file)) { + assertThat(jar.size()).isEqualTo(12); + } + try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) { + assertThat(jar.size()).isEqualTo(5); + } + } + + @Test + void getEntryReturnsEntry() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + JarEntry entry = jar.getEntry("1.dat"); + assertEntryOne(entry); + } + } + + @Test + void getEntryWhenClosedThrowsException() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + jar.close(); + assertThatIllegalStateException().isThrownBy(() -> jar.getEntry("1.dat")).withMessage("Zip file closed"); + } + } + + @Test + void getJarEntryReturnsEntry() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + JarEntry entry = jar.getJarEntry("1.dat"); + assertEntryOne(entry); + } + } + + @Test + void getJarEntryWhenClosedThrowsException() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + jar.close(); + assertThatIllegalStateException().isThrownBy(() -> jar.getJarEntry("1.dat")).withMessage("Zip file closed"); + } + } + + private void assertEntryOne(JarEntry entry) { + assertThat(entry.getName()).isEqualTo("1.dat"); + assertThat(entry.getRealName()).isEqualTo("1.dat"); + assertThat(entry.getSize()).isEqualTo(1); + assertThat(entry.getCompressedSize()).isEqualTo(3); + assertThat(entry.getCrc()).isEqualTo(2768625435L); + assertThat(entry.getMethod()).isEqualTo(8); + } + + @Test + void getEntryWhenMultiReleaseEntryReturnsEntry() throws IOException { + File multiReleaseFile = new File(this.tempDir, "mutli.zip"); + try (ZipContent zip = ZipContent.open(this.file.toPath(), "multi-release.jar")) { + try (InputStream in = zip.openRawZipData().asInputStream()) { + try (FileOutputStream out = new FileOutputStream(multiReleaseFile)) { + in.transferTo(out); + } + } + } + try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar", JarFile.runtimeVersion())) { + try (JarFile jdkJar = new JarFile(multiReleaseFile, true, ZipFile.OPEN_READ, JarFile.runtimeVersion())) { + JarEntry entry = jar.getJarEntry("multi-release.dat"); + JarEntry jdkEntry = jdkJar.getJarEntry("multi-release.dat"); + assertThat(entry.getName()).isEqualTo(jdkEntry.getName()); + assertThat(entry.getRealName()).isEqualTo(jdkEntry.getRealName()); + try (InputStream inputStream = jdkJar.getInputStream(entry)) { + assertThat(inputStream.available()).isOne(); + assertThat(inputStream.read()).isEqualTo(Runtime.version().feature()); + } + try (InputStream inputStream = jar.getInputStream(entry)) { + assertThat(inputStream.available()).isOne(); + assertThat(inputStream.read()).isEqualTo(Runtime.version().feature()); + } + } + } + } + + @Test + void getManifestReturnsManifest() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + Manifest manifest = jar.getManifest(); + assertThat(manifest).isNotNull(); + assertThat(manifest.getEntries()).isEmpty(); + assertThat(manifest.getMainAttributes().getValue("Manifest-Version")).isEqualTo("1.0"); + } + } + + @Test + void getCommentReturnsComment() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + assertThat(jar.getComment()).isEqualTo("outer"); + } + } + + @Test + void getCommentWhenClosedThrowsException() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + jar.close(); + assertThatIllegalStateException().isThrownBy(() -> jar.getComment()).withMessage("Zip file closed"); + } + } + + @Test + void getNameReturnsName() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath()); + } + } + + @Test + void getNameWhenNestedReturnsName() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) { + assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath() + "!/nested.jar"); + } + } + + @Test + void sizeReturnsSize() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + assertThat(jar.size()).isEqualByComparingTo(12); + } + } + + @Test + void sizeWhenClosedThrowsException() throws Exception { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + jar.close(); + assertThatIllegalStateException().isThrownBy(() -> jar.size()).withMessage("Zip file closed"); + } + } + + @Test + void getEntryTime() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + try (JarFile jdkJar = new JarFile(this.file)) { + assertThat(jar.getEntry("META-INF/MANIFEST.MF").getTime()) + .isEqualTo(jar.getEntry("META-INF/MANIFEST.MF").getTime()); + } + } + } + + @Test + void closeTriggersCleanupOnlyOnce() throws IOException { + Cleaner cleaner = mock(Cleaner.class); + ArgumentCaptor action = ArgumentCaptor.forClass(Runnable.class); + Cleanable cleanable = mock(Cleanable.class); + given(cleaner.register(any(), action.capture())).willReturn(cleanable); + NestedJarFile jar = new NestedJarFile(this.file, null, null, false, cleaner); + jar.close(); + jar.close(); + then(cleanable).should(atMostOnce()).clean(); + action.getValue().run(); + } + + @Test + void cleanupFromReleasesResources() throws IOException { + Cleaner cleaner = mock(Cleaner.class); + ArgumentCaptor action = ArgumentCaptor.forClass(Runnable.class); + Cleanable cleanable = mock(Cleanable.class); + given(cleaner.register(any(), action.capture())).willReturn(cleanable); + try (NestedJarFile jar = new NestedJarFile(this.file, null, null, false, cleaner)) { + Object channel = Extractors.byName("resources.zipContent.data.channel").apply(jar); + assertThat(channel).extracting("referenceCount").isEqualTo(1); + action.getValue().run(); + assertThat(channel).extracting("referenceCount").isEqualTo(0); + } + } + + @Test + void getInputStreamReturnsInputStream() throws IOException { + try (NestedJarFile jarFile = new NestedJarFile(this.file)) { + JarEntry entry = jarFile.getJarEntry("2.dat"); + try (InputStream in = jarFile.getInputStream(entry)) { + assertThat(in).hasBinaryContent(new byte[] { 0x02 }); + } + } + } + + @Test + void getInputStreamWhenIsDirectory() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + try (InputStream inputStream = jar.getInputStream(jar.getEntry("d/"))) { + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(-1); + } + } + } + + @Test + void getInputStreamWhenNameWithoutSlashAndIsDirectory() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + try (InputStream inputStream = jar.getInputStream(jar.getEntry("d"))) { + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(-1); + } + } + } + + @Test + void verifySignedJar() throws Exception { + File signedJarFile = TestJar.getSigned(); + assertThat(signedJarFile).exists(); + try (JarFile expected = new JarFile(signedJarFile)) { + try (NestedJarFile actual = new NestedJarFile(signedJarFile)) { + StopWatch stopWatch = new StopWatch(); + Enumeration actualEntries = actual.entries(); + while (actualEntries.hasMoreElements()) { + JarEntry actualEntry = actualEntries.nextElement(); + JarEntry expectedEntry = expected.getJarEntry(actualEntry.getName()); + StreamUtils.drain(expected.getInputStream(expectedEntry)); + if (!actualEntry.getName().equals("META-INF/MANIFEST.MF")) { + assertThat(actualEntry.getCertificates()).as(actualEntry.getName()) + .isEqualTo(expectedEntry.getCertificates()); + assertThat(actualEntry.getCodeSigners()).as(actualEntry.getName()) + .isEqualTo(expectedEntry.getCodeSigners()); + } + } + assertThat(stopWatch.getTotalTimeSeconds()).isLessThan(3.0); + } + } + } + + @Test + void closeAllowsFileToBeDeleted() throws Exception { + new NestedJarFile(this.file).close(); + assertThat(this.file.delete()).isTrue(); + } + + @Test + void streamStreamsEntries() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar")) { + assertThat(jar.stream().map((entry) -> entry.getName() + ":" + entry.getRealName())).containsExactly( + "META-INF/:META-INF/", "META-INF/MANIFEST.MF:META-INF/MANIFEST.MF", + "multi-release.dat:multi-release.dat", + "META-INF/versions/%1$d/multi-release.dat:META-INF/versions/%1$d/multi-release.dat" + .formatted(TestJar.MULTI_JAR_VERSION)); + } + } + + @Test + void versionedStreamStreamsEntries() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar", Runtime.version())) { + assertThat(jar.versionedStream().map((entry) -> entry.getName() + ":" + entry.getRealName())) + .containsExactly("META-INF/:META-INF/", "META-INF/MANIFEST.MF:META-INF/MANIFEST.MF", + "multi-release.dat:META-INF/versions/%1$d/multi-release.dat" + .formatted(TestJar.MULTI_JAR_VERSION)); + } + } + + @Test // gh-39166 + void getCommentAlignsWithJdkJar() throws Exception { + File file = new File(this.tempDir, "testcomments.jar"); + try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) { + jar.putNextEntry(new ZipEntry("BOOT-INF/")); + jar.closeEntry(); + jar.putNextEntry(new ZipEntry("BOOT-INF/classes/")); + jar.closeEntry(); + for (int i = 0; i < 5; i++) { + ZipEntry entry = new ZipEntry("BOOT-INF/classes/T" + i + ".class"); + entry.setComment("T" + i); + jar.putNextEntry(entry); + jar.write(UUID.randomUUID().toString().getBytes()); + jar.closeEntry(); + } + } + List jdk = collectComments(new JarFile(file)); + List nested = collectComments(new NestedJarFile(file, "BOOT-INF/classes/")); + assertThat(nested).isEqualTo(jdk); + } + + private List collectComments(JarFile jarFile) throws IOException { + try (jarFile) { + List comments = new ArrayList<>(); + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + String comment = entries.nextElement().getComment(); + if (comment != null) { + comments.add(comment); + } + } + return comments; + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java new file mode 100644 index 000000000000..21fb5f6ae0ae --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-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.boot.loader.jar; + +import java.io.File; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.ZipContent; +import org.springframework.boot.loader.zip.ZipContent.Entry; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SecurityInfo}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class SecurityInfoTests { + + @TempDir + File temp; + + @Test + void getWhenNoSignatureFileReturnsNone() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + try (ZipContent content = ZipContent.open(file.toPath())) { + SecurityInfo info = SecurityInfo.get(content); + assertThat(info).isSameAs(SecurityInfo.NONE); + for (int i = 0; i < content.size(); i++) { + Entry entry = content.getEntry(i); + assertThat(info.getCertificates(entry)).isNull(); + assertThat(info.getCodeSigners(entry)).isNull(); + } + } + } + + @Test + void getWhenHasSignatureFileButNoSecurityMaterialReturnsNone() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file, false, true); + try (ZipContent content = ZipContent.open(file.toPath())) { + assertThat(content.hasJarSignatureFile()).isTrue(); + SecurityInfo info = SecurityInfo.get(content); + assertThat(info).isSameAs(SecurityInfo.NONE); + } + } + + @Test + void getWhenJarIsSigned() throws Exception { + File file = TestJar.getSigned(); + try (ZipContent content = ZipContent.open(file.toPath())) { + assertThat(content.hasJarSignatureFile()).isTrue(); + SecurityInfo info = SecurityInfo.get(content); + for (int i = 0; i < content.size(); i++) { + Entry entry = content.getEntry(i); + if (entry.getName().endsWith(".class")) { + assertThat(info.getCertificates(entry)).isNotNull(); + assertThat(info.getCodeSigners(entry)).isNotNull(); + } + } + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/TestJarMode.java similarity index 58% rename from spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/TestJarMode.java index ad99f85a778a..2e17175690a5 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/TestJarMode.java @@ -14,21 +14,25 @@ * limitations under the License. */ -package org.springframework.boot.autoconfigure.web.reactive.function.client; +package org.springframework.boot.loader.jarmode; -import org.springframework.http.client.reactive.JettyResourceFactory; +import java.util.Arrays; /** - * Tests for {@link JettyClientHttpConnectorFactory}. + * {@link JarMode} for testing. * * @author Phillip Webb */ -class JettyClientHttpConnectorFactoryTests extends AbstractClientHttpConnectorFactoryTests { +class TestJarMode implements JarMode { @Override - protected ClientHttpConnectorFactory getFactory() { - JettyResourceFactory resourceFactory = new JettyResourceFactory(); - return new JettyClientHttpConnectorFactory(resourceFactory); + public boolean accepts(String mode) { + return "test".equals(mode); + } + + @Override + public void run(String mode, String[] args) { + System.out.println("running in " + mode + " jar mode " + Arrays.asList(args)); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/AbstractExecutableArchiveLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/AbstractExecutableArchiveLauncherTests.java new file mode 100644 index 000000000000..efdc7012d2ea --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/AbstractExecutableArchiveLauncherTests.java @@ -0,0 +1,136 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.util.FileCopyUtils; + +/** + * Base class for testing {@link ExecutableArchiveLauncher} implementations. + * + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + */ +abstract class AbstractExecutableArchiveLauncherTests { + + @TempDir + File tempDir; + + protected File createJarArchive(String name, String entryPrefix) throws IOException { + return createJarArchive(name, entryPrefix, false, Collections.emptyList()); + } + + protected File createJarArchive(String name, String entryPrefix, boolean indexed, List extraLibs) + throws IOException { + return createJarArchive(name, null, entryPrefix, indexed, extraLibs); + } + + protected File createJarArchive(String name, Manifest manifest, String entryPrefix, boolean indexed, + List extraLibs) throws IOException { + File archive = new File(this.tempDir, name); + JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(archive)); + if (manifest != null) { + jarOutputStream.putNextEntry(new JarEntry("META-INF/")); + jarOutputStream.putNextEntry(new JarEntry("META-INF/MANIFEST.MF")); + manifest.write(jarOutputStream); + jarOutputStream.closeEntry(); + } + jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/")); + jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classes/")); + jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/lib/")); + if (indexed) { + jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classpath.idx")); + Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8); + writer.write("- \"" + entryPrefix + "/lib/foo.jar\"\n"); + writer.write("- \"" + entryPrefix + "/lib/bar.jar\"\n"); + writer.write("- \"" + entryPrefix + "/lib/baz.jar\"\n"); + writer.flush(); + jarOutputStream.closeEntry(); + } + addNestedJars(entryPrefix, "/lib/foo.jar", jarOutputStream); + addNestedJars(entryPrefix, "/lib/bar.jar", jarOutputStream); + addNestedJars(entryPrefix, "/lib/baz.jar", jarOutputStream); + for (String lib : extraLibs) { + addNestedJars(entryPrefix, "/lib/" + lib, jarOutputStream); + } + jarOutputStream.close(); + return archive; + } + + private void addNestedJars(String entryPrefix, String lib, JarOutputStream jarOutputStream) throws IOException { + JarEntry libFoo = new JarEntry(entryPrefix + lib); + libFoo.setMethod(ZipEntry.STORED); + ByteArrayOutputStream fooJarStream = new ByteArrayOutputStream(); + new JarOutputStream(fooJarStream).close(); + libFoo.setSize(fooJarStream.size()); + CRC32 crc32 = new CRC32(); + crc32.update(fooJarStream.toByteArray()); + libFoo.setCrc(crc32.getValue()); + jarOutputStream.putNextEntry(libFoo); + jarOutputStream.write(fooJarStream.toByteArray()); + } + + protected File explode(File archive) throws IOException { + File exploded = new File(this.tempDir, "exploded"); + exploded.mkdirs(); + JarFile jarFile = new JarFile(archive); + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + File entryFile = new File(exploded, entry.getName()); + if (entry.isDirectory()) { + entryFile.mkdirs(); + } + else { + FileCopyUtils.copy(jarFile.getInputStream(entry), new FileOutputStream(entryFile)); + } + } + jarFile.close(); + return exploded; + } + + protected final URL toUrl(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.java new file mode 100644 index 000000000000..77371024b4b2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.launch.Archive.Entry; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +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.BDDMockito.then; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.withSettings; + +/** + * Tests for {@link Archive}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class ArchiveTests { + + @TempDir + File temp; + + @Test + void getClassPathUrlsWithOnlyIncludeFilterSearchesAllDirectories() throws Exception { + Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + Predicate includeFilter = (entry) -> false; + archive.getClassPathUrls(includeFilter); + then(archive).should().getClassPathUrls(includeFilter, Archive.ALL_ENTRIES); + } + + @Test + void isExplodedWhenHasRootDirectoryReturnsTrue() { + Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + given(archive.getRootDirectory()).willReturn(this.temp); + assertThat(archive.isExploded()).isTrue(); + } + + @Test + void isExplodedWhenHasNoRootDirectoryReturnsFalse() { + Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + given(archive.getRootDirectory()).willReturn(null); + assertThat(archive.isExploded()).isFalse(); + } + + @Test + void createFromProtectionDomainCreatesJarArchive() throws Exception { + File jarFile = new File(this.temp, "test.jar"); + TestJar.create(jarFile); + ProtectionDomain protectionDomain = mock(ProtectionDomain.class); + CodeSource codeSource = mock(CodeSource.class); + given(protectionDomain.getCodeSource()).willReturn(codeSource); + given(codeSource.getLocation()).willReturn(jarFile.toURI().toURL()); + try (Archive archive = Archive.create(protectionDomain)) { + assertThat(archive).isInstanceOf(JarFileArchive.class); + } + } + + @Test + void createFromProtectionDomainWhenNoLocationThrowsException() throws Exception { + File jarFile = new File(this.temp, "test.jar"); + TestJar.create(jarFile); + ProtectionDomain protectionDomain = mock(ProtectionDomain.class); + assertThatIllegalStateException().isThrownBy(() -> Archive.create(protectionDomain)) + .withMessage("Unable to determine code source archive"); + } + + @Test + void createFromFileWhenFileDoesNotExistThrowsException() { + File target = new File(this.temp, "missing"); + assertThatIllegalStateException().isThrownBy(() -> Archive.create(target)) + .withMessageContaining("Unable to determine code source archive"); + } + + @Test + void createFromFileWhenJarFileReturnsJarFileArchive() throws Exception { + File target = new File(this.temp, "missing"); + TestJar.create(target); + try (Archive archive = Archive.create(target)) { + assertThat(archive).isInstanceOf(JarFileArchive.class); + } + } + + @Test + void createFromFileWhenDirectoryReturnsExplodedFileArchive() throws Exception { + File target = this.temp; + try (Archive archive = Archive.create(target)) { + assertThat(archive).isInstanceOf(ExplodedArchive.class); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ClassPathIndexFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ClassPathIndexFileTests.java new file mode 100644 index 000000000000..4f175b2832b2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ClassPathIndexFileTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ClassPathIndexFile}. + * + * @author Madhura Bhave + * @author Phillip Webb + */ +class ClassPathIndexFileTests { + + @TempDir + File temp; + + @Test + void loadIfPossibleWhenRootDoesNotExistReturnsNull() throws Exception { + File root = new File(this.temp, "missing"); + assertThat(ClassPathIndexFile.loadIfPossible(root, "test.idx")).isNull(); + } + + @Test + void loadIfPossibleWhenRootIsDirectoryThrowsException() throws Exception { + File root = new File(this.temp, "directory"); + root.mkdirs(); + assertThat(ClassPathIndexFile.loadIfPossible(root, "test.idx")).isNull(); + } + + @Test + void loadIfPossibleReturnsInstance() throws Exception { + ClassPathIndexFile indexFile = copyAndLoadTestIndexFile(); + assertThat(indexFile).isNotNull(); + } + + @Test + void sizeReturnsNumberOfLines() throws Exception { + ClassPathIndexFile indexFile = copyAndLoadTestIndexFile(); + assertThat(indexFile.size()).isEqualTo(5); + } + + @Test + void getUrlsReturnsUrls() throws Exception { + ClassPathIndexFile indexFile = copyAndLoadTestIndexFile(); + List urls = indexFile.getUrls(); + List expected = new ArrayList<>(); + expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/a.jar")); + expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/b.jar")); + expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/c.jar")); + expected.add(new File(this.temp, "BOOT-INF/layers/two/lib/d.jar")); + expected.add(new File(this.temp, "BOOT-INF/layers/two/lib/e.jar")); + assertThat(urls).containsExactly(expected.stream().map(this::toUrl).toArray(URL[]::new)); + } + + private URL toUrl(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private ClassPathIndexFile copyAndLoadTestIndexFile() throws IOException { + copyTestIndexFile(); + ClassPathIndexFile indexFile = ClassPathIndexFile.loadIfPossible(this.temp, "test.idx"); + return indexFile; + } + + private void copyTestIndexFile() throws IOException { + Files.copy(getClass().getResourceAsStream("classpath-index-file.idx"), + new File(this.temp, "test.idx").toPath()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.java new file mode 100755 index 000000000000..b18164f7513b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.java @@ -0,0 +1,159 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Enumeration; +import java.util.Set; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.launch.Archive.Entry; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ExplodedArchive}. + * + * @author Phillip Webb + * @author Dave Syer + * @author Andy Wilkinson + */ +@AssertFileChannelDataBlocksClosed +class ExplodedArchiveTests { + + @TempDir + File tempDir; + + private File rootDirectory; + + private ExplodedArchive archive; + + @BeforeEach + void setup() throws Exception { + createArchive(); + } + + @AfterEach + void tearDown() throws Exception { + if (this.archive != null) { + this.archive.close(); + } + } + + @Test + void isExplodedReturnsTrue() { + assertThat(this.archive.isExploded()).isTrue(); + } + + @Test + void getRootDirectoryReturnsRootDirectory() { + assertThat(this.archive.getRootDirectory()).isEqualTo(this.rootDirectory); + } + + @Test + void getManifestReturnsManifest() throws Exception { + assertThat(this.archive.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + + @Test + void getClassPathUrlsWhenNoPredicatesReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES); + URL[] expectedUrls = TestJar.expectedEntries().stream().map(this::toUrl).toArray(URL[]::new); + assertThat(urls).containsExactlyInAnyOrder(expectedUrls); + } + + @Test + void getClassPathUrlsWhenHasIncludeFilterReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar); + assertThat(urls).containsOnly(toUrl("nested.jar")); + } + + @Test + void getClassPathUrlsWhenHasIncludeFilterAndSpaceInRootNameReturnsUrls() throws Exception { + createArchive("spaces in the name"); + Set urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar); + assertThat(urls).containsOnly(toUrl("nested.jar")); + } + + @Test + void getClassPathUrlsWhenHasSearchFilterReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES, (entry) -> !entry.name().equals("d/")); + assertThat(urls).contains(toUrl("nested.jar")).doesNotContain(toUrl("d/9.dat")); + } + + private void createArchive() throws Exception { + createArchive(null); + } + + private void createArchive(String directoryName) throws Exception { + File file = new File(this.tempDir, "test.jar"); + TestJar.create(file); + this.rootDirectory = (StringUtils.hasText(directoryName) ? new File(this.tempDir, directoryName) + : new File(this.tempDir, UUID.randomUUID().toString())); + try (JarFile jarFile = new JarFile(file)) { + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + File destination = new File(this.rootDirectory, entry.getName()); + destination.getParentFile().mkdirs(); + if (entry.isDirectory()) { + destination.mkdir(); + } + else { + try (InputStream in = jarFile.getInputStream(entry); + OutputStream out = new FileOutputStream(destination)) { + in.transferTo(out); + } + } + } + this.archive = new ExplodedArchive(this.rootDirectory); + } + } + + private URL toUrl(String name) { + return toUrl(new File(this.rootDirectory, name)); + } + + private URL toUrl(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private boolean entryNameIsNestedJar(Entry entry) { + return entry.name().equals("nested.jar"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java new file mode 100755 index 000000000000..94fd60609d91 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.launch.Archive.Entry; +import org.springframework.boot.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarFileArchive}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Camille Vienot + */ +@AssertFileChannelDataBlocksClosed +class JarFileArchiveTests { + + @TempDir + File tempDir; + + private File file; + + private JarFileArchive archive; + + @BeforeEach + void setup() throws Exception { + createTestJarArchive(false); + } + + @AfterEach + void tearDown() throws Exception { + this.archive.close(); + } + + @Test + void isExplodedReturnsFalse() { + assertThat(this.archive.isExploded()).isFalse(); + } + + @Test + void getRootDirectoryReturnsNull() { + assertThat(this.archive.getRootDirectory()).isNull(); + } + + @Test + void getManifestReturnsManifest() throws Exception { + assertThat(this.archive.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + + @Test + void getClassPathUrlsWhenNoPredicatesReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES); + URL[] expected = TestJar.expectedEntries() + .stream() + .map((name) -> JarUrl.create(this.file, name)) + .toArray(URL[]::new); + assertThat(urls).containsExactly(expected); + } + + @Test + void getClassPathUrlsWhenHasIncludeFilterReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar); + assertThat(urls).containsOnly(JarUrl.create(this.file, "nested.jar")); + } + + @Test + void getClassPathUrlsWhenHasSearchFilterAllUrlsSinceSearchFilterIsNotUsed() throws Exception { + Set urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES, (entry) -> false); + URL[] expected = TestJar.expectedEntries() + .stream() + .map((name) -> JarUrl.create(this.file, name)) + .toArray(URL[]::new); + assertThat(urls).containsExactly(expected); + } + + @Test + void getClassPathUrlsWhenHasUnpackCommentUnpacksAndReturnsUrls() throws Exception { + createTestJarArchive(true); + Set urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar); + assertThat(urls).hasSize(1); + URL url = urls.iterator().next(); + assertThat(url).isNotEqualTo(JarUrl.create(this.file, "nested.jar")); + // The unpack URL must be a raw file URL (see gh-38833) + assertThat(url.toString()).startsWith("file:").endsWith("/nested.jar").doesNotStartWith("jar:"); + } + + @Test + void getClassPathUrlsWhenHasUnpackCommentUnpacksToUniqueLocationsPerArchive() throws Exception { + createTestJarArchive(true); + URL firstNestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next(); + createTestJarArchive(true); + URL secondNestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next(); + assertThat(secondNestedUrl).isNotEqualTo(firstNestedUrl); + } + + @Test + void getClassPathUrlsWhenHasUnpackCommentUnpacksAndShareSameParent() throws Exception { + createTestJarArchive(true); + URL nestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next(); + URL anotherNestedUrl = this.archive.getClassPathUrls((entry) -> entry.name().equals("another-nested.jar")) + .iterator() + .next(); + assertThat(nestedUrl.toString()) + .isEqualTo(anotherNestedUrl.toString().replace("another-nested.jar", "nested.jar")); + } + + @Test + void getClassPathUrlsWhenZip64ListsAllEntries() throws Exception { + File file = new File(this.tempDir, "test.jar"); + FileCopyUtils.copy(writeZip64Jar(), file); + try (Archive jarArchive = new JarFileArchive(file)) { + Set urls = jarArchive.getClassPathUrls(Archive.ALL_ENTRIES); + assertThat(urls).hasSize(65537); + } + } + + private byte[] writeZip64Jar() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (JarOutputStream jarOutput = new JarOutputStream(bytes)) { + for (int i = 0; i < 65537; i++) { + jarOutput.putNextEntry(new JarEntry(i + ".dat")); + jarOutput.closeEntry(); + } + } + return bytes.toByteArray(); + } + + private void createTestJarArchive(boolean unpackNested) throws Exception { + if (this.archive != null) { + this.archive.close(); + } + this.file = new File(this.tempDir, "root.jar"); + TestJar.create(this.file, unpackNested); + this.archive = new JarFileArchive(this.file); + } + + private boolean entryNameIsNestedJar(Entry entry) { + return entry.name().equals("nested.jar"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarLauncherTests.java new file mode 100644 index 000000000000..7e231949bea4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarLauncherTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.FileOutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.test.tools.SourceFile; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.function.ThrowingConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarLauncher}. + * + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class JarLauncherTests extends AbstractExecutableArchiveLauncherTests { + + @Test + void explodedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception { + File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF")); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot)); + Set urls = launcher.getClassPathUrls(); + assertThat(urls).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot)); + } + + @Test + void archivedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception { + File jarRoot = createJarArchive("archive.jar", "BOOT-INF"); + try (JarFileArchive archive = new JarFileArchive(jarRoot)) { + JarLauncher launcher = new JarLauncher(archive); + Set urls = launcher.getClassPathUrls(); + List expectedUrls = new ArrayList<>(); + expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/classes/")); + expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/foo.jar")); + expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/bar.jar")); + expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/baz.jar")); + assertThat(urls).containsOnlyOnceElementsOf(expectedUrls); + } + } + + @Test + void explodedJarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception { + File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, Collections.emptyList())); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); + assertThat(classLoader.getURLs()).containsExactly(getExpectedFileUrls(explodedRoot)); + } + + @Test + void jarFilesPresentInBootInfLibsAndNotInClasspathIndexShouldBeAddedAfterBootInfClasses() throws Exception { + ArrayList extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar")); + File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, extraLibs)); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); + List expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot); + URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new); + assertThat(classLoader.getURLs()).containsExactly(expectedFileUrls); + } + + @Test + void explodedJarDefinedPackagesIncludeManifestAttributes() { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Name.MANIFEST_VERSION, "1.0"); + attributes.put(Name.IMPLEMENTATION_TITLE, "test"); + SourceFile sourceFile = SourceFile.of("explodedsample/ExampleClass.java", + new ClassPathResource("explodedsample/ExampleClass.txt")); + TestCompiler.forSystem().compile(sourceFile, ThrowingConsumer.of((compiled) -> { + File explodedRoot = explode( + createJarArchive("archive.jar", manifest, "BOOT-INF", true, Collections.emptyList())); + File target = new File(explodedRoot, "BOOT-INF/classes/explodedsample/ExampleClass.class"); + target.getParentFile().mkdirs(); + FileCopyUtils.copy(compiled.getClassLoader().getResourceAsStream("explodedsample/ExampleClass.class"), + new FileOutputStream(target)); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); + Class loaded = classLoader.loadClass("explodedsample.ExampleClass"); + assertThat(loaded.getPackage().getImplementationTitle()).isEqualTo("test"); + })); + } + + private URLClassLoader createClassLoader(JarLauncher launcher) throws Exception { + return (URLClassLoader) launcher.createClassLoader(launcher.getClassPathUrls()); + } + + private URL[] getExpectedFileUrls(File explodedRoot) { + return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new); + } + + private List getExpectedFiles(File parent) { + List expected = new ArrayList<>(); + expected.add(new File(parent, "BOOT-INF/classes")); + expected.add(new File(parent, "BOOT-INF/lib/foo.jar")); + expected.add(new File(parent, "BOOT-INF/lib/bar.jar")); + expected.add(new File(parent, "BOOT-INF/lib/baz.jar")); + return expected; + } + + private List getExpectedFilesWithExtraLibs(File parent) { + List expected = new ArrayList<>(); + expected.add(new File(parent, "BOOT-INF/classes")); + expected.add(new File(parent, "BOOT-INF/lib/extra-1.jar")); + expected.add(new File(parent, "BOOT-INF/lib/extra-2.jar")); + expected.add(new File(parent, "BOOT-INF/lib/foo.jar")); + expected.add(new File(parent, "BOOT-INF/lib/bar.jar")); + expected.add(new File(parent, "BOOT-INF/lib/baz.jar")); + return expected; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java new file mode 100644 index 000000000000..d3a92f50c689 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.net.URL; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.jarmode.JarMode; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LaunchedClassLoader}. + * + * @author Dave Syer + * @author Phillip Webb + * @author Andy Wilkinson + */ +@AssertFileChannelDataBlocksClosed +class LaunchedClassLoaderTests { + + @Test + void loadClassWhenJarModeClassLoadsInLaunchedClassLoader() throws Exception { + try (LaunchedClassLoader classLoader = new LaunchedClassLoader(false, new URL[] {}, + getClass().getClassLoader())) { + Class jarModeClass = classLoader.loadClass(JarMode.class.getName()); + Class jarModeRunnerClass = classLoader.loadClass(JarModeRunner.class.getName()); + assertThat(jarModeClass.getClassLoader()).isSameAs(classLoader); + assertThat(jarModeRunnerClass.getClassLoader()).isSameAs(classLoader); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LauncherTests.java new file mode 100644 index 000000000000..2937d9e6752f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LauncherTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.launch; + +import java.net.URL; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Launcher}. + * + * @author Phillip Webb + */ +@ExtendWith(OutputCaptureExtension.class) +@AssertFileChannelDataBlocksClosed +class LauncherTests { + + /** + * Jar Mode tests. + */ + @Nested + class JarMode { + + @BeforeEach + void setup() { + System.setProperty(JarModeRunner.DISABLE_SYSTEM_EXIT, "true"); + } + + @AfterEach + void cleanup() { + System.clearProperty("jarmode"); + System.clearProperty(JarModeRunner.DISABLE_SYSTEM_EXIT); + } + + @Test + void launchWhenJarModePropertyIsSetLaunchesJarMode(CapturedOutput out) throws Exception { + System.setProperty("jarmode", "test"); + new TestLauncher().launch(new String[] { "boot" }); + assertThat(out).contains("running in test jar mode [boot]"); + } + + @Test + void launchWhenJarModePropertyIsNotAcceptedThrowsException(CapturedOutput out) throws Exception { + System.setProperty("jarmode", "idontexist"); + new TestLauncher().launch(new String[] { "boot" }); + assertThat(out).contains("Unsupported jarmode 'idontexist'"); + } + + } + + private static final class TestLauncher extends Launcher { + + @Override + protected String getMainClass() throws Exception { + throw new IllegalStateException("Should not be called"); + } + + @Override + protected Archive getArchive() { + return null; + } + + @Override + protected Set getClassPathUrls() throws Exception { + return Collections.emptySet(); + } + + @Override + protected void launch(String[] args) throws Exception { + super.launch(args); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/PropertiesLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/PropertiesLauncherTests.java new file mode 100644 index 000000000000..9bac00e9e74c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/PropertiesLauncherTests.java @@ -0,0 +1,422 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.time.Duration; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +import org.assertj.core.api.Condition; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; +import org.springframework.core.io.FileSystemResource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.hamcrest.Matchers.containsString; + +/** + * Tests for {@link PropertiesLauncher}. + * + * @author Dave Syer + * @author Andy Wilkinson + */ +@ExtendWith(OutputCaptureExtension.class) +@AssertFileChannelDataBlocksClosed +class PropertiesLauncherTests { + + @TempDir + File tempDir; + + private PropertiesLauncher launcher; + + private ClassLoader contextClassLoader; + + private CapturedOutput output; + + @BeforeEach + void setup(CapturedOutput capturedOutput) { + this.contextClassLoader = Thread.currentThread().getContextClassLoader(); + System.setProperty("loader.home", new File("src/test/resources").getAbsolutePath()); + this.output = capturedOutput; + } + + @AfterEach + void close() throws Exception { + Thread.currentThread().setContextClassLoader(this.contextClassLoader); + System.clearProperty("loader.home"); + System.clearProperty("loader.path"); + System.clearProperty("loader.main"); + System.clearProperty("loader.config.name"); + System.clearProperty("loader.config.location"); + System.clearProperty("loader.system"); + System.clearProperty("loader.classLoader"); + if (this.launcher != null) { + this.launcher.close(); + } + } + + @Test + void testDefaultHome() throws Exception { + System.clearProperty("loader.home"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getHomeDirectory()).isEqualTo(new File(System.getProperty("user.dir"))); + } + + @Test + void testAlternateHome() throws Exception { + System.setProperty("loader.home", "src/test/resources/home"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getHomeDirectory()).isEqualTo(new File(System.getProperty("loader.home"))); + assertThat(this.launcher.getMainClass()).isEqualTo("demo.HomeApplication"); + } + + @Test + void testNonExistentHome() { + System.setProperty("loader.home", "src/test/resources/nonexistent"); + assertThatIllegalArgumentException().isThrownBy(PropertiesLauncher::new) + .withMessageContaining("Invalid source directory"); + } + + @Test + void testUserSpecifiedMain() throws Exception { + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("demo.Application"); + assertThat(System.getProperty("loader.main")).isNull(); + } + + @Test + void testUserSpecifiedConfigName() throws Exception { + System.setProperty("loader.config.name", "foo"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("my.Application"); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[etc/]"); + } + + @Test + void testRootOfClasspathFirst() throws Exception { + System.setProperty("loader.config.name", "bar"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("my.BarApplication"); + } + + @Test + void testUserSpecifiedDotPath() throws Exception { + System.setProperty("loader.path", "."); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[.]"); + } + + @Test + void testUserSpecifiedSlashPath() throws Exception { + System.setProperty("loader.path", "jars/"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/]"); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("app.jar")); + } + + @Test + void testUserSpecifiedWildcardPath() throws Exception { + System.setProperty("loader.path", "jars/*"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedJarPath() throws Exception { + System.setProperty("loader.path", "jars/app.jar"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedRootOfJarPath() throws Exception { + System.setProperty("loader.path", "jar:file:./src/test/resources/nested-jars/app.jar!/"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")) + .hasToString("[jar:file:./src/test/resources/nested-jars/app.jar!/]"); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("foo.jar!/")); + assertThat(urls).areExactly(1, endingWith("app.jar!/")); + } + + @Test + void testUserSpecifiedRootOfJarPathWithDot() throws Exception { + System.setProperty("loader.path", "nested-jars/app.jar!/./"); + this.launcher = new PropertiesLauncher(); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("foo.jar!/")); + assertThat(urls).areExactly(1, endingWith("app.jar!/")); + } + + @Test + void testUserSpecifiedRootOfJarPathWithDotAndJarPrefix() throws Exception { + System.setProperty("loader.path", "jar:file:./src/test/resources/nested-jars/app.jar!/./"); + this.launcher = new PropertiesLauncher(); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("foo.jar!/")); + } + + @Test + void testUserSpecifiedJarFileWithNestedArchives() throws Exception { + System.setProperty("loader.path", "nested-jars/app.jar"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("foo.jar!/")); + assertThat(urls).areExactly(1, endingWith("app.jar")); + } + + @Test + void testUserSpecifiedNestedJarPath() throws Exception { + System.setProperty("loader.path", "nested-jars/nested-jar-app.jar!/BOOT-INF/classes/"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")) + .hasToString("[nested-jars/nested-jar-app.jar!/BOOT-INF/classes/]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedDirectoryContainingJarFileWithNestedArchives() throws Exception { + System.setProperty("loader.path", "nested-jars"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedJarPathWithDot() throws Exception { + System.setProperty("loader.path", "./jars/app.jar"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedClassLoader() throws Exception { + System.setProperty("loader.path", "jars/app.jar"); + System.setProperty("loader.classLoader", URLClassLoader.class.getName()); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedClassPathOrder() throws Exception { + System.setProperty("loader.path", "more-jars/app.jar,jars/app.jar"); + System.setProperty("loader.classLoader", URLClassLoader.class.getName()); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")) + .hasToString("[more-jars/app.jar, jars/app.jar]"); + this.launcher.launch(new String[0]); + waitFor("Hello Other World"); + } + + @Test + void testCustomClassLoaderCreation() throws Exception { + System.setProperty("loader.classLoader", TestLoader.class.getName()); + this.launcher = new PropertiesLauncher(); + ClassLoader loader = this.launcher.createClassLoader(classPathUrls()); + assertThat(loader).isNotNull(); + assertThat(loader.getClass().getName()).isEqualTo(TestLoader.class.getName()); + } + + private Set classPathUrls() throws Exception { + Set urls = new LinkedHashSet<>(); + String classPath = System.getProperty("java.class.path"); + for (String path : classPath.split(File.pathSeparator)) { + File file = new FileSystemResource(path).getFile(); + if (file.exists()) { + urls.add(file.toURI().toURL()); + } + } + return urls; + } + + @Test + void testUserSpecifiedConfigPathWins() throws Exception { + System.setProperty("loader.config.name", "foo"); + System.setProperty("loader.config.location", "classpath:bar.properties"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("my.BarApplication"); + } + + @Test + void testSystemPropertySpecifiedMain() throws Exception { + System.setProperty("loader.main", "foo.Bar"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("foo.Bar"); + } + + @Test + void testSystemPropertiesSet() throws Exception { + System.setProperty("loader.system", "true"); + new PropertiesLauncher(); + assertThat(System.getProperty("loader.main")).isEqualTo("demo.Application"); + } + + @Test + void testArgsEnhanced() throws Exception { + System.setProperty("loader.args", "foo"); + this.launcher = new PropertiesLauncher(); + assertThat(Arrays.asList(this.launcher.getArgs("bar"))).hasToString("[foo, bar]"); + } + + @Test + @SuppressWarnings("unchecked") + void testLoadPathCustomizedUsingManifest() throws Exception { + System.setProperty("loader.home", this.tempDir.getAbsolutePath()); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("Loader-Path", "/foo.jar, /bar"); + File manifestFile = new File(this.tempDir, "META-INF/MANIFEST.MF"); + manifestFile.getParentFile().mkdirs(); + try (FileOutputStream manifestStream = new FileOutputStream(manifestFile)) { + manifest.write(manifestStream); + } + this.launcher = new PropertiesLauncher(); + assertThat((List) ReflectionTestUtils.getField(this.launcher, "paths")).containsExactly("/foo.jar", + "/bar/"); + } + + @Test + void testManifestWithPlaceholders() throws Exception { + System.setProperty("loader.home", "src/test/resources/placeholders"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("demo.FooApplication"); + } + + @Test + void encodedFileUrlLoaderPathIsHandledCorrectly() throws Exception { + File loaderPath = new File(this.tempDir, "loader path"); + loaderPath.mkdir(); + System.setProperty("loader.path", loaderPath.toURI().toURL().toString()); + this.launcher = new PropertiesLauncher(); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).hasSize(1); + assertThat(urls.iterator().next()).isEqualTo(loaderPath.toURI().toURL()); + } + + @Test // gh-21575 + void loadResourceFromJarFile() throws Exception { + File file = new File(this.tempDir, "app.jar"); + TestJar.create(file); + System.setProperty("loader.home", this.tempDir.getAbsolutePath()); + System.setProperty("loader.path", "app.jar"); + this.launcher = new PropertiesLauncher(); + try { + this.launcher.launch(new String[0]); + } + catch (Exception ex) { + // Expected ClassNotFoundException + LaunchedClassLoader classLoader = (LaunchedClassLoader) Thread.currentThread().getContextClassLoader(); + classLoader.close(); + } + URL resource = JarUrl.create(file, "nested.jar", "3.dat"); + byte[] bytes = FileCopyUtils.copyToByteArray(resource.openStream()); + assertThat(bytes).isNotEmpty(); + } + + @Test // gh-37992 + void classPathWithoutLoaderPathDefaultsToJarLauncherIncludes() throws Exception { + File file = new File(this.tempDir, "test.jar"); + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(file))) { + try (JarFile in = new JarFile(new File("src/test/resources/jars/app.jar"))) { + out.putNextEntry(new ZipEntry("BOOT-INF/")); + out.putNextEntry(new ZipEntry("BOOT-INF/classes/")); + out.putNextEntry(new ZipEntry("BOOT-INF/classes/demo/")); + out.putNextEntry(new ZipEntry("BOOT-INF/classes/demo/Application.class")); + try (InputStream classIn = in.getInputStream(in.getEntry("demo/Application.class"))) { + classIn.transferTo(out); + } + out.closeEntry(); + } + } + Archive archive = new JarFileArchive(file); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(archive); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + + } + + private void waitFor(String value) { + Awaitility.waitAtMost(Duration.ofSeconds(5)).until(this.output::toString, containsString(value)); + } + + private Condition endingWith(String value) { + return new Condition<>() { + + @Override + public boolean matches(URL archive) { + return archive.toString().endsWith(value); + } + + }; + } + + static class TestLoader extends URLClassLoader { + + TestLoader(ClassLoader parent) { + super(new URL[0], parent); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + return super.findClass(name); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/WarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/WarLauncherTests.java new file mode 100644 index 000000000000..cea89eabe7c7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/WarLauncherTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2012-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.boot.loader.launch; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link WarLauncher}. + * + * @author Andy Wilkinson + * @author Scott Frederick + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class WarLauncherTests extends AbstractExecutableArchiveLauncherTests { + + @Test + void explodedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception { + File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF")); + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot)); + Set urls = launcher.getClassPathUrls(); + assertThat(urls).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot)); + } + + @Test + void archivedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception { + File file = createJarArchive("archive.war", "WEB-INF"); + try (JarFileArchive archive = new JarFileArchive(file)) { + WarLauncher launcher = new WarLauncher(archive); + Set urls = launcher.getClassPathUrls(); + List expected = new ArrayList<>(); + expected.add(JarUrl.create(file, "WEB-INF/classes/")); + expected.add(JarUrl.create(file, "WEB-INF/lib/foo.jar")); + expected.add(JarUrl.create(file, "WEB-INF/lib/bar.jar")); + expected.add(JarUrl.create(file, "WEB-INF/lib/baz.jar")); + assertThat(urls).containsOnly(expected.toArray(URL[]::new)); + } + } + + @Test + void explodedWarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception { + File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, Collections.emptyList())); + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); + URL[] urls = classLoader.getURLs(); + assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot)); + } + + @Test + void warFilesPresentInWebInfLibsAndNotInClasspathIndexShouldBeAddedAfterWebInfClasses() throws Exception { + ArrayList extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar")); + File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, extraLibs)); + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); + URL[] urls = classLoader.getURLs(); + List expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot); + URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new); + assertThat(urls).containsExactly(expectedFileUrls); + } + + private URLClassLoader createClassLoader(Launcher launcher) throws Exception { + return (URLClassLoader) launcher.createClassLoader(launcher.getClassPathUrls()); + } + + private URL[] getExpectedFileUrls(File explodedRoot) { + return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new); + } + + private List getExpectedFiles(File parent) { + List expected = new ArrayList<>(); + expected.add(new File(parent, "WEB-INF/classes")); + expected.add(new File(parent, "WEB-INF/lib/foo.jar")); + expected.add(new File(parent, "WEB-INF/lib/bar.jar")); + expected.add(new File(parent, "WEB-INF/lib/baz.jar")); + return expected; + } + + private List getExpectedFilesWithExtraLibs(File parent) { + List expected = new ArrayList<>(); + expected.add(new File(parent, "WEB-INF/classes")); + expected.add(new File(parent, "WEB-INF/lib/extra-1.jar")); + expected.add(new File(parent, "WEB-INF/lib/extra-2.jar")); + expected.add(new File(parent, "WEB-INF/lib/foo.jar")); + expected.add(new File(parent, "WEB-INF/lib/bar.jar")); + expected.add(new File(parent, "WEB-INF/lib/baz.jar")); + return expected; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.java new file mode 100644 index 000000000000..7f585e37d430 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Canonicalizer}. + * + * @author Phillip Webb + */ +class CanonicalizerTests { + + @Test + void canonicalizeAfterOnlyChangesAfterPos() { + String prefix = "/foo/.././bar/.!/foo/.././bar/."; + String canonicalized = Canonicalizer.canonicalizeAfter(prefix, prefix.indexOf("!/")); + assertThat(canonicalized).isEqualTo("/foo/.././bar/.!/bar/"); + } + + @Test + void canonicalizeWhenHasEmbeddedSlashDotDotSlash() { + assertThat(Canonicalizer.canonicalize("/foo/../bar/bif/bam/../../baz")).isEqualTo("/bar/baz"); + } + + @Test + void canonicalizeWhenHasEmbeddedSlashDotSlash() { + assertThat(Canonicalizer.canonicalize("/foo/./bar/bif/bam/././baz")).isEqualTo("/foo/bar/bif/bam/baz"); + } + + @Test + void canonicalizeWhenHasTrailingSlashDotDot() { + assertThat(Canonicalizer.canonicalize("/foo/bar/baz/../..")).isEqualTo("/foo/"); + } + + @Test + void canonicalizeWhenHasTrailingSlashDot() { + assertThat(Canonicalizer.canonicalize("/foo/bar/baz/./.")).isEqualTo("/foo/bar/baz/"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java new file mode 100644 index 000000000000..6e8254805229 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java @@ -0,0 +1,212 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link Handler}. + * + * @author Andy Wilkinson + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class HandlerTests { + + private final Handler handler = new Handler(); + + @Test + void indexOfSeparator() { + String spec = "jar:nested:foo!bar!/some/entry#foo"; + assertThat(Handler.indexOfSeparator(spec, 0, spec.indexOf('#'))).isEqualTo(spec.lastIndexOf("!/")); + } + + @Test + void indexOfSeparatorWhenHasStartAndLimit() { + String spec = "a!/jar:nested:foo!bar!/some/entry#foo!/b"; + int beginIndex = 3; + int endIndex = spec.length() - 4; + String substring = spec.substring(beginIndex, endIndex); + assertThat(Handler.indexOfSeparator(spec, 0, spec.indexOf('#'))) + .isEqualTo(substring.lastIndexOf("!/") + beginIndex); + } + + @Test + void parseUrlWhenAbsoluteParses() throws MalformedURLException { + URL url = createJarUrl(""); + String spec = "jar:file:example.jar!/entry.txt"; + this.handler.parseURL(url, spec, 4, spec.length()); + assertThat(url.toExternalForm()).isEqualTo(spec); + } + + @Test + void parseUrlWhenAbsoluteWithAnchorParses() throws MalformedURLException { + URL url = createJarUrl(""); + String spec = "jar:file:example.jar!/entry.txt"; + this.handler.parseURL(url, spec + "#foo", 4, spec.length()); + assertThat(url.toExternalForm()).isEqualTo(spec + "#foo"); + } + + @Test + void parseUrlWhenAbsoluteWithNoSeparatorThrowsException() throws MalformedURLException { + URL url = createJarUrl(""); + String spec = "jar:file:example.jar!\\entry.txt"; + assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 4, spec.length())) + .withMessage("no !/ in spec"); + } + + @Test + void parseUrlWhenAbsoluteWithMalformedInnerUrlThrowsException() throws MalformedURLException { + URL url = createJarUrl(""); + String spec = "jar:example.jar!/entry.txt"; + assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 4, spec.length())) + .withMessage( + "invalid url: jar:example.jar!/entry.txt (java.net.MalformedURLException: no protocol: example.jar)"); + } + + @Test + void parseUrlWhenRelativeWithLeadingSlashParses() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/entry.txt"); + String spec = "/other.txt"; + this.handler.parseURL(url, spec, 0, spec.length()); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/other.txt"); + } + + @Test + void parseUrlWhenRelativeWithLeadingSlashAndAnchorParses() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/entry.txt"); + String spec = "/other.txt"; + this.handler.parseURL(url, spec + "#relative", 0, spec.length()); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/other.txt#relative"); + } + + @Test + void parseUrlWhenRelativeWithLeadingSlashAndNoSeparator() throws MalformedURLException { + URL url = createJarUrl("file:example.jar/entry.txt"); + String spec = "/other.txt"; + assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 0, spec.length())) + .withMessage("malformed context url:jar:file:example.jar/entry.txt: no !/"); + } + + @Test + void parseUrlWhenRelativeWithoutLeadingSlashParses() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/foo/"); + String spec = "bar.txt"; + this.handler.parseURL(url, spec, 0, spec.length()); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/foo/bar.txt"); + } + + @Test + void parseUrlWhenRelativeWithoutLeadingSlashAndWithoutTrailingSlashParses() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/foo/baz"); + String spec = "bar.txt"; + this.handler.parseURL(url, spec, 0, spec.length()); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/foo/bar.txt"); + } + + @Test + void parseUrlWhenRelativeWithoutLeadingSlashAndWithoutContextSlashThrowsException() throws MalformedURLException { + URL url = createJarUrl("file:example.jar"); + String spec = "bar.txt"; + assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 0, spec.length())) + .withMessage("malformed context url:jar:file:example.jar"); + } + + @Test + void parseUrlWhenAnchorOnly() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/entry.txt"); + String spec = "#runtime"; + this.handler.parseURL(url, spec, 0, 0); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/entry.txt#runtime"); + } + + @Test // gh-38524 + void parseUrlWhenSpecIsEmpty() throws MalformedURLException { + URL url = createJarUrl("nested:gh-38524.jar/!BOOT-INF/classes/!/"); + String spec = ""; + this.handler.parseURL(url, spec, 0, 0); + assertThat(url.toExternalForm()).isEqualTo("jar:nested:gh-38524.jar/!BOOT-INF/classes/!/"); + + } + + @Test + void hashCodeGeneratesHashCode() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/entry.txt"); + assertThat(this.handler.hashCode(url)).isEqualTo(1873709601); + } + + @Test + void hashCodeWhenMalformedInnerUrlGeneratesHashCode() throws MalformedURLException { + URL url = createJarUrl("example.jar!/entry.txt"); + assertThat(this.handler.hashCode(url)).isEqualTo(1870566566); + } + + @Test + void sameFileWhenSameReturnsTrue() throws MalformedURLException { + URL url1 = createJarUrl("file:example.jar!/entry.txt"); + URL url2 = createJarUrl("file:example.jar!/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isTrue(); + } + + @Test + void sameFileWhenMissingSeparatorReturnsFalse() throws MalformedURLException { + URL url1 = createJarUrl("file:example.jar!/entry.txt"); + URL url2 = createJarUrl("file:example.jar/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isFalse(); + } + + @Test + void sameFileWhenDifferentEntryReturnsFalse() throws MalformedURLException { + URL url1 = createJarUrl("file:example.jar!/entry1.txt"); + URL url2 = createJarUrl("file:example.jar!/entry2.txt"); + assertThat(this.handler.sameFile(url1, url2)).isFalse(); + } + + @Test + void sameFileWhenDifferentInnerUrlReturnsFalse() throws MalformedURLException { + URL url1 = createJarUrl("file:example1.jar!/entry.txt"); + URL url2 = createJarUrl("file:example2.jar!/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isFalse(); + } + + @Test + void sameFileWhenSameMalformedInnerUrlReturnsTrue() throws MalformedURLException { + URL url1 = createJarUrl("example.jar!/entry.txt"); + URL url2 = createJarUrl("example.jar!/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isTrue(); + } + + @Test + void sameFileWhenDifferentMalformedInnerUrlReturnsFalse() throws MalformedURLException { + URL url1 = createJarUrl("example1.jar!/entry.txt"); + URL url2 = createJarUrl("example2.jar!/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isFalse(); + } + + private URL createJarUrl(String file) throws MalformedURLException { + return new URL("jar", null, -1, file, this.handler); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKeyTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKeyTests.java new file mode 100644 index 000000000000..b4131123d53e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKeyTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.net.URL; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.net.protocol.Handlers; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarFileUrlKey}. + * + * @author Phillip Webb + */ +class JarFileUrlKeyTests { + + @BeforeAll + static void setup() { + Handlers.register(); + } + + @Test + void getCreatesKey() throws Exception { + URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path"); + } + + @Test + void getWhenUppercaseProtocolCreatesKey() throws Exception { + URL url = new URL("JAR:nested:/my.jar/!mynested.jar!/my/path"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path"); + } + + @Test + void getWhenHasHostAndPortCreatesKey() throws Exception { + URL url = new URL("https://example.com:1234/test"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:1234/test"); + } + + @Test + void getWhenHasUppercaseHostCreatesKey() throws Exception { + URL url = new URL("https://EXAMPLE.com:1234/test"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:1234/test"); + } + + @Test + void getWhenHasNoPortCreatesKeyWithDefaultPort() throws Exception { + URL url = new URL("https://EXAMPLE.com/test"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:443/test"); + } + + @Test + void getWhenHasNoFileCreatesKey() throws Exception { + URL url = new URL("https://EXAMPLE.com"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:443"); + } + + @Test + void getWhenHasRuntimeRefCreatesKey() throws Exception { + URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path#runtime"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path#runtime"); + } + + @Test + void getWhenHasOtherRefCreatesKeyWithoutRef() throws Exception { + URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path#example"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java new file mode 100644 index 000000000000..d4eeed8c29b0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarUrlClassLoader}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class JarUrlClassLoaderTests { + + private static final URL APP_JAR; + static { + try { + APP_JAR = new URL("jar:file:src/test/resources/jars/app.jar!/"); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + @TempDir + File tempDir; + + @BeforeAll + static void setup() { + Handlers.register(); + } + + @Test + void resolveResourceFromArchive() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.getResource("demo/Application.java")).isNotNull(); + } + } + + @Test + void resolveResourcesFromArchive() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.getResources("demo/Application.java").hasMoreElements()).isTrue(); + } + } + + @Test + void resolveRootPathFromArchive() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.getResource("")).isNotNull(); + } + } + + @Test + void resolveRootResourcesFromArchive() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.getResources("").hasMoreElements()).isTrue(); + } + } + + @Test + void resolveFromNested() throws Exception { + File jarFile = new File(this.tempDir, "test.jar"); + TestJar.create(jarFile); + URL url = JarUrl.create(jarFile, "nested.jar"); + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(url)) { + URL resource = loader.getResource("3.dat"); + assertThat(resource).hasToString(url + "3.dat"); + try (InputStream input = resource.openConnection().getInputStream()) { + assertThat(input.read()).isEqualTo(3); + } + } + } + + @Test + void loadClass() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.loadClass("demo.Application")).isNotNull().hasToString("class demo.Application"); + } + } + + @Test + void loadClassFromNested() throws Exception { + File appJar = new File("src/test/resources/jars/app.jar"); + File jarFile = new File(this.tempDir, "test.jar"); + FileOutputStream fileOutputStream = new FileOutputStream(jarFile); + try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) { + JarEntry nestedEntry = new JarEntry("app.jar"); + byte[] nestedJarData = Files.readAllBytes(appJar.toPath()); + nestedEntry.setSize(nestedJarData.length); + nestedEntry.setCompressedSize(nestedJarData.length); + CRC32 crc32 = new CRC32(); + crc32.update(nestedJarData); + nestedEntry.setCrc(crc32.getValue()); + nestedEntry.setMethod(ZipEntry.STORED); + jarOutputStream.putNextEntry(nestedEntry); + jarOutputStream.write(nestedJarData); + jarOutputStream.closeEntry(); + } + URL url = JarUrl.create(jarFile, "app.jar"); + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(url)) { + assertThat(loader.loadClass("demo.Application")).isNotNull().hasToString("class demo.Application"); + } + } + + static class TestJarUrlClassLoader extends JarUrlClassLoader { + + TestJarUrlClassLoader(URL... urls) { + super(urls, JarUrlClassLoaderTests.class.getClassLoader()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java new file mode 100644 index 000000000000..83f522553332 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java @@ -0,0 +1,533 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.security.Permission; +import java.time.Instant; +import java.time.temporal.ChronoField; +import java.util.List; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.ZipContent; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIOException; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link JarUrlConnection}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class JarUrlConnectionTests { + + @TempDir + File temp; + + private File file; + + private URL url; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @BeforeEach + @AfterEach + void reset() { + JarUrlConnection.clearCache(); + Optimizations.disable(); + } + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.temp, "test.jar"); + TestJar.create(this.file); + this.url = JarUrl.create(this.file, "nested.jar"); + } + + @Test + void getJarFileReturnsJarFile() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + JarFile jarFile = connection.getJarFile(); + assertThat(jarFile).isNotNull(); + assertThat(jarFile.getEntry("3.dat")).isNotNull(); + } + + @Test + void getJarEntryReturnsJarEntry() throws Exception { + URL url = JarUrl.create(this.file, "nested.jar", "3.dat"); + JarUrlConnection connection = JarUrlConnection.open(url); + JarEntry entry = connection.getJarEntry(); + assertThat(entry).isNotNull(); + assertThat(entry.getName()).isEqualTo("3.dat"); + } + + @Test + void getJarEntryWhenHasNoEntryNameReturnsNull() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + JarEntry entry = connection.getJarEntry(); + assertThat(entry).isNull(); + } + + @Test + void getContentLengthReturnsContentLength() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + try (ZipContent content = ZipContent.open(this.file.toPath())) { + int expected = content.getEntry("nested.jar").getUncompressedSize(); + assertThat(connection.getContentLength()).isEqualTo(expected); + } + } + + @Test + void getContentLengthWhenLengthIsLargerThanMaxIntReturnsMinusOne() { + JarUrlConnection connection = mock(JarUrlConnection.class); + given(connection.getContentLength()).willCallRealMethod(); + given(connection.getContentLengthLong()).willReturn((long) Integer.MAX_VALUE + 1); + assertThat(connection.getContentLength()).isEqualTo(-1); + } + + @Test + void getContentLengthLongWhenHasNoEntryReturnsSizeOfJar() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + try (ZipContent content = ZipContent.open(this.file.toPath())) { + int expected = content.getEntry("nested.jar").getUncompressedSize(); + assertThat(connection.getContentLengthLong()).isEqualTo(expected); + } + } + + @Test + void getContentLengthLongWhenHasEntryReturnsEntrySize() throws Exception { + URL url = JarUrl.create(this.file, "nested.jar", "3.dat"); + JarUrlConnection connection = JarUrlConnection.open(url); + assertThat(connection.getContentLengthLong()).isEqualTo(1); + } + + @Test + void getContentLengthLongWhenCannotConnectReturnsMinusOne() throws IOException { + JarUrlConnection connection = mock(JarUrlConnection.class); + willThrow(IOException.class).given(connection).connect(); + given(connection.getContentLengthLong()).willCallRealMethod(); + assertThat(connection.getContentLengthLong()).isEqualTo(-1); + } + + @Test + void getContentTypeWhenHasNoEntryReturnsJavaJar() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + assertThat(connection.getContentType()).isEqualTo("x-java/jar"); + } + + @Test + void getContentTypeWhenHasKnownStreamReturnsDeducedType() throws Exception { + String content = ""; + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) { + out.putNextEntry(new ZipEntry("test.dat")); + out.write(content.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + JarUrlConnection connection = JarUrlConnection + .open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.dat")); + assertThat(connection.getContentType()).isEqualTo("application/xml"); + } + + @Test + void getContentTypeWhenNotKnownInStreamButKnownNameReturnsDeducedType() throws Exception { + String content = "nothinguseful"; + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) { + out.putNextEntry(new ZipEntry("test.xml")); + out.write(content.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + JarUrlConnection connection = JarUrlConnection + .open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.xml")); + assertThat(connection.getContentType()).isEqualTo("application/xml"); + } + + @Test + void getContentTypeWhenCannotBeDeducedReturnsContentUnknown() throws Exception { + String content = "nothinguseful"; + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) { + out.putNextEntry(new ZipEntry("test.dat")); + out.write(content.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + JarUrlConnection connection = JarUrlConnection + .open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.dat")); + assertThat(connection.getContentType()).isEqualTo("content/unknown"); + } + + @Test + void getHeaderFieldDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + given(jarFileConnection.getHeaderField("test")).willReturn("test"); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + assertThat(connection.getHeaderField("test")).isEqualTo("test"); + } + + @Test + void getContentWhenHasEntryReturnsContentFromEntry() throws Exception { + String content = "hello"; + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) { + out.putNextEntry(new ZipEntry("test.txt")); + out.write(content.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + JarUrlConnection connection = JarUrlConnection + .open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.txt")); + assertThat(connection.getContent()).isInstanceOf(FilterInputStream.class); + } + + @Test + void getContentWhenHasNoEntryReturnsJarFile() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + assertThat(connection.getContent()).isInstanceOf(JarFile.class); + } + + @Test + void getPermissionReturnJarConnectionPermission() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + Permission permission = mock(Permission.class); + given(jarFileConnection.getPermission()).willReturn(permission); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + assertThat(connection.getPermission()).isSameAs(permission); + } + + @Test + void getInputStreamWhenNotNestedAndHasNoEntryThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file)); + assertThatIOException().isThrownBy(() -> connection.getInputStream()).withMessage("no entry name specified"); + } + + @Test + void getInputStreamWhenOptimizedWithoutReadAndHasCachedJarWithEntryReturnsEmptyInputStream() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + setupConnection.connect(); + assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull(); + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat")); + connection.setUseCaches(false); + Optimizations.enable(false); + assertThat(connection.getInputStream()).isSameAs(JarUrlConnection.emptyInputStream); + } + + @Test + void getInputStreamWhenNoEntryAndOptimizedThrowsException() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + setupConnection.connect(); + assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull(); + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + Optimizations.enable(false); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::getInputStream) + .isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION); + } + + @Test + void getInputStreamWhenNoEntryAndNotOptimizedThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::getInputStream) + .withMessageContaining("JAR entry missing.dat not found in"); + } + + @Test // gh-38047 + void getInputStreamWhenNoEntryAndNestedReturnsFullJarInputStream() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + File outFile = new File(this.temp, "out.zip"); + try (OutputStream out = new FileOutputStream(outFile)) { + connection.getInputStream().transferTo(out); + } + try (JarFile outJar = new JarFile(outFile)) { + assertThat(outJar.getEntry("3.dat")).isNotNull(); + } + } + + @Test + void getInputStreamReturnsInputStream() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat")); + try (InputStream in = connection.getInputStream()) { + assertThat(in).hasBinaryContent(new byte[] { 3 }); + } + } + + @Test + void getInputStreamWhenNoCachedClosesJarFileOnClose() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat")); + connection.setUseCaches(false); + InputStream in = connection.getInputStream(); + JarFile jarFile = (JarFile) ReflectionTestUtils.getField(connection, "jarFile"); + jarFile = spy(jarFile); + ReflectionTestUtils.setField(connection, "jarFile", jarFile); + in.close(); + then(jarFile).should().close(); + } + + @Test + void getAllowUserInteractionDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + given(jarFileConnection.getAllowUserInteraction()).willReturn(true); + assertThat(connection.getAllowUserInteraction()).isTrue(); + then(jarFileConnection).should().getAllowUserInteraction(); + } + + @Test + void setAllowUserInteractionDelegatesToJarFileConnection() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setAllowUserInteraction(true); + then(jarFileConnection).should().setAllowUserInteraction(true); + } + + @Test + void getUseCachesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + given(jarFileConnection.getUseCaches()).willReturn(true); + assertThat(connection.getUseCaches()).isTrue(); + then(jarFileConnection).should().getUseCaches(); + } + + @Test + void setUseCachesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setUseCaches(true); + then(jarFileConnection).should().setUseCaches(true); + } + + @Test + void getDefaultUseCachesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + given(jarFileConnection.getDefaultUseCaches()).willReturn(true); + assertThat(connection.getDefaultUseCaches()).isTrue(); + then(jarFileConnection).should().getDefaultUseCaches(); + } + + @Test + void setDefaultUseCachesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setDefaultUseCaches(true); + then(jarFileConnection).should().setDefaultUseCaches(true); + } + + @Test + void setIfModifiedSinceDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setIfModifiedSince(123L); + then(jarFileConnection).should().setIfModifiedSince(123L); + } + + @Test + void getRequestPropertyDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + given(jarFileConnection.getRequestProperty("test")).willReturn("test"); + assertThat(connection.getRequestProperty("test")).isEqualTo("test"); + then(jarFileConnection).should().getRequestProperty("test"); + } + + @Test + void setRequestPropertyDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setRequestProperty("test", "testvalue"); + then(jarFileConnection).should().setRequestProperty("test", "testvalue"); + } + + @Test + void addRequestPropertyDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.addRequestProperty("test", "testvalue"); + then(jarFileConnection).should().addRequestProperty("test", "testvalue"); + } + + @Test + void getRequestPropertiesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + Map> properties = Map.of("test", List.of("testvalue")); + given(jarFileConnection.getRequestProperties()).willReturn(properties); + assertThat(connection.getRequestProperties()).isEqualTo(properties); + then(jarFileConnection).should().getRequestProperties(); + } + + @Test + void connectWhenConnectedDoesNotReconnect() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + connection.connect(); + ReflectionTestUtils.setField(connection, "jarFile", null); + connection.connect(); + assertThat(ReflectionTestUtils.getField(connection, "jarFile")).isNull(); + } + + @Test + void connectWhenHasNotFoundSupplierThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + assertThat(connection).extracting("notFound").isNotNull(); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect) + .withMessageContaining("JAR entry missing.dat not found in"); + } + + @Test + void connectWhenOptimizationsEnabledAndHasCachedJarWithoutEntryThrowsException() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + setupConnection.connect(); + assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull(); + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + Optimizations.enable(true); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect) + .isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION); + } + + @Test + void connectWhenHasNoEntryConnects() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(this.url); + setupConnection.connect(); + assertThat(setupConnection.getJarFile()).isNotNull(); + } + + @Test + void connectWhenEntryDoesNotExistAndOptimizationsEnabledThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + Optimizations.enable(true); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect) + .isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION); + } + + @Test + void connectWhenEntryDoesNotExistAndNoOptimizationsEnabledThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect) + .withMessageContaining("JAR entry missing.dat not found in"); + } + + @Test + void connectWhenEntryExists() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat")); + connection.connect(); + assertThat(connection.getJarEntry()).isNotNull(); + } + + @Test + void connectWhenAddedToCacheReconnects() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(this.url); + Object originalConnection = ReflectionTestUtils.getField(connection, "jarFileConnection"); + connection.connect(); + assertThat(connection).extracting("jarFileConnection").isNotSameAs(originalConnection); + } + + @Test + void openWhenNestedAndInCachedWithoutEntryAndOptimizationsEnabledReturnsNoFoundConnection() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + setupConnection.connect(); + assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull(); + Optimizations.enable(true); + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + assertThat(connection).isSameAs(JarUrlConnection.NOT_FOUND_CONNECTION); + } + + @Test + void openReturnsConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + assertThat(connection).isNotNull(); + } + + @Test // gh-38204 + void getLastModifiedReturnsFileModifiedTime() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + assertThat(connection.getLastModified()).isEqualTo(this.file.lastModified()); + } + + @Test // gh-38204 + void getLastModifiedHeaderReturnsFileModifiedTime() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection fileConnection = this.file.toURI().toURL().openConnection(); + try { + assertThat(connection.getHeaderFieldDate("last-modified", 0)) + .isEqualTo(withoutNanos(this.file.lastModified())) + .isEqualTo(fileConnection.getHeaderFieldDate("last-modified", 0)); + } + finally { + fileConnection.getInputStream().close(); + } + } + + @Test + void getJarFileWhenInFolderWithEncodedCharsReturnsJarFile() throws Exception { + this.temp = new File(this.temp, "te#st"); + this.temp.mkdirs(); + this.file = new File(this.temp, "test.jar"); + this.url = JarUrl.create(this.file, "nested.jar"); + assertThat(this.url.toString()).contains("te%23st"); + TestJar.create(this.file); + JarUrlConnection connection = JarUrlConnection.open(this.url); + JarFile jarFile = connection.getJarFile(); + assertThat(jarFile).isNotNull(); + assertThat(jarFile.getEntry("3.dat")).isNotNull(); + } + + private long withoutNanos(long epochMilli) { + return Instant.ofEpochMilli(epochMilli).with(ChronoField.NANO_OF_SECOND, 0).toEpochMilli(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java new file mode 100644 index 000000000000..7ca3fb683995 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.jar.JarEntry; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.util.UrlDecoder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarUrl}. + * + * @author Phillip Webb + */ +class JarUrlTests { + + @TempDir + File temp; + + File jarFile; + + String jarFileUrlPath; + + @BeforeEach + void setup() throws MalformedURLException { + this.jarFile = new File(this.temp, "my.jar"); + this.jarFileUrlPath = this.jarFile.toURI().toURL().toString().substring("file:".length()).replace("!", "%21"); + } + + @Test + void createWithFileReturnsUrl() { + URL url = JarUrl.create(this.jarFile); + assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileAndEntryReturnsUrl() { + JarEntry entry = new JarEntry("lib.jar"); + URL url = JarUrl.create(this.jarFile, entry); + assertThat(url).hasToString("jar:nested:%s/!lib.jar!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileAndNullEntryReturnsUrl() { + URL url = JarUrl.create(this.jarFile, (JarEntry) null); + assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileAndNameReturnsUrl() { + URL url = JarUrl.create(this.jarFile, "lib.jar"); + assertThat(url).hasToString("jar:nested:%s/!lib.jar!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileAndNullNameReturnsUrl() { + URL url = JarUrl.create(this.jarFile, (String) null); + assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileNameAndPathReturnsUrl() { + URL url = JarUrl.create(this.jarFile, "lib.jar", "com/example/My.class"); + assertThat(url).hasToString("jar:nested:%s/!lib.jar!/com/example/My.class".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithReservedCharsInName() throws Exception { + String badFolderName = "foo#bar!/baz/!oof"; + this.temp = new File(this.temp, badFolderName); + setup(); + URL url = JarUrl.create(this.jarFile, "lib.jar", "com/example/My.class"); + assertThat(url).hasToString("jar:nested:%s/!lib.jar!/com/example/My.class".formatted(this.jarFileUrlPath)); + assertThat(UrlDecoder.decode(url.toString())).contains(badFolderName); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java new file mode 100644 index 000000000000..88ed82f440a7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.net.protocol.jar; + +import java.io.IOException; +import java.io.InputStream; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link LazyDelegatingInputStream}. + * + * @author Phillip Webb + */ +class LazyDelegatingInputStreamTests { + + private InputStream delegate = mock(InputStream.class); + + private TestLazyDelegatingInputStream inputStream = new TestLazyDelegatingInputStream(); + + @Test + void noOperationsDoesNotGetDelegateInputStream() { + then(this.delegate).shouldHaveNoInteractions(); + } + + @Test + void readDelegatesToInputStream() throws Exception { + this.inputStream.read(); + then(this.delegate).should().read(); + } + + @Test + void readWithByteArrayDelegatesToInputStream() throws Exception { + byte[] bytes = new byte[1]; + this.inputStream.read(bytes); + then(this.delegate).should().read(bytes); + } + + @Test + void readWithByteArrayAndOffsetAndLenDelegatesToInputStream() throws Exception { + byte[] bytes = new byte[1]; + this.inputStream.read(bytes, 0, 1); + then(this.delegate).should().read(bytes, 0, 1); + } + + @Test + void skipDelegatesToInputStream() throws Exception { + this.inputStream.skip(10); + then(this.delegate).should().skip(10); + } + + @Test + void availableDelegatesToInputStream() throws Exception { + this.inputStream.available(); + then(this.delegate).should().available(); + } + + @Test + void markSupportedDelegatesToInputStream() { + this.inputStream.markSupported(); + then(this.delegate).should().markSupported(); + } + + @Test + void markDelegatesToInputStream() { + this.inputStream.mark(10); + then(this.delegate).should().mark(10); + } + + @Test + void resetDelegatesToInputStream() throws Exception { + this.inputStream.reset(); + then(this.delegate).should().reset(); + } + + @Test + void closeWhenDelegateNotCreatedDoesNothing() throws Exception { + this.inputStream.close(); + then(this.delegate).shouldHaveNoInteractions(); + } + + @Test + void closeDelegatesToInputStream() throws Exception { + this.inputStream.available(); + this.inputStream.close(); + then(this.delegate).should().close(); + } + + @Test + void getDelegateInputStreamIsOnlyCalledOnce() throws Exception { + this.inputStream.available(); + this.inputStream.mark(10); + this.inputStream.read(); + assertThat(this.inputStream.count).isOne(); + } + + private final class TestLazyDelegatingInputStream extends LazyDelegatingInputStream { + + private int count; + + @Override + protected InputStream getDelegateInputStream() throws IOException { + this.count++; + return LazyDelegatingInputStreamTests.this.delegate; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java new file mode 100644 index 000000000000..40afdb813abe --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Optimizations}. + * + * @author Phillip Webb + */ +class OptimizationsTests { + + @AfterEach + void reset() { + Optimizations.disable(); + } + + @Test + void defaultIsNotEnabled() { + assertThat(Optimizations.isEnabled()).isFalse(); + assertThat(Optimizations.isEnabled(true)).isFalse(); + assertThat(Optimizations.isEnabled(false)).isFalse(); + } + + @Test + void enableWithReadContentsEnables() { + Optimizations.enable(true); + assertThat(Optimizations.isEnabled()).isTrue(); + assertThat(Optimizations.isEnabled(true)).isTrue(); + assertThat(Optimizations.isEnabled(false)).isFalse(); + } + + @Test + void enableWithoutReadContentsEnables() { + Optimizations.enable(false); + assertThat(Optimizations.isEnabled()).isTrue(); + assertThat(Optimizations.isEnabled(true)).isFalse(); + assertThat(Optimizations.isEnabled(false)).isTrue(); + } + + @Test + void enableIsByThread() throws InterruptedException { + Optimizations.enable(true); + boolean[] enabled = new boolean[1]; + Thread thread = new Thread(() -> enabled[0] = Optimizations.isEnabled()); + thread.start(); + thread.join(); + assertThat(enabled[0]).isFalse(); + } + + @Test + void disableDisables() { + Optimizations.enable(true); + Optimizations.disable(); + assertThat(Optimizations.isEnabled()).isFalse(); + assertThat(Optimizations.isEnabled(true)).isFalse(); + assertThat(Optimizations.isEnabled(false)).isFalse(); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.java new file mode 100644 index 000000000000..44d71008f3fa --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.util.jar.Attributes; +import java.util.jar.JarEntry; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link UrlJarEntry}. + * + * @author Phillip Webb + */ +class UrlJarEntryTests { + + @Test + void ofWhenEntryIsNullReturnsNull() { + assertThat(UrlJarEntry.of(null, null)).isNull(); + } + + @Test + void ofReturnsUrlJarEntry() { + JarEntry entry = new JarEntry("test"); + assertThat(UrlJarEntry.of(entry, null)).isNotNull(); + + } + + @Test + void getAttributesDelegatesToUrlJarManifest() throws Exception { + JarEntry entry = new JarEntry("test"); + UrlJarManifest manifest = mock(UrlJarManifest.class); + Attributes attributes = mock(Attributes.class); + given(manifest.getEntryAttributes(any())).willReturn(attributes); + assertThat(UrlJarEntry.of(entry, manifest).getAttributes()).isSameAs(attributes); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java new file mode 100644 index 000000000000..69ace5f6d752 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.util.function.Consumer; +import java.util.jar.JarFile; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link UrlJarFileFactory}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class UrlJarFileFactoryTests { + + @TempDir + File temp; + + private final UrlJarFileFactory factory = new UrlJarFileFactory(); + + @Mock + private Consumer closeAction; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void createJarFileWhenLocalFile() throws Throwable { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + URL url = file.toURI().toURL(); + JarFile jarFile = this.factory.createJarFile(url, this.closeAction); + assertThat(jarFile).isInstanceOf(UrlJarFile.class); + assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction); + } + + @Test + void createJarFileWhenNested() throws Throwable { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + URL url = new URL("nested:" + file.getPath() + "/!nested.jar"); + JarFile jarFile = this.factory.createJarFile(url, this.closeAction); + assertThat(jarFile).isInstanceOf(UrlNestedJarFile.class); + assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction); + } + + @Test + void createJarFileWhenStream() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/test", (exchange) -> { + exchange.sendResponseHeaders(200, file.length()); + try (InputStream in = new FileInputStream(file)) { + in.transferTo(exchange.getResponseBody()); + } + exchange.close(); + }); + server.start(); + try { + URL url = new URL("http://localhost:" + server.getAddress().getPort() + "/test"); + JarFile jarFile = this.factory.createJarFile(url, this.closeAction); + assertThat(jarFile).isInstanceOf(UrlJarFile.class); + assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction); + } + finally { + server.stop(0); + } + } + + @Test + void createWhenHasRuntimeRef() { + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java new file mode 100644 index 000000000000..0640483c8c0c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.util.function.Consumer; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; + +/** + * Tests for {@link UrlJarFile}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class UrlJarFileTests { + + @TempDir + File temp; + + private UrlJarFile jarFile; + + @Mock + private Consumer closeAction; + + @BeforeEach + void setup() throws Exception { + MockitoAnnotations.openMocks(this); + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + this.jarFile = new UrlJarFile(file, Runtime.version(), this.closeAction); + } + + @AfterEach + void cleanup() throws Exception { + this.jarFile.close(); + } + + @Test + void getEntryWhenNotfoundReturnsNull() { + assertThat(this.jarFile.getEntry("missing")).isNull(); + } + + @Test + void getEntryWhenFoundReturnsUrlJarEntry() { + assertThat(this.jarFile.getEntry("1.dat")).isInstanceOf(UrlJarEntry.class); + } + + @Test + void getManifestReturnsNewCopy() throws Exception { + Manifest manifest1 = this.jarFile.getManifest(); + Manifest manifest2 = this.jarFile.getManifest(); + assertThat(manifest1).isNotSameAs(manifest2); + } + + @Test + void closeCallsCloseAction() throws Exception { + this.jarFile.close(); + then(this.closeAction).should().accept(this.jarFile); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java new file mode 100644 index 000000000000..f7a6ed089fc5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java @@ -0,0 +1,164 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.net.URL; +import java.net.URLConnection; +import java.util.jar.JarFile; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link UrlJarFiles}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class UrlJarFilesTests { + + @TempDir + File temp; + + private UrlJarFileFactory factory = mock(UrlJarFileFactory.class); + + private final UrlJarFiles jarFiles = new UrlJarFiles(this.factory); + + private File file; + + private URL url; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.temp, "test.jar"); + this.url = new URL("nested:" + this.file.getAbsolutePath() + "/!nested.jar"); + TestJar.create(this.file); + } + + @Test + void getOrCreateWhenNotUsingCachesAlwaysCreatesNewJarFile() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile1 = this.jarFiles.getOrCreate(false, this.url); + JarFile jarFile2 = this.jarFiles.getOrCreate(false, this.url); + JarFile jarFile3 = this.jarFiles.getOrCreate(false, this.url); + assertThat(jarFile1).isNotSameAs(jarFile2).isNotSameAs(jarFile3); + } + + @Test + void getOrCreateWhenUsingCachingReturnsCachedWhenAvailable() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile1 = this.jarFiles.getOrCreate(true, this.url); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile1); + JarFile jarFile2 = this.jarFiles.getOrCreate(true, this.url); + JarFile jarFile3 = this.jarFiles.getOrCreate(true, this.url); + assertThat(jarFile1).isSameAs(jarFile2).isSameAs(jarFile3); + } + + @Test + void getCachedWhenNotCachedReturnsNull() { + assertThat(this.jarFiles.getCached(this.url)).isNull(); + } + + @Test + void getCachedWhenCachedReturnsCachedJar() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile = this.factory.createJarFile(this.url, null); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile); + assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile); + } + + @Test + void cacheIfAbsentWhenNotUsingCachesDoesNotCacheAndReturnsFalse() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile = this.factory.createJarFile(this.url, null); + this.jarFiles.cacheIfAbsent(false, this.url, jarFile); + assertThat(this.jarFiles.getCached(this.url)).isNull(); + } + + @Test + void cacheIfAbsentWhenUsingCachingAndNotAlreadyCachedCachesAndReturnsTrue() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile = this.factory.createJarFile(this.url, null); + assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile)).isTrue(); + assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile); + } + + @Test + void cacheIfAbsentWhenUsingCachingAndAlreadyCachedLeavesCacheAndReturnsFalse() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile1 = this.factory.createJarFile(this.url, null); + JarFile jarFile2 = this.factory.createJarFile(this.url, null); + assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile1)).isTrue(); + assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile2)).isFalse(); + assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile1); + } + + @Test + void closeIfNotCachedWhenNotCachedClosesJarFile() throws Exception { + JarFile jarFile = mock(JarFile.class); + this.jarFiles.closeIfNotCached(this.url, jarFile); + then(jarFile).should().close(); + } + + @Test + void closeIfNotCachedWhenCachedDoesNotCloseJarFile() throws Exception { + JarFile jarFile = mock(JarFile.class); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile); + this.jarFiles.closeIfNotCached(this.url, jarFile); + then(jarFile).should(never()).close(); + } + + @Test + void reconnectReconnectsAndAppliesUseCaches() throws Exception { + JarFile jarFile = mock(JarFile.class); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile); + URLConnection existingConnection = mock(URLConnection.class); + given(existingConnection.getUseCaches()).willReturn(true); + URLConnection connection = this.jarFiles.reconnect(jarFile, existingConnection); + assertThat(connection).isNotSameAs(existingConnection); + assertThat(connection.getUseCaches()).isTrue(); + } + + @Test + void reconnectWhenExistingConnectionIsNullReconnects() throws Exception { + JarFile jarFile = mock(JarFile.class); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile); + URLConnection connection = this.jarFiles.reconnect(jarFile, null); + assertThat(connection).isNotNull(); + assertThat(connection.getUseCaches()).isTrue(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java new file mode 100644 index 000000000000..0bc83a023f1a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.IOException; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.net.protocol.jar.UrlJarManifest.ManifestSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +/** + * Tests for {@link UrlJarManifest}. + * + * @author Phillip Webb + */ +class UrlJarManifestTests { + + @Test + void getWhenSuppliedManifestIsNullReturnsNull() throws Exception { + UrlJarManifest urlJarManifest = new UrlJarManifest(() -> null); + assertThat(urlJarManifest.get()).isNull(); + } + + @Test + void getAlwaysReturnsDeepCopy() throws Exception { + Manifest manifest = new Manifest(); + UrlJarManifest urlJarManifest = new UrlJarManifest(() -> manifest); + manifest.getMainAttributes().putValue("test", "one"); + manifest.getEntries().put("spring", new Attributes()); + Manifest copy = urlJarManifest.get(); + assertThat(copy).isNotSameAs(manifest); + manifest.getMainAttributes().clear(); + manifest.getEntries().clear(); + assertThat(copy.getMainAttributes()).isNotEmpty(); + assertThat(copy.getAttributes("spring")).isNotNull(); + } + + @Test + void getEntryAttributesWhenSuppliedManifestIsNullReturnsNull() throws Exception { + UrlJarManifest urlJarManifest = new UrlJarManifest(() -> null); + assertThat(urlJarManifest.getEntryAttributes(new JarEntry("test"))).isNull(); + } + + @Test + void getEntryAttributesReturnsDeepCopy() throws Exception { + Manifest manifest = new Manifest(); + UrlJarManifest urlJarManifest = new UrlJarManifest(() -> manifest); + Attributes attributes = new Attributes(); + attributes.putValue("test", "test"); + manifest.getEntries().put("spring", attributes); + Attributes copy = urlJarManifest.getEntryAttributes(new JarEntry("spring")); + assertThat(copy).isNotSameAs(attributes); + attributes.clear(); + assertThat(copy.getValue("test")).isNotNull(); + + } + + @Test + void supplierIsOnlyCalledOnce() throws IOException { + ManifestSupplier supplier = mock(ManifestSupplier.class); + UrlJarManifest urlJarManifest = new UrlJarManifest(supplier); + urlJarManifest.get(); + urlJarManifest.get(); + then(supplier).should(times(1)).getManifest(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java new file mode 100644 index 000000000000..137caca278ee --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.jar; + +import java.io.File; +import java.util.function.Consumer; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.boot.loader.testsupport.TestJar; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; + +/** + * Tests for {@link UrlNestedJarFile}. + * + * @author Phillip Webb + */ +class UrlNestedJarFileTests { + + @TempDir + File temp; + + private UrlNestedJarFile jarFile; + + @Mock + private Consumer closeAction; + + @BeforeEach + void setup() throws Exception { + MockitoAnnotations.openMocks(this); + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + this.jarFile = new UrlNestedJarFile(file, "multi-release.jar", Runtime.version(), this.closeAction); + } + + @AfterEach + void cleanup() throws Exception { + this.jarFile.close(); + } + + @Test + void getEntryWhenNotfoundReturnsNull() { + assertThat(this.jarFile.getEntry("missing")).isNull(); + } + + @Test + void getEntryWhenFoundReturnsUrlJarEntry() { + assertThat(this.jarFile.getEntry("multi-release.dat")).isInstanceOf(UrlJarEntry.class); + } + + @Test + void getManifestReturnsNewCopy() throws Exception { + Manifest manifest1 = this.jarFile.getManifest(); + Manifest manifest2 = this.jarFile.getManifest(); + assertThat(manifest1).isNotSameAs(manifest2); + } + + @Test + void closeCallsCloseAction() throws Exception { + this.jarFile.close(); + then(this.closeAction).should().accept(this.jarFile); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java new file mode 100644 index 000000000000..d480c5de6192 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.nested; + +import java.io.File; +import java.net.URL; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link Handler}. + * + * @author Phillip Webb + */ +class HandlerTests { + + @TempDir + File temp; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @Test + void openConnectionReturnsNestedUrlConnection() throws Exception { + URL url = new URL("nested:" + this.temp.getAbsolutePath() + "/!nested.jar"); + assertThat(url.openConnection()).isInstanceOf(NestedUrlConnection.class); + } + + @Test + void assertUrlIsNotMalformedWhenUrlIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Handler.assertUrlIsNotMalformed(null)) + .withMessageContaining("'url' must not be null"); + } + + @Test + void assertUrlIsNotMalformedWhenUrlIsNotNestedThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Handler.assertUrlIsNotMalformed("file:")) + .withMessageContaining("must use 'nested'"); + } + + @Test + void assertUrlIsNotMalformedWhenUrlIsValidDoesNotThrowException() { + String url = "nested:" + this.temp.getAbsolutePath() + "/!nested.jar"; + assertThatNoException().isThrownBy(() -> Handler.assertUrlIsNotMalformed(url)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java new file mode 100644 index 000000000000..40449813b837 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.net.protocol.nested; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.file.Path; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link NestedLocation}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class NestedLocationTests { + + @TempDir + File temp; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @Test + void createWhenPathIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new NestedLocation(null, "nested.jar")) + .withMessageContaining("'path' must not be null"); + } + + @Test + void createWhenNestedEntryNameIsNull() { + NestedLocation location = new NestedLocation(Path.of("test.jar"), null); + assertThat(location.path().toString()).contains("test.jar"); + assertThat(location.nestedEntryName()).isNull(); + } + + @Test + void createWhenNestedEntryNameIsEmpty() { + NestedLocation location = new NestedLocation(Path.of("test.jar"), ""); + assertThat(location.path().toString()).contains("test.jar"); + assertThat(location.nestedEntryName()).isNull(); + } + + @Test + void fromUrlWhenUrlIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(null)) + .withMessageContaining("'url' must not be null"); + } + + @Test + void fromUrlWhenNotNestedProtocolThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(new URL("file://test.jar"))) + .withMessageContaining("must use 'nested' protocol"); + } + + @Test + void fromUrlWhenNoPathThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(new URL("nested:"))) + .withMessageContaining("'path' must not be empty"); + } + + @Test + void fromUrlWhenNoSeparator() throws Exception { + File file = new File(this.temp, "test.jar"); + NestedLocation location = NestedLocation.fromUrl(new URL("nested:" + file.getAbsolutePath() + "/")); + assertThat(location.path()).isEqualTo(file.toPath()); + assertThat(location.nestedEntryName()).isNull(); + } + + @Test + void fromUrlReturnsNestedLocation() throws Exception { + File file = new File(this.temp, "test.jar"); + NestedLocation location = NestedLocation + .fromUrl(new URL("nested:" + file.getAbsolutePath() + "/!lib/nested.jar")); + assertThat(location.path()).isEqualTo(file.toPath()); + assertThat(location.nestedEntryName()).isEqualTo("lib/nested.jar"); + } + + @Test + void fromUriWhenUrlIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUri(null)) + .withMessageContaining("'uri' must not be null"); + } + + @Test + void fromUriWhenNotNestedProtocolThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUri(new URI("file://test.jar"))) + .withMessageContaining("must use 'nested' scheme"); + } + + @Test + @Disabled + void fromUriWhenNoSeparator() throws Exception { + NestedLocation location = NestedLocation.fromUri(new URI("nested:test.jar!nested.jar")); + assertThat(location.path().toString()).contains("test.jar!nested.jar"); + assertThat(location.nestedEntryName()).isNull(); + } + + @Test + void fromUriReturnsNestedLocation() throws Exception { + File file = new File(this.temp, "test.jar"); + NestedLocation location = NestedLocation + .fromUri(new URI("nested:" + file.getAbsoluteFile().toURI().getPath() + "/!lib/nested.jar")); + assertThat(location.path()).isEqualTo(file.toPath()); + assertThat(location.nestedEntryName()).isEqualTo("lib/nested.jar"); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void windowsUncPathIsHandledCorrectly() throws MalformedURLException { + NestedLocation location = NestedLocation.fromUrl( + new URL("nested://localhost/c$/dev/temp/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/")); + assertThat(location.path()).asString() + .isEqualTo("\\\\localhost\\c$\\dev\\temp\\demo\\build\\libs\\demo-0.0.1-SNAPSHOT.jar"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.java new file mode 100644 index 000000000000..b194610486e2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.java @@ -0,0 +1,170 @@ +/* + * Copyright 2012-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.boot.loader.net.protocol.nested; + +import java.io.File; +import java.io.FilePermission; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.Cleaner.Cleanable; +import java.net.URL; +import java.net.URLConnection; +import java.security.Permission; +import java.time.Instant; +import java.time.temporal.ChronoField; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.ZipContent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NestedUrlConnection}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class NestedUrlConnectionTests { + + @TempDir + File temp; + + private File jarFile; + + private URL url; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @BeforeEach + void setup() throws Exception { + this.jarFile = new File(this.temp, "test.jar"); + TestJar.create(this.jarFile); + this.url = new URL("nested:" + this.jarFile.getAbsolutePath() + "/!nested.jar"); + } + + @Test + void getContentLengthWhenContentLengthMoreThanMaxIntReturnsMinusOne() { + NestedUrlConnection connection = mock(NestedUrlConnection.class); + given(connection.getContentLength()).willCallRealMethod(); + given(connection.getContentLengthLong()).willReturn((long) Integer.MAX_VALUE + 1); + assertThat(connection.getContentLength()).isEqualTo(-1); + } + + @Test + void getContentLengthGetsContentLength() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) { + int expectedSize = zipContent.getEntry("nested.jar").getUncompressedSize(); + assertThat(connection.getContentLength()).isEqualTo(expectedSize); + } + } + + @Test + void getContentLengthLongReturnsContentLength() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) { + int expectedSize = zipContent.getEntry("nested.jar").getUncompressedSize(); + assertThat(connection.getContentLengthLong()).isEqualTo(expectedSize); + } + } + + @Test + void getContentTypeReturnsJavaJar() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + assertThat(connection.getContentType()).isEqualTo("x-java/jar"); + } + + @Test + void getLastModifiedReturnsFileLastModified() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + assertThat(connection.getLastModified()).isEqualTo(this.jarFile.lastModified()); + } + + @Test + void getPermissionReturnsFilePermission() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + Permission permission = connection.getPermission(); + assertThat(permission).isInstanceOf(FilePermission.class); + assertThat(permission.getName()).isEqualTo(this.jarFile.getCanonicalPath()); + } + + @Test + void getInputStreamReturnsContentOfNestedJar() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + try (InputStream actual = connection.getInputStream()) { + try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) { + try (InputStream expected = zipContent.getEntry("nested.jar").openContent().asInputStream()) { + assertThat(actual).hasSameContentAs(expected); + } + } + } + } + + @Test + void inputStreamCloseCleansResource() throws Exception { + Cleaner cleaner = mock(Cleaner.class); + Cleanable cleanable = mock(Cleanable.class); + given(cleaner.register(any(), any())).willReturn(cleanable); + NestedUrlConnection connection = new NestedUrlConnection(this.url, cleaner); + connection.getInputStream().close(); + then(cleanable).should().clean(); + ArgumentCaptor actionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(cleaner).should().register(any(), actionCaptor.capture()); + actionCaptor.getValue().run(); + } + + @Test // gh-38204 + void getLastModifiedReturnsFileModifiedTime() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + assertThat(connection.getLastModified()).isEqualTo(this.jarFile.lastModified()); + } + + @Test // gh-38204 + void getLastModifiedHeaderReturnsFileModifiedTime() throws IOException { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + URLConnection fileConnection = this.jarFile.toURI().toURL().openConnection(); + try { + assertThat(connection.getHeaderFieldDate("last-modified", 0)) + .isEqualTo(withoutNanos(this.jarFile.lastModified())) + .isEqualTo(fileConnection.getHeaderFieldDate("last-modified", 0)); + } + finally { + fileConnection.getInputStream().close(); + } + } + + private long withoutNanos(long epochMilli) { + return Instant.ofEpochMilli(epochMilli).with(ChronoField.NANO_OF_SECOND, 0).toEpochMilli(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java new file mode 100644 index 000000000000..84708a0ba507 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-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.boot.loader.net.util; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link UrlDecoder}. + * + * @author Phillip Webb + */ +class UrlDecoderTests { + + @Test + void decodeWhenBasicString() { + assertThat(UrlDecoder.decode("a/b/C.class")).isEqualTo("a/b/C.class"); + } + + @Test + void decodeWhenHasSingleByteEncodedCharacters() { + assertThat(UrlDecoder.decode("%61/%62/%43.class")).isEqualTo("a/b/C.class"); + } + + @Test + void decodeWhenHasDoubleByteEncodedCharacters() { + assertThat(UrlDecoder.decode("%c3%a1/b/C.class")).isEqualTo("\u00e1/b/C.class"); + } + + @Test + void decodeWhenHasMixtureOfEncodedAndUnencodedDoubleByteCharacters() { + assertThat(UrlDecoder.decode("%c3%a1/b/\u00c7.class")).isEqualTo("\u00e1/b/\u00c7.class"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedByteChannelTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedByteChannelTests.java new file mode 100644 index 000000000000..7fef82d35198 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedByteChannelTests.java @@ -0,0 +1,214 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.ref.Cleaner.Cleanable; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.NonWritableChannelException; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.FileChannelDataBlockManagedFileChannel; +import org.springframework.boot.loader.zip.ZipContent; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NestedByteChannel}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class NestedByteChannelTests { + + @TempDir + File temp; + + private File file; + + private NestedByteChannel channel; + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.temp, "test.jar"); + TestJar.create(this.file); + this.channel = new NestedByteChannel(this.file.toPath(), "nested.jar"); + } + + @AfterEach + void cleanup() throws Exception { + this.channel.close(); + } + + @Test + void isOpenWhenOpenReturnsTrue() { + assertThat(this.channel.isOpen()).isTrue(); + } + + @Test + void isOpenWhenClosedReturnsFalse() throws Exception { + this.channel.close(); + assertThat(this.channel.isOpen()).isFalse(); + } + + @Test + void closeCleansResources() throws Exception { + Cleaner cleaner = mock(Cleaner.class); + Cleanable cleanable = mock(Cleanable.class); + given(cleaner.register(any(), any())).willReturn(cleanable); + NestedByteChannel channel = new NestedByteChannel(this.file.toPath(), "nested.jar", cleaner); + channel.close(); + then(cleanable).should().clean(); + ArgumentCaptor actionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(cleaner).should().register(any(), actionCaptor.capture()); + actionCaptor.getValue().run(); + } + + @Test + void closeWhenAlreadyClosedDoesNothing() throws IOException { + Cleaner cleaner = mock(Cleaner.class); + Cleanable cleanable = mock(Cleanable.class); + given(cleaner.register(any(), any())).willReturn(cleanable); + NestedByteChannel channel = new NestedByteChannel(this.file.toPath(), "nested.jar", cleaner); + channel.close(); + then(cleanable).should().clean(); + ArgumentCaptor actionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(cleaner).should().register(any(), actionCaptor.capture()); + actionCaptor.getValue().run(); + channel.close(); + then(cleaner).shouldHaveNoMoreInteractions(); + } + + @Test + void readReadsBytesAndIncrementsPosition() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(10); + assertThat(this.channel.position()).isZero(); + this.channel.read(dst); + assertThat(this.channel.position()).isEqualTo(10L); + assertThat(dst.array()).isNotEqualTo(ByteBuffer.allocate(10).array()); + } + + @Test // gh-38592 + void readReadsAsManyBytesAsPossible() throws Exception { + // ZipFileSystem checks that the number of bytes read matches an expected value + // ...if (readFullyAt(cen, 0, cen.length, cenpos) != end.cenlen + ENDHDR) + // but the readFullyAt assumes that all remaining bytes are attempted to be read + // This doesn't seem to exactly match the contract of ReadableByteChannel.read + // which states "A read operation might not fill the buffer, and in fact it might + // not read any bytes at all", but we need to match ZipFileSystem's expectations + int size = FileChannelDataBlockManagedFileChannel.BUFFER_SIZE * 2; + byte[] data = new byte[size]; + this.file = new File(this.temp, "testread.jar"); + FileOutputStream fileOutputStream = new FileOutputStream(this.file); + try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) { + JarEntry nestedEntry = new JarEntry("data"); + nestedEntry.setSize(size); + nestedEntry.setCompressedSize(size); + CRC32 crc32 = new CRC32(); + crc32.update(data); + nestedEntry.setCrc(crc32.getValue()); + nestedEntry.setMethod(ZipEntry.STORED); + jarOutputStream.putNextEntry(nestedEntry); + jarOutputStream.write(data); + jarOutputStream.closeEntry(); + } + this.channel = new NestedByteChannel(this.file.toPath(), null); + ByteBuffer buffer = ByteBuffer.allocate((int) this.file.length()); + assertThat(this.channel.read(buffer)).isEqualTo(buffer.capacity()); + assertThat(this.file).binaryContent().isEqualTo(buffer.array()); + } + + @Test + void writeThrowsException() { + assertThatExceptionOfType(NonWritableChannelException.class) + .isThrownBy(() -> this.channel.write(ByteBuffer.allocate(10))); + } + + @Test + void positionWhenClosedThrowsException() throws Exception { + this.channel.close(); + assertThatExceptionOfType(ClosedChannelException.class).isThrownBy(() -> this.channel.position()); + } + + @Test + void positionWhenOpenReturnsPosition() throws Exception { + assertThat(this.channel.position()).isEqualTo(0L); + } + + @Test + void positionWithLongWhenClosedThrowsException() throws Exception { + this.channel.close(); + assertThatExceptionOfType(ClosedChannelException.class).isThrownBy(() -> this.channel.position(0L)); + } + + @Test + void positionWithLongWhenLessThanZeroThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.channel.position(-1)); + } + + @Test + void positionWithLongWhenEqualToSizeThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.channel.position(this.channel.size())); + } + + @Test + void positionWithLongWhenOpenUpdatesPosition() throws Exception { + ByteBuffer dst1 = ByteBuffer.allocate(10); + ByteBuffer dst2 = ByteBuffer.allocate(10); + dst2.position(1); + this.channel.read(dst1); + this.channel.position(1); + this.channel.read(dst2); + dst2.array()[0] = dst1.array()[0]; + assertThat(dst1.array()).isEqualTo(dst2.array()); + } + + @Test + void sizeWhenClosedThrowsException() throws Exception { + this.channel.close(); + assertThatExceptionOfType(ClosedChannelException.class).isThrownBy(() -> this.channel.size()); + } + + @Test + void sizeWhenOpenReturnsSize() throws IOException { + try (ZipContent content = ZipContent.open(this.file.toPath())) { + assertThat(this.channel.size()).isEqualTo(content.getEntry("nested.jar").getUncompressedSize()); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileStoreTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileStoreTests.java new file mode 100644 index 000000000000..9baf2b4f9b10 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileStoreTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.File; +import java.nio.file.FileStore; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.FileStoreAttributeView; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NestedFileStore}. + * + * @author Phillip Webb + */ +class NestedFileStoreTests { + + @TempDir + File temp; + + private NestedFileSystemProvider provider; + + private Path jarPath; + + private NestedFileSystem fileSystem; + + private TestNestedFileStore fileStore; + + @BeforeEach + void setup() { + this.provider = new NestedFileSystemProvider(); + this.jarPath = new File(this.temp, "test.jar").toPath(); + this.fileSystem = new NestedFileSystem(this.provider, this.jarPath); + this.fileStore = new TestNestedFileStore(this.fileSystem); + } + + @Test + void nameReturnsName() { + assertThat(this.fileStore.name()).isEqualTo(this.jarPath.toAbsolutePath().toString()); + } + + @Test + void typeReturnsNestedFs() { + assertThat(this.fileStore.type()).isEqualTo("nestedfs"); + } + + @Test + void isReadOnlyReturnsTrue() { + assertThat(this.fileStore.isReadOnly()).isTrue(); + } + + @Test + void getTotalSpaceReturnsZero() throws Exception { + assertThat(this.fileStore.getTotalSpace()).isZero(); + } + + @Test + void getUsableSpaceReturnsZero() throws Exception { + assertThat(this.fileStore.getUsableSpace()).isZero(); + } + + @Test + void getUnallocatedSpaceReturnsZero() throws Exception { + assertThat(this.fileStore.getUnallocatedSpace()).isZero(); + } + + @Test + void supportsFileAttributeViewWithClassDelegatesToJarPathFileStore() { + FileStore jarFileStore = mock(FileStore.class); + given(jarFileStore.supportsFileAttributeView(BasicFileAttributeView.class)).willReturn(true); + this.fileStore.setJarPathFileStore(jarFileStore); + assertThat(this.fileStore.supportsFileAttributeView(BasicFileAttributeView.class)).isTrue(); + then(jarFileStore).should().supportsFileAttributeView(BasicFileAttributeView.class); + } + + @Test + void supportsFileAttributeViewWithStringDelegatesToJarPathFileStore() { + FileStore jarFileStore = mock(FileStore.class); + given(jarFileStore.supportsFileAttributeView("basic")).willReturn(true); + this.fileStore.setJarPathFileStore(jarFileStore); + assertThat(this.fileStore.supportsFileAttributeView("basic")).isTrue(); + then(jarFileStore).should().supportsFileAttributeView("basic"); + } + + @Test + void getFileStoreAttributeViewDelegatesToJarPathFileStore() { + FileStore jarFileStore = mock(FileStore.class); + TestFileStoreAttributeView attributeView = mock(TestFileStoreAttributeView.class); + given(jarFileStore.getFileStoreAttributeView(TestFileStoreAttributeView.class)).willReturn(attributeView); + this.fileStore.setJarPathFileStore(jarFileStore); + assertThat(this.fileStore.getFileStoreAttributeView(TestFileStoreAttributeView.class)).isEqualTo(attributeView); + then(jarFileStore).should().getFileStoreAttributeView(TestFileStoreAttributeView.class); + } + + @Test + void getAttributeDelegatesToJarPathFileStore() throws Exception { + FileStore jarFileStore = mock(FileStore.class); + given(jarFileStore.getAttribute("test")).willReturn("spring"); + this.fileStore.setJarPathFileStore(jarFileStore); + assertThat(this.fileStore.getAttribute("test")).isEqualTo("spring"); + then(jarFileStore).should().getAttribute("test"); + } + + static class TestNestedFileStore extends NestedFileStore { + + TestNestedFileStore(NestedFileSystem fileSystem) { + super(fileSystem); + } + + private FileStore jarPathFileStore; + + void setJarPathFileStore(FileStore jarPathFileStore) { + this.jarPathFileStore = jarPathFileStore; + } + + @Override + protected FileStore getJarPathFileStore() { + return (this.jarPathFileStore != null) ? this.jarPathFileStore : super.getJarPathFileStore(); + } + + } + + abstract static class TestFileStoreAttributeView implements FileStoreAttributeView { + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemProviderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemProviderTests.java new file mode 100644 index 000000000000..1204705a2098 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemProviderTests.java @@ -0,0 +1,276 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.File; +import java.net.URI; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemAlreadyExistsException; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.NoSuchFileException; +import java.nio.file.NotDirectoryException; +import java.nio.file.Path; +import java.nio.file.ReadOnlyFileSystemException; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.spi.FileSystemProvider; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NestedFileSystemProvider}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class NestedFileSystemProviderTests { + + @TempDir + File temp; + + private File file; + + private TestNestedFileSystemProvider provider = new TestNestedFileSystemProvider(); + + private String uriPrefix; + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.temp, "test.jar"); + TestJar.create(this.file); + this.uriPrefix = "nested:" + this.file.toURI().getPath() + "/!"; + } + + @Test + void getSchemeReturnsScheme() { + assertThat(this.provider.getScheme()).isEqualTo("nested"); + } + + @Test + void newFilesSystemWhenBadUrlThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.provider.newFileSystem(new URI("bad:notreal"), Collections.emptyMap())) + .withMessageContaining("must use 'nested' scheme"); + } + + @Test + void newFileSystemWhenAlreadyExistsThrowsException() throws Exception { + this.provider.newFileSystem(new URI(this.uriPrefix + "nested.jar"), null); + assertThatExceptionOfType(FileSystemAlreadyExistsException.class) + .isThrownBy(() -> this.provider.newFileSystem(new URI(this.uriPrefix + "other.jar"), null)); + } + + @Test + void newFileSystemReturnsFileSystem() throws Exception { + FileSystem fileSystem = this.provider.newFileSystem(new URI(this.uriPrefix + "nested.jar"), null); + assertThat(fileSystem).isInstanceOf(NestedFileSystem.class); + } + + @Test + void getFileSystemWhenBadUrlThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.provider.getFileSystem(new URI("bad:notreal"))) + .withMessageContaining("must use 'nested' scheme"); + } + + @Test + void getFileSystemWhenNotCreatedThrowsException() { + assertThatExceptionOfType(FileSystemNotFoundException.class) + .isThrownBy(() -> this.provider.getFileSystem(new URI(this.uriPrefix + "nested.jar"))); + } + + @Test + void getFileSystemReturnsFileSystem() throws Exception { + FileSystem expected = this.provider.newFileSystem(new URI(this.uriPrefix + "nested.jar"), null); + assertThat(this.provider.getFileSystem(new URI(this.uriPrefix + "nested.jar"))).isSameAs(expected); + } + + @Test + void getPathWhenFileSystemExistsReturnsPath() throws Exception { + URI uri = new URI(this.uriPrefix + "nested.jar"); + this.provider.newFileSystem(uri, null); + assertThat(this.provider.getPath(uri)).isInstanceOf(NestedPath.class); + } + + @Test + void getPathWhenFileSystemDoesNtExistReturnsPath() throws Exception { + URI uri = new URI(this.uriPrefix + "nested.jar"); + assertThat(this.provider.getPath(uri)).isInstanceOf(NestedPath.class); + } + + @Test + void newByteChannelReturnsByteChannel() throws Exception { + URI uri = new URI(this.uriPrefix + "nested.jar"); + Path path = this.provider.getPath(uri); + try (SeekableByteChannel byteChannel = this.provider.newByteChannel(path, Set.of(StandardOpenOption.READ))) { + assertThat(byteChannel).isInstanceOf(NestedByteChannel.class); + } + } + + @Test + void newDirectoryStreamThrowsException() throws Exception { + URI uri = new URI(this.uriPrefix + "nested.jar"); + Path path = this.provider.getPath(uri); + assertThatExceptionOfType(NotDirectoryException.class) + .isThrownBy(() -> this.provider.newDirectoryStream(path, null)); + } + + @Test + void createDirectoryThrowsException() throws Exception { + URI uri = new URI(this.uriPrefix + "nested.jar"); + Path path = this.provider.getPath(uri); + assertThatExceptionOfType(ReadOnlyFileSystemException.class) + .isThrownBy(() -> this.provider.createDirectory(path)); + } + + @Test + void deleteThrowsException() throws Exception { + URI uri = new URI(this.uriPrefix + "nested.jar"); + Path path = this.provider.getPath(uri); + assertThatExceptionOfType(ReadOnlyFileSystemException.class).isThrownBy(() -> this.provider.delete(path)); + } + + @Test + void copyThrowsException() throws Exception { + URI uri = new URI(this.uriPrefix + "nested.jar"); + Path path = this.provider.getPath(uri); + assertThatExceptionOfType(ReadOnlyFileSystemException.class).isThrownBy(() -> this.provider.copy(path, path)); + } + + @Test + void moveThrowsException() throws Exception { + URI uri = new URI(this.uriPrefix + "nested.jar"); + Path path = this.provider.getPath(uri); + assertThatExceptionOfType(ReadOnlyFileSystemException.class).isThrownBy(() -> this.provider.move(path, path)); + } + + @Test + void isSameFileWhenSameReturnsTrue() throws Exception { + Path p1 = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + Path p2 = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + assertThat(this.provider.isSameFile(p1, p1)).isTrue(); + assertThat(this.provider.isSameFile(p1, p2)).isTrue(); + } + + @Test + void isSameFileWhenDifferentReturnsFalse() throws Exception { + Path p1 = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + Path p2 = this.provider.getPath(new URI(this.uriPrefix + "other.jar")); + assertThat(this.provider.isSameFile(p1, p2)).isFalse(); + } + + @Test + void isHiddenReturnsFalse() throws Exception { + Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + assertThat(this.provider.isHidden(path)).isFalse(); + } + + @Test + void getFileStoreWhenFileDoesNotExistThrowsException() throws Exception { + Path path = this.provider.getPath(new URI(this.uriPrefix + "missing.jar")); + assertThatExceptionOfType(NoSuchFileException.class).isThrownBy(() -> this.provider.getFileStore(path)); + } + + @Test + void getFileStoreReturnsFileStore() throws Exception { + Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + assertThat(this.provider.getFileStore(path)).isInstanceOf(NestedFileStore.class); + } + + @Test + void checkAccessDelegatesToJarPath() throws Exception { + Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + Path jarPath = mockJarPath(); + this.provider.setMockJarPath(jarPath); + this.provider.checkAccess(path); + then(jarPath.getFileSystem().provider()).should().checkAccess(jarPath); + } + + @Test + void getFileAttributeViewDelegatesToJarPath() throws Exception { + Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + Path jarPath = mockJarPath(); + this.provider.setMockJarPath(jarPath); + this.provider.getFileAttributeView(path, BasicFileAttributeView.class); + then(jarPath.getFileSystem().provider()).should().getFileAttributeView(jarPath, BasicFileAttributeView.class); + } + + @Test + void readAttributesWithTypeDelegatesToJarPath() throws Exception { + Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + Path jarPath = mockJarPath(); + this.provider.setMockJarPath(jarPath); + this.provider.readAttributes(path, BasicFileAttributes.class); + then(jarPath.getFileSystem().provider()).should().readAttributes(jarPath, BasicFileAttributes.class); + } + + @Test + void readAttributesWithNameDelegatesToJarPath() throws Exception { + Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + Path jarPath = mockJarPath(); + this.provider.setMockJarPath(jarPath); + this.provider.readAttributes(path, "basic"); + then(jarPath.getFileSystem().provider()).should().readAttributes(jarPath, "basic"); + } + + @Test + void setAttributeThrowsException() throws Exception { + Path path = this.provider.getPath(new URI(this.uriPrefix + "nested.jar")); + assertThatExceptionOfType(ReadOnlyFileSystemException.class) + .isThrownBy(() -> this.provider.setAttribute(path, "test", "test")); + } + + private Path mockJarPath() { + Path path = mock(Path.class); + FileSystem fileSystem = mock(FileSystem.class); + given(path.getFileSystem()).willReturn(fileSystem); + FileSystemProvider provider = mock(FileSystemProvider.class); + given(fileSystem.provider()).willReturn(provider); + return path; + } + + static class TestNestedFileSystemProvider extends NestedFileSystemProvider { + + private Path mockJarPath; + + @Override + protected Path getJarPath(Path path) { + return (this.mockJarPath != null) ? this.mockJarPath : super.getJarPath(path); + } + + void setMockJarPath(Path mockJarPath) { + this.mockJarPath = mockJarPath; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemTests.java new file mode 100644 index 000000000000..a65c534c568b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemTests.java @@ -0,0 +1,191 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.nio.file; + +import java.io.File; +import java.nio.file.ClosedFileSystemException; +import java.nio.file.Path; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link NestedFileSystem}. + * + * @author Phillip Webb + */ +class NestedFileSystemTests { + + @TempDir + File temp; + + private NestedFileSystemProvider provider; + + private Path jarPath; + + private NestedFileSystem fileSystem; + + @BeforeEach + void setup() { + this.provider = new NestedFileSystemProvider(); + this.jarPath = new File(this.temp, "test.jar").toPath(); + this.fileSystem = new NestedFileSystem(this.provider, this.jarPath); + } + + @Test + void providerReturnsProvider() { + assertThat(this.fileSystem.provider()).isSameAs(this.provider); + } + + @Test + void getJarPathReturnsJarPath() { + assertThat(this.fileSystem.getJarPath()).isSameAs(this.jarPath); + } + + @Test + void closeClosesFileSystem() throws Exception { + this.fileSystem.close(); + assertThat(this.fileSystem.isOpen()).isFalse(); + } + + @Test + void closeWhenAlreadyClosedDoesNothing() throws Exception { + this.fileSystem.close(); + this.fileSystem.close(); + assertThat(this.fileSystem.isOpen()).isFalse(); + } + + @Test + void isOpenWhenOpenReturnsTrue() { + assertThat(this.fileSystem.isOpen()).isTrue(); + } + + @Test + void isOpenWhenClosedReturnsFalse() throws Exception { + this.fileSystem.close(); + assertThat(this.fileSystem.isOpen()).isFalse(); + } + + @Test + void isReadOnlyReturnsTrue() { + assertThat(this.fileSystem.isReadOnly()).isTrue(); + } + + @Test + void getSeparatorReturnsSeparator() { + assertThat(this.fileSystem.getSeparator()).isEqualTo("/!"); + } + + @Test + void getRootDirectoryWhenOpenReturnsEmptyIterable() { + assertThat(this.fileSystem.getRootDirectories()).isEmpty(); + } + + @Test + void getRootDirectoryWhenClosedThrowsException() throws Exception { + this.fileSystem.close(); + assertThatExceptionOfType(ClosedFileSystemException.class) + .isThrownBy(() -> this.fileSystem.getRootDirectories()); + } + + @Test + void supportedFileAttributeViewsWhenOpenReturnsBasic() { + assertThat(this.fileSystem.supportedFileAttributeViews()).containsExactly("basic"); + } + + @Test + void supportedFileAttributeViewsWhenClosedThrowsException() throws Exception { + this.fileSystem.close(); + assertThatExceptionOfType(ClosedFileSystemException.class) + .isThrownBy(() -> this.fileSystem.supportedFileAttributeViews()); + } + + @Test + void getPathWhenClosedThrowsException() throws Exception { + this.fileSystem.close(); + assertThatExceptionOfType(ClosedFileSystemException.class) + .isThrownBy(() -> this.fileSystem.getPath("nested.jar")); + } + + @Test + void getPathWhenFirstIsNull() { + Path path = this.fileSystem.getPath(null); + assertThat(path.toString()).endsWith(File.separator + "test.jar"); + } + + @Test + void getPathWhenFirstIsBlank() { + Path path = this.fileSystem.getPath(""); + assertThat(path.toString()).endsWith(File.separator + "test.jar"); + } + + @Test + void getPathWhenMoreIsNotEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.fileSystem.getPath("nested.jar", "another.jar")) + .withMessage("Nested paths must contain a single element"); + } + + @Test + void getPathReturnsPath() { + assertThat(this.fileSystem.getPath("nested.jar")).isInstanceOf(NestedPath.class); + } + + @Test + void getPathMatchThrowsException() { + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> this.fileSystem.getPathMatcher("*")) + .withMessage("Nested paths do not support path matchers"); + } + + @Test + void getUserPrincipalLookupServiceThrowsException() { + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> this.fileSystem.getUserPrincipalLookupService()) + .withMessage("Nested paths do not have a user principal lookup service"); + } + + @Test + void newWatchServiceThrowsException() { + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> this.fileSystem.newWatchService()) + .withMessage("Nested paths do not support the WatchService"); + } + + @Test + void toStringReturnsString() { + assertThat(this.fileSystem).hasToString(this.jarPath.toAbsolutePath().toString()); + } + + @Test + void equalsAndHashCode() { + Path jp1 = new File(this.temp, "test1.jar").toPath(); + Path jp2 = new File(this.temp, "test1.jar").toPath(); + Path jp3 = new File(this.temp, "test2.jar").toPath(); + NestedFileSystem f1 = new NestedFileSystem(this.provider, jp1); + NestedFileSystem f2 = new NestedFileSystem(this.provider, jp1); + NestedFileSystem f3 = new NestedFileSystem(this.provider, jp2); + NestedFileSystem f4 = new NestedFileSystem(this.provider, jp3); + assertThat(f1.hashCode()).isEqualTo(f2.hashCode()); + assertThat(f1).isEqualTo(f1).isEqualTo(f2).isEqualTo(f3).isNotEqualTo(f4); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemZipFileSystemIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemZipFileSystemIntegrationTests.java new file mode 100644 index 000000000000..9756fda1e5fa --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedFileSystemZipFileSystemIntegrationTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.File; +import java.net.URI; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link NestedFileSystem} in combination with + * {@code ZipFileSystem}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class NestedFileSystemZipFileSystemIntegrationTests { + + @TempDir + File temp; + + @Test + void zip() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + URI uri = JarUrl.create(file).toURI(); + try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) { + assertThat(Files.readAllBytes(fs.getPath("1.dat"))).containsExactly(0x1); + } + } + + @Test + void nestedZip() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + URI uri = JarUrl.create(file, "nested.jar").toURI(); + try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) { + assertThat(Files.readAllBytes(fs.getPath("3.dat"))).containsExactly(0x3); + } + } + + @Test + void nestedZipWithoutNewFileSystem() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + URI uri = JarUrl.create(file, "nested.jar", "3.dat").toURI(); + Path path = Path.of(uri); + assertThat(Files.readAllBytes(path)).containsExactly(0x3); + } + + @Test // gh-38592 + void nestedZipSplitAndRestore() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + URI uri = JarUrl.create(file, "nested.jar", "3.dat").toURI(); + String[] components = uri.toString().split("!"); + System.out.println(List.of(components)); + try (FileSystem rootFs = FileSystems.newFileSystem(URI.create(components[0]), Collections.emptyMap())) { + Path childPath = rootFs.getPath(components[1]); + try (FileSystem childFs = FileSystems.newFileSystem(childPath)) { + Path nestedRoot = childFs.getPath("/"); + assertThat(Files.list(nestedRoot)).hasSize(4); + Path path = childFs.getPath(components[2]); + assertThat(Files.readAllBytes(path)).containsExactly(0x3); + } + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedPathTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedPathTests.java new file mode 100644 index 000000000000..df75d9d930f3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/nio/file/NestedPathTests.java @@ -0,0 +1,235 @@ +/* + * Copyright 2012-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.boot.loader.nio.file; + +import java.io.File; +import java.net.URI; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.ProviderMismatchException; +import java.nio.file.WatchService; +import java.util.Set; +import java.util.TreeSet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.testsupport.TestJar; + +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.Mockito.mock; + +/** + * Tests for {@link NestedPath}. + * + * @author Phillip Webb + */ +class NestedPathTests { + + @TempDir + File temp; + + private NestedFileSystem fileSystem; + + private NestedFileSystemProvider provider; + + private Path jarPath; + + private NestedPath path; + + @BeforeEach + void setup() { + this.jarPath = new File(this.temp, "test.jar").toPath(); + this.provider = new NestedFileSystemProvider(); + this.fileSystem = new NestedFileSystem(this.provider, this.jarPath); + this.path = new NestedPath(this.fileSystem, "nested.jar"); + } + + @Test + void getJarPathReturnsJarPath() { + assertThat(this.path.getJarPath()).isEqualTo(this.jarPath); + } + + @Test + void getNestedEntryNameReturnsNestedEntryName() { + assertThat(this.path.getNestedEntryName()).isEqualTo("nested.jar"); + } + + @Test + void getFileSystemReturnsFileSystem() { + assertThat(this.path.getFileSystem()).isSameAs(this.fileSystem); + } + + @Test + void isAbsoluteReturnsTrue() { + assertThat(this.path.isAbsolute()).isTrue(); + } + + @Test + void getRootReturnsNull() { + assertThat(this.path.getRoot()).isNull(); + } + + @Test + void getFileNameReturnsPath() { + assertThat(this.path.getFileName()).isSameAs(this.path); + } + + @Test + void getParentReturnsNull() { + assertThat(this.path.getParent()).isNull(); + } + + @Test + void getNameCountReturnsOne() { + assertThat(this.path.getNameCount()).isEqualTo(1); + } + + @Test + void subPathWhenBeginZeroEndOneReturnsPath() { + assertThat(this.path.subpath(0, 1)).isSameAs(this.path); + } + + @Test + void subPathWhenBeginIndexNotZeroThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.path.subpath(1, 1)) + .withMessage("Nested paths only have a single element"); + } + + @Test + void subPathThenEndIndexNotOneThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.path.subpath(0, 2)) + .withMessage("Nested paths only have a single element"); + } + + @Test + void startsWithWhenStartsWithReturnsTrue() { + NestedPath otherPath = new NestedPath(this.fileSystem, "nested.jar"); + assertThat(this.path.startsWith(otherPath)).isTrue(); + } + + @Test + void startsWithWhenNotStartsWithReturnsFalse() { + NestedPath otherPath = new NestedPath(this.fileSystem, "other.jar"); + assertThat(this.path.startsWith(otherPath)).isFalse(); + } + + @Test + void endsWithWhenEndsWithReturnsTrue() { + NestedPath otherPath = new NestedPath(this.fileSystem, "nested.jar"); + assertThat(this.path.endsWith(otherPath)).isTrue(); + } + + @Test + void endsWithWhenNotEndsWithReturnsFalse() { + NestedPath otherPath = new NestedPath(this.fileSystem, "other.jar"); + assertThat(this.path.endsWith(otherPath)).isFalse(); + } + + @Test + void normalizeReturnsPath() { + assertThat(this.path.normalize()).isSameAs(this.path); + } + + @Test + void resolveThrowsException() { + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> this.path.resolve(this.path)) + .withMessage("Unable to resolve nested path"); + } + + @Test + void relativizeThrowsException() { + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> this.path.relativize(this.path)) + .withMessage("Unable to relativize nested path"); + } + + @Test + void toUriReturnsUri() throws Exception { + assertThat(this.path.toUri()).isEqualTo(new URI("nested:" + this.jarPath.toUri().getPath() + "/!nested.jar")); + } + + @Test + void toAbsolutePathReturnsPath() { + assertThat(this.path.toAbsolutePath()).isSameAs(this.path); + } + + @Test + void toRealPathReturnsPath() throws Exception { + assertThat(this.path.toRealPath()).isSameAs(this.path); + } + + @Test + void registerThrowsException() { + WatchService watcher = mock(WatchService.class); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> this.path.register(watcher)) + .withMessage("Nested paths cannot be watched"); + } + + @Test + void compareToComparesOnNestedEntryName() { + NestedPath a = new NestedPath(this.fileSystem, "a.jar"); + NestedPath b = new NestedPath(this.fileSystem, "b.jar"); + NestedPath c = new NestedPath(this.fileSystem, "c.jar"); + assertThat(new TreeSet<>(Set.of(c, a, b))).containsExactly(a, b, c); + } + + @Test + void hashCodeAndEquals() { + NestedFileSystem fs2 = new NestedFileSystem(this.provider, new File(this.temp, "test2.jar").toPath()); + NestedPath p1 = new NestedPath(this.fileSystem, "a.jar"); + NestedPath p2 = new NestedPath(this.fileSystem, "a.jar"); + NestedPath p3 = new NestedPath(this.fileSystem, "c.jar"); + NestedPath p4 = new NestedPath(fs2, "c.jar"); + assertThat(p1.hashCode()).isEqualTo(p2.hashCode()); + assertThat(p1).isEqualTo(p1).isEqualTo(p2).isNotEqualTo(p3).isNotEqualTo(p4); + } + + @Test + void toStringReturnsString() { + assertThat(this.path).hasToString(this.jarPath.toString() + "/!nested.jar"); + } + + @Test + void assertExistsWhenExists() throws Exception { + TestJar.create(this.jarPath.toFile()); + this.path.assertExists(); + } + + @Test + void assertExistsWhenDoesNotExistThrowsException() { + assertThatExceptionOfType(NoSuchFileException.class).isThrownBy(this.path::assertExists); + } + + @Test + void castWhenNestedPathReturnsNestedPath() { + assertThat(NestedPath.cast(this.path)).isSameAs(this.path); + } + + @Test + void castWhenNullThrowsException() { + assertThatExceptionOfType(ProviderMismatchException.class).isThrownBy(() -> NestedPath.cast(null)); + } + + @Test + void castWhenNotNestedPathThrowsException() { + assertThatExceptionOfType(ProviderMismatchException.class).isThrownBy(() -> NestedPath.cast(this.jarPath)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java new file mode 100644 index 000000000000..c2342c7a5425 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-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.boot.loader.ref; + +import java.lang.ref.Cleaner.Cleanable; +import java.util.function.BiConsumer; + +/** + * Utility that allows tests to set a tracker on {@link DefaultCleaner}. + * + * @author Phillip Webb + */ +public final class DefaultCleanerTracking { + + private DefaultCleanerTracking() { + } + + public static void set(BiConsumer tracker) { + DefaultCleaner.tracker = tracker; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java new file mode 100644 index 000000000000..137f684c0796 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java @@ -0,0 +1,162 @@ +/* + * Copyright 2012-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.boot.loader.testsupport; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +/** + * Support class to create or get test jars. + * + * @author Phillip Webb + */ +public abstract class TestJar { + + public static final int MULTI_JAR_VERSION = Runtime.version().feature(); + + private static final int BASE_VERSION = 8; + + public static void create(File file) throws Exception { + create(file, false); + } + + public static void create(File file, boolean unpackNested) throws Exception { + create(file, unpackNested, false); + } + + public static void create(File file, boolean unpackNested, boolean addSignatureFile) throws Exception { + FileOutputStream fileOutputStream = new FileOutputStream(file); + try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) { + jarOutputStream.setComment("outer"); + writeManifest(jarOutputStream, "j1"); + if (addSignatureFile) { + writeEntry(jarOutputStream, "META-INF/some.DSA", 0); + } + writeEntry(jarOutputStream, "1.dat", 1); + writeEntry(jarOutputStream, "2.dat", 2); + writeDirEntry(jarOutputStream, "d/"); + writeEntry(jarOutputStream, "d/9.dat", 9); + writeDirEntry(jarOutputStream, "special/"); + writeEntry(jarOutputStream, "special/\u00EB.dat", '\u00EB'); + writeNestedEntry("nested.jar", unpackNested, jarOutputStream); + writeNestedEntry("another-nested.jar", unpackNested, jarOutputStream); + writeNestedEntry("space nested.jar", unpackNested, jarOutputStream); + writeNestedMultiReleaseEntry("multi-release.jar", unpackNested, jarOutputStream); + } + } + + public static List expectedEntries() { + return List.of("META-INF/", "META-INF/MANIFEST.MF", "1.dat", "2.dat", "d/", "d/9.dat", "special/", + "special/\u00EB.dat", "nested.jar", "another-nested.jar", "space nested.jar", "multi-release.jar"); + } + + private static void writeNestedEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream) + throws Exception { + writeNestedEntry(name, unpackNested, jarOutputStream, false); + } + + private static void writeNestedMultiReleaseEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream) + throws Exception { + writeNestedEntry(name, unpackNested, jarOutputStream, true); + } + + private static void writeNestedEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream, + boolean multiRelease) throws Exception { + JarEntry nestedEntry = new JarEntry(name); + byte[] nestedJarData = getNestedJarData(multiRelease); + nestedEntry.setSize(nestedJarData.length); + nestedEntry.setCompressedSize(nestedJarData.length); + if (unpackNested) { + nestedEntry.setComment("UNPACK:0000000000000000000000000000000000000000"); + } + CRC32 crc32 = new CRC32(); + crc32.update(nestedJarData); + nestedEntry.setCrc(crc32.getValue()); + nestedEntry.setMethod(ZipEntry.STORED); + jarOutputStream.putNextEntry(nestedEntry); + jarOutputStream.write(nestedJarData); + jarOutputStream.closeEntry(); + } + + private static byte[] getNestedJarData(boolean multiRelease) throws Exception { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + JarOutputStream jarOutputStream = new JarOutputStream(byteArrayOutputStream); + jarOutputStream.setComment("nested"); + writeManifest(jarOutputStream, "j2", multiRelease); + if (multiRelease) { + writeEntry(jarOutputStream, "multi-release.dat", BASE_VERSION); + writeEntry(jarOutputStream, String.format("META-INF/versions/%d/multi-release.dat", MULTI_JAR_VERSION), + MULTI_JAR_VERSION); + } + else { + writeEntry(jarOutputStream, "3.dat", 3); + writeEntry(jarOutputStream, "4.dat", 4); + writeEntry(jarOutputStream, "\u00E4.dat", '\u00E4'); + } + jarOutputStream.close(); + return byteArrayOutputStream.toByteArray(); + } + + private static void writeManifest(JarOutputStream jarOutputStream, String name) throws Exception { + writeManifest(jarOutputStream, name, false); + } + + private static void writeManifest(JarOutputStream jarOutputStream, String name, boolean multiRelease) + throws Exception { + writeDirEntry(jarOutputStream, "META-INF/"); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Built-By", name); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + if (multiRelease) { + manifest.getMainAttributes().putValue("Multi-Release", Boolean.toString(true)); + } + jarOutputStream.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + manifest.write(jarOutputStream); + jarOutputStream.closeEntry(); + } + + private static void writeDirEntry(JarOutputStream jarOutputStream, String name) throws IOException { + jarOutputStream.putNextEntry(new JarEntry(name)); + jarOutputStream.closeEntry(); + } + + private static void writeEntry(JarOutputStream jarOutputStream, String name, int data) throws IOException { + jarOutputStream.putNextEntry(new JarEntry(name)); + jarOutputStream.write(new byte[] { (byte) data }); + jarOutputStream.closeEntry(); + } + + public static File getSigned() { + String[] entries = System.getProperty("java.class.path").split(System.getProperty("path.separator")); + for (String entry : entries) { + if (entry.contains("bcprov")) { + return new File(entry); + } + } + return null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConstructorBinding.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java similarity index 67% rename from spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConstructorBinding.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java index ee1ae440f81c..75c208e5853f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/DeprecatedConstructorBinding.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.configurationsample; +package org.springframework.boot.loader.zip; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -22,18 +22,18 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + /** - * Alternative to Spring Boot's deprecated - * {@code @org.springframework.boot.context.properties.ConstructorBinding} for testing - * (removes the need for a dependency on the real annotation). + * Annotation that can be added to tests to assert that {@link FileChannelDataBlock} files + * are not left open. * - * @author Stephane Nicoll + * @author Phillip Webb */ -@Target(ElementType.CONSTRUCTOR) +@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented -@ConstructorBinding -@Deprecated(since = "3.0.0", forRemoval = true) -public @interface DeprecatedConstructorBinding { +@ExtendWith(AssertFileChannelDataBlocksClosedExtension.class) +public @interface AssertFileChannelDataBlocksClosed { } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.java new file mode 100644 index 000000000000..21ad19da62c4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.zip; + +import java.io.Closeable; +import java.io.IOException; +import java.lang.ref.Cleaner.Cleanable; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import org.springframework.boot.loader.ref.DefaultCleanerTracking; +import org.springframework.boot.loader.zip.FileChannelDataBlock.Tracker; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Extension for {@link AssertFileChannelDataBlocksClosed @TrackFileChannelDataBlock}. + */ +class AssertFileChannelDataBlocksClosedExtension implements BeforeEachCallback, AfterEachCallback { + + private static OpenFilesTracker tracker = new OpenFilesTracker(); + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + tracker.clear(); + FileChannelDataBlock.tracker = tracker; + DefaultCleanerTracking.set(tracker::addedCleanable); + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + tracker.assertAllClosed(); + FileChannelDataBlock.tracker = null; + } + + private static final class OpenFilesTracker implements Tracker { + + private final Set paths = new LinkedHashSet<>(); + + private final List clean = new ArrayList<>(); + + private final List close = new ArrayList<>(); + + @Override + public void openedFileChannel(Path path, FileChannel fileChannel) { + this.paths.add(path); + } + + @Override + public void closedFileChannel(Path path, FileChannel fileChannel) { + this.paths.remove(path); + } + + void clear() { + this.paths.clear(); + this.clean.clear(); + } + + void assertAllClosed() throws IOException { + for (Closeable closeable : this.close) { + closeable.close(); + } + this.clean.forEach(Cleanable::clean); + assertThat(this.paths).as("open paths").isEmpty(); + } + + private void addedCleanable(Object obj, Cleanable cleanable) { + if (cleanable != null) { + this.clean.add(cleanable); + } + if (obj instanceof Closeable closeable) { + this.close.add(closeable); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java new file mode 100644 index 000000000000..7c78ec4276fb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ByteArrayDataBlock}. + * + * @author Phillip Webb + */ +class ByteArrayDataBlockTests { + + private final byte[] BYTES = { 0, 1, 2, 3, 4, 5, 6, 7 }; + + @Test + void sizeReturnsByteArrayLength() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + assertThat(dataBlock.size()).isEqualTo(this.BYTES.length); + } + + @Test + void readPutsBytes() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + ByteBuffer dst = ByteBuffer.allocate(8); + int result = dataBlock.read(dst, 0); + assertThat(result).isEqualTo(8); + assertThat(dst.array()).containsExactly(this.BYTES); + } + + @Test + void readWhenLessBytesThanRemainingInBufferPutsBytes() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + ByteBuffer dst = ByteBuffer.allocate(9); + int result = dataBlock.read(dst, 0); + assertThat(result).isEqualTo(8); + assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 0); + } + + @Test + void readWhenLessRemainingInBufferThanLengthPutsBytes() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + ByteBuffer dst = ByteBuffer.allocate(7); + int result = dataBlock.read(dst, 0); + assertThat(result).isEqualTo(7); + assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5, 6); + } + + @Test + void readWhenHasPosOffsetReadsBytes() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + ByteBuffer dst = ByteBuffer.allocate(3); + int result = dataBlock.read(dst, 4); + assertThat(result).isEqualTo(3); + assertThat(dst.array()).containsExactly(4, 5, 6); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockInputStreamTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockInputStreamTests.java new file mode 100644 index 000000000000..d5330ba9bed3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockInputStreamTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.io.InputStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; + +/** + * Tests for {@link DataBlockInputStream}. + * + * @author Phillip Webb + */ +class DataBlockInputStreamTests { + + private ByteArrayDataBlock dataBlock; + + private InputStream inputStream; + + @BeforeEach + void setup() throws Exception { + this.dataBlock = new ByteArrayDataBlock(new byte[] { 0, 1, 2 }); + this.inputStream = this.dataBlock.asInputStream(); + } + + @Test + void readSingleByteReadsByte() throws Exception { + assertThat(this.inputStream.read()).isEqualTo(0); + assertThat(this.inputStream.read()).isEqualTo(1); + assertThat(this.inputStream.read()).isEqualTo(2); + assertThat(this.inputStream.read()).isEqualTo(-1); + } + + @Test + void readByteArrayWhenNotOpenThrowsException() throws Exception { + byte[] bytes = new byte[10]; + this.inputStream.close(); + assertThatIOException().isThrownBy(() -> this.inputStream.read(bytes)).withMessage("InputStream closed"); + } + + @Test + void readByteArrayWhenReadingMultipleTimesReadsBytes() throws Exception { + byte[] bytes = new byte[3]; + assertThat(this.inputStream.read(bytes, 0, 2)).isEqualTo(2); + assertThat(this.inputStream.read(bytes, 2, 1)).isEqualTo(1); + assertThat(bytes).containsExactly(0, 1, 2); + } + + @Test + void readByteArrayWhenReadingMoreThanAvailableReadsRemainingBytes() throws Exception { + byte[] bytes = new byte[5]; + assertThat(this.inputStream.read(bytes, 0, 5)).isEqualTo(3); + assertThat(bytes).containsExactly(0, 1, 2, 0, 0); + } + + @Test + void skipSkipsBytes() throws Exception { + assertThat(this.inputStream.skip(2)).isEqualTo(2); + assertThat(this.inputStream.read()).isEqualTo(2); + assertThat(this.inputStream.read()).isEqualTo(-1); + } + + @Test + void skipWhenSkippingMoreThanRemainingSkipsBytes() throws Exception { + assertThat(this.inputStream.skip(100)).isEqualTo(3); + assertThat(this.inputStream.read()).isEqualTo(-1); + } + + @Test + void skipBackwardsSkipsBytes() throws IOException { + assertThat(this.inputStream.skip(2)).isEqualTo(2); + assertThat(this.inputStream.skip(-1)).isEqualTo(-1); + assertThat(this.inputStream.read()).isEqualTo(1); + } + + @Test + void skipBackwardsPastBeginningSkipsBytes() throws Exception { + assertThat(this.inputStream.skip(1)).isEqualTo(1); + assertThat(this.inputStream.skip(-100)).isEqualTo(-1); + assertThat(this.inputStream.read()).isEqualTo(0); + } + + @Test + void availableReturnsRemainingBytes() throws IOException { + assertThat(this.inputStream.available()).isEqualTo(3); + this.inputStream.read(); + assertThat(this.inputStream.available()).isEqualTo(2); + this.inputStream.skip(1); + assertThat(this.inputStream.available()).isEqualTo(1); + } + + @Test + void availableWhenClosedReturnsZero() throws IOException { + this.inputStream.close(); + assertThat(this.inputStream.available()).isZero(); + } + + @Test + void closeClosesDataBlock() throws Exception { + this.dataBlock = spy(new ByteArrayDataBlock(new byte[] { 0, 1, 2 })); + this.inputStream = this.dataBlock.asInputStream(); + this.inputStream.close(); + then(this.dataBlock).should().close(); + } + + @Test + void closeMultipleTimesClosesDataBlockOnce() throws Exception { + this.dataBlock = spy(new ByteArrayDataBlock(new byte[] { 0, 1, 2 })); + this.inputStream = this.dataBlock.asInputStream(); + this.inputStream.close(); + this.inputStream.close(); + then(this.dataBlock).should(times(1)).close(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java new file mode 100644 index 000000000000..eed800f981b3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.withSettings; + +/** + * Tests for {@link DataBlock}. + * + * @author Phillip Webb + */ +class DataBlockTests { + + @Test + void readFullyReadsAllBytesByCallingReadMultipleTimes() throws IOException { + DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + given(dataBlock.read(any(), anyLong())) + .will(putBytes(new byte[] { 0, 1 }, new byte[] { 2 }, new byte[] { 3, 4, 5 })); + ByteBuffer dst = ByteBuffer.allocate(6); + dataBlock.readFully(dst, 0); + assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5); + } + + private Answer putBytes(byte[]... bytes) { + AtomicInteger count = new AtomicInteger(); + return (invocation) -> { + int index = count.getAndIncrement(); + invocation.getArgument(0, ByteBuffer.class).put(bytes[index]); + return bytes.length; + }; + } + + @Test + void readFullyWhenReadReturnsNegativeResultThrowsException() throws Exception { + DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + given(dataBlock.read(any(), anyLong())).willReturn(-1); + ByteBuffer dst = ByteBuffer.allocate(8); + assertThatExceptionOfType(EOFException.class).isThrownBy(() -> dataBlock.readFully(dst, 0)); + } + + @Test + void asInputStreamReturnsDataBlockInputStream() throws Exception { + DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + assertThat(dataBlock.asInputStream()).isInstanceOf(DataBlockInputStream.class); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockManagedFileChannel.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockManagedFileChannel.java new file mode 100644 index 000000000000..6e945dbd43e8 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockManagedFileChannel.java @@ -0,0 +1,33 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import org.springframework.boot.loader.zip.FileChannelDataBlock.ManagedFileChannel; + +/** + * Test access to {@link ManagedFileChannel} details. + * + * @author Phillip Webb + */ +public final class FileChannelDataBlockManagedFileChannel { + + private FileChannelDataBlockManagedFileChannel() { + } + + public static int BUFFER_SIZE = FileChannelDataBlock.ManagedFileChannel.BUFFER_SIZE; + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java new file mode 100644 index 000000000000..b5abefc6aada --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java @@ -0,0 +1,251 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.zip.FileChannelDataBlock.Tracker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link FileChannelDataBlock}. + * + * @author Phillip Webb + */ +class FileChannelDataBlockTests { + + private static final byte[] CONTENT = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 }; + + @TempDir + File tempDir; + + File tempFile; + + @BeforeEach + void writeTempFile() throws IOException { + this.tempFile = new File(this.tempDir, "content"); + Files.write(this.tempFile.toPath(), CONTENT); + } + + @AfterEach + void resetTracker() { + FileChannelDataBlock.tracker = null; + } + + @Test + void sizeReturnsFileSize() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + assertThat(block.size()).isEqualTo(CONTENT.length); + } + } + + @Test + void readReadsFile() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThat(block.read(buffer, 0)).isEqualTo(6); + assertThat(buffer.array()).containsExactly(CONTENT); + } + } + + @Test + void readReadsFileWhenThreadHasBeenInterrupted() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + Thread.currentThread().interrupt(); + assertThat(block.read(buffer, 0)).isEqualTo(6); + assertThat(buffer.array()).containsExactly(CONTENT); + } + finally { + Thread.interrupted(); + } + } + + @Test + void readDoesNotReadPastEndOfFile() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThat(block.read(buffer, 2)).isEqualTo(4); + assertThat(buffer.array()).containsExactly(0x02, 0x03, 0x04, 0x05, 0x0, 0x0); + } + } + + @Test + void readWhenPosAtSizeReturnsMinusOne() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThat(block.read(buffer, 6)).isEqualTo(-1); + } + } + + @Test + void readWhenPosOverSizeReturnsMinusOne() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThat(block.read(buffer, 7)).isEqualTo(-1); + } + } + + @Test + void readWhenPosIsNegativeThrowsException() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThatIllegalArgumentException().isThrownBy(() -> block.read(buffer, -1)); + } + } + + @Test + void sliceWhenOffsetIsNegativeThrowsException() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + assertThatIllegalArgumentException().isThrownBy(() -> block.slice(-1, 0)) + .withMessage("Offset must not be negative"); + } + } + + @Test + void sliceWhenSizeIsNegativeThrowsException() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + assertThatIllegalArgumentException().isThrownBy(() -> block.slice(0, -1)) + .withMessage("Size must not be negative and must be within bounds"); + } + } + + @Test + void sliceWhenSizeIsOutOfBoundsThrowsException() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + assertThatIllegalArgumentException().isThrownBy(() -> block.slice(2, 5)) + .withMessage("Size must not be negative and must be within bounds"); + } + } + + @Test + void sliceReturnsSlice() throws IOException { + try (FileChannelDataBlock slice = createAndOpenBlock().slice(1, 4)) { + assertThat(slice.size()).isEqualTo(4); + ByteBuffer buffer = ByteBuffer.allocate(4); + assertThat(slice.read(buffer, 0)).isEqualTo(4); + assertThat(buffer.array()).containsExactly(0x01, 0x02, 0x03, 0x04); + } + } + + @Test + void openAndCloseHandleReferenceCounting() throws IOException { + TestTracker tracker = new TestTracker(); + FileChannelDataBlock.tracker = tracker; + FileChannelDataBlock block = createBlock(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(0, 0); + block.open(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(1, 0); + block.open(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(2); + tracker.assertOpenCloseCounts(1, 0); + block.close(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(1, 0); + block.close(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(1, 1); + block.open(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(2, 1); + block.close(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(2, 2); + } + + @Test + void openAndCloseSliceHandleReferenceCounting() throws IOException { + TestTracker tracker = new TestTracker(); + FileChannelDataBlock.tracker = tracker; + FileChannelDataBlock block = createBlock(); + FileChannelDataBlock slice = block.slice(1, 4); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(0, 0); + block.open(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(1, 0); + slice.open(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(2); + tracker.assertOpenCloseCounts(1, 0); + slice.open(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(3); + tracker.assertOpenCloseCounts(1, 0); + slice.close(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(2); + tracker.assertOpenCloseCounts(1, 0); + slice.close(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(1, 0); + block.close(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(1, 1); + slice.open(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(2, 1); + slice.close(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(2, 2); + } + + private FileChannelDataBlock createAndOpenBlock() throws IOException { + FileChannelDataBlock block = createBlock(); + block.open(); + return block; + } + + private FileChannelDataBlock createBlock() throws IOException { + return new FileChannelDataBlock(this.tempFile.toPath()); + } + + static class TestTracker implements Tracker { + + private int openCount; + + private int closeCount; + + @Override + public void openedFileChannel(Path path, FileChannel fileChannel) { + this.openCount++; + } + + @Override + public void closedFileChannel(Path path, FileChannel fileChannel) { + this.closeCount++; + } + + void assertOpenCloseCounts(int expectedOpenCount, int expectedCloseCount) { + assertThat(this.openCount).as("openCount").isEqualTo(expectedOpenCount); + assertThat(this.closeCount).as("closeCount").isEqualTo(expectedCloseCount); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java new file mode 100644 index 000000000000..c2b8c8338392 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link VirtualDataBlock}. + * + * @author Phillip Webb + */ +class VirtualDataBlockTests { + + private VirtualDataBlock virtualDataBlock; + + @BeforeEach + void setup() throws IOException { + List subsections = new ArrayList<>(); + subsections.add(new ByteArrayDataBlock("abc".getBytes(StandardCharsets.UTF_8))); + subsections.add(new ByteArrayDataBlock("defg".getBytes(StandardCharsets.UTF_8))); + subsections.add(new ByteArrayDataBlock("h".getBytes(StandardCharsets.UTF_8))); + this.virtualDataBlock = new VirtualDataBlock(subsections); + } + + @Test + void sizeReturnsSize() throws IOException { + assertThat(this.virtualDataBlock.size()).isEqualTo(8); + } + + @Test + void readFullyReadsAllBlocks() throws IOException { + ByteBuffer dst = ByteBuffer.allocate((int) this.virtualDataBlock.size()); + this.virtualDataBlock.readFully(dst, 0); + assertThat(dst.array()).containsExactly("abcdefgh".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void readWithShortBlock() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(2); + assertThat(this.virtualDataBlock.read(dst, 1)).isEqualTo(2); + assertThat(dst.array()).containsExactly("bc".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void readWithShortBlockAcrossSubsections() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(3); + assertThat(this.virtualDataBlock.read(dst, 2)).isEqualTo(3); + assertThat(dst.array()).containsExactly("cde".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void readWithBigBlock() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(16); + assertThat(this.virtualDataBlock.read(dst, 1)).isEqualTo(7); + assertThat(dst.array()).startsWith("bcdefgh".getBytes(StandardCharsets.UTF_8)); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java new file mode 100644 index 000000000000..33f93bfb0f02 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java @@ -0,0 +1,138 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.testsupport.TestJar; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link VirtualZipDataBlock}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class VirtualZipDataBlockTests { + + @TempDir + File tempDir; + + private File file; + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.tempDir, "test.jar"); + TestJar.create(this.file); + } + + @Test + void createContainsValidZipContent() throws IOException { + FileChannelDataBlock data = new FileChannelDataBlock(this.file.toPath()); + data.open(); + List centralRecords = new ArrayList<>(); + List centralRecordPositions = new ArrayList<>(); + ZipEndOfCentralDirectoryRecord eocd = ZipEndOfCentralDirectoryRecord.load(data).endOfCentralDirectoryRecord(); + long pos = eocd.offsetToStartOfCentralDirectory(); + for (int i = 0; i < eocd.totalNumberOfCentralDirectoryEntries(); i++) { + ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos); + String name = ZipString.readString(data, pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET, + centralRecord.fileNameLength()); + if (name.endsWith(".jar")) { + centralRecords.add(centralRecord); + centralRecordPositions.add(pos); + } + pos += centralRecord.size(); + } + NameOffsetLookups nameOffsetLookups = new NameOffsetLookups(2, centralRecords.size()); + for (int i = 0; i < centralRecords.size(); i++) { + nameOffsetLookups.enable(i, true); + } + nameOffsetLookups.enable(0, true); + File outputFile = new File(this.tempDir, "out.jar"); + try (VirtualZipDataBlock block = new VirtualZipDataBlock(data, nameOffsetLookups, + centralRecords.toArray(ZipCentralDirectoryFileHeaderRecord[]::new), + centralRecordPositions.stream().mapToLong(Long::longValue).toArray())) { + try (FileOutputStream out = new FileOutputStream(outputFile)) { + block.asInputStream().transferTo(out); + } + } + try (FileSystem fileSystem = FileSystems.newFileSystem(outputFile.toPath())) { + assertThatExceptionOfType(NoSuchFileException.class) + .isThrownBy(() -> Files.size(fileSystem.getPath("nessted.jar"))); + assertThat(Files.size(fileSystem.getPath("sted.jar"))).isGreaterThan(0); + assertThat(Files.size(fileSystem.getPath("other-nested.jar"))).isGreaterThan(0); + assertThat(Files.size(fileSystem.getPath("ace nested.jar"))).isGreaterThan(0); + assertThat(Files.size(fileSystem.getPath("lti-release.jar"))).isGreaterThan(0); + } + } + + @Test // gh-38063 + void createWithDescriptorRecordContainsValidZipContent() throws Exception { + try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(this.file))) { + ZipEntry entry = new ZipEntry("META-INF/"); + entry.setMethod(ZipEntry.DEFLATED); + zip.putNextEntry(entry); + zip.write(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }); + zip.closeEntry(); + } + byte[] bytes = Files.readAllBytes(this.file.toPath()); + CloseableDataBlock data = new ByteArrayDataBlock(bytes); + List centralRecords = new ArrayList<>(); + List centralRecordPositions = new ArrayList<>(); + ZipEndOfCentralDirectoryRecord eocd = ZipEndOfCentralDirectoryRecord.load(data).endOfCentralDirectoryRecord(); + long pos = eocd.offsetToStartOfCentralDirectory(); + for (int i = 0; i < eocd.totalNumberOfCentralDirectoryEntries(); i++) { + ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos); + centralRecords.add(centralRecord); + centralRecordPositions.add(pos); + pos += centralRecord.size(); + } + NameOffsetLookups nameOffsetLookups = new NameOffsetLookups(0, centralRecords.size()); + for (int i = 0; i < centralRecords.size(); i++) { + nameOffsetLookups.enable(i, true); + } + nameOffsetLookups.enable(0, true); + File outputFile = new File(this.tempDir, "out.jar"); + try (VirtualZipDataBlock block = new VirtualZipDataBlock(data, nameOffsetLookups, + centralRecords.toArray(ZipCentralDirectoryFileHeaderRecord[]::new), + centralRecordPositions.stream().mapToLong(Long::longValue).toArray())) { + try (FileOutputStream out = new FileOutputStream(outputFile)) { + block.asInputStream().transferTo(out); + } + } + byte[] virtualBytes = Files.readAllBytes(outputFile.toPath()); + assertThat(bytes).isEqualTo(virtualBytes); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.java new file mode 100644 index 000000000000..78b5a00498ce --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Zip64EndOfCentralDirectoryLocator}. + * + * @author Phillip Webb + */ +class Zip64EndOfCentralDirectoryLocatorTests { + + @Test + void findReturnsRecord() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x06, 0x07, // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x03, 0x00, 0x00, 0x00 }); // + Zip64EndOfCentralDirectoryLocator eocd = Zip64EndOfCentralDirectoryLocator.find(dataBlock, 20); + assertThat(eocd.pos()).isEqualTo(0); + assertThat(eocd.numberOfThisDisk()).isEqualTo(1); + assertThat(eocd.offsetToZip64EndOfCentralDirectoryRecord()).isEqualTo(2); + assertThat(eocd.totalNumberOfDisks()).isEqualTo(3); + } + + @Test + void findWhenSignatureDoesNotMatchReturnsNull() throws IOException { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x06, 0x07, // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x03, 0x00, 0x00, 0x00 }); // + Zip64EndOfCentralDirectoryLocator eocd = Zip64EndOfCentralDirectoryLocator.find(dataBlock, 20); + assertThat(eocd).isNull(); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java new file mode 100644 index 000000000000..486d34970ddd --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link Zip64EndOfCentralDirectoryRecord}. + * + * @author Phillip Webb + */ +class Zip64EndOfCentralDirectoryRecordTests { + + @Test + void loadLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x06, 0x06, // + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, 0x00, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }); // + Zip64EndOfCentralDirectoryLocator locator = new Zip64EndOfCentralDirectoryLocator(56, 0, 0, 0); + Zip64EndOfCentralDirectoryRecord eocd = Zip64EndOfCentralDirectoryRecord.load(dataBlock, locator); + assertThat(eocd.size()).isEqualTo(56); + assertThat(eocd.sizeOfZip64EndOfCentralDirectoryRecord()).isEqualTo(1); + assertThat(eocd.versionMadeBy()).isEqualTo((short) 2); + assertThat(eocd.versionNeededToExtract()).isEqualTo((short) 3); + assertThat(eocd.numberOfThisDisk()).isEqualTo(4); + assertThat(eocd.diskWhereCentralDirectoryStarts()).isEqualTo(5); + assertThat(eocd.numberOfCentralDirectoryEntriesOnThisDisk()).isEqualTo(6); + assertThat(eocd.totalNumberOfCentralDirectoryEntries()).isEqualTo(7); + assertThat(eocd.sizeOfCentralDirectory()).isEqualTo(8); + assertThat(eocd.offsetToStartOfCentralDirectory()); + } + + @Test + void loadWhenSignatureDoesNotMatchThrowsException() { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x06, 0x06, // + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, 0x00, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }); // + Zip64EndOfCentralDirectoryLocator locator = new Zip64EndOfCentralDirectoryLocator(56, 0, 0, 0); + assertThatIOException().isThrownBy(() -> Zip64EndOfCentralDirectoryRecord.load(dataBlock, locator)) + .withMessageContaining("Zip64 'End Of Central Directory Record' not found at position"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java new file mode 100644 index 000000000000..5fe7e9ee8897 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java @@ -0,0 +1,216 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link ZipCentralDirectoryFileHeaderRecord}. + * + * @author Phillip Webb + */ +class ZipCentralDirectoryFileHeaderRecordTests { + + @Test + void loadLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x01, 0x02, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, // + 0x0A, 0x00, // + 0x0B, 0x00, // + 0x0C, 0x00, // + 0x0D, 0x00, // + 0x0E, 0x00, // + 0x0F, 0x00, 0x00, 0x00, // + 0x10, 0x00, 0x00, 0x00 }); // + ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0); + assertThat(record.versionMadeBy()).isEqualTo((short) 1); + assertThat(record.versionNeededToExtract()).isEqualTo((short) 2); + assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3); + assertThat(record.compressionMethod()).isEqualTo((short) 4); + assertThat(record.lastModFileTime()).isEqualTo((short) 5); + assertThat(record.lastModFileDate()).isEqualTo((short) 6); + assertThat(record.crc32()).isEqualTo(7); + assertThat(record.compressedSize()).isEqualTo(8); + assertThat(record.uncompressedSize()).isEqualTo(9); + assertThat(record.fileNameLength()).isEqualTo((short) 10); + assertThat(record.extraFieldLength()).isEqualTo((short) 11); + assertThat(record.fileCommentLength()).isEqualTo((short) 12); + assertThat(record.diskNumberStart()).isEqualTo((short) 13); + assertThat(record.internalFileAttributes()).isEqualTo((short) 14); + assertThat(record.externalFileAttributes()).isEqualTo(15); + assertThat(record.offsetToLocalHeader()).isEqualTo(16); + } + + @Test + void loadWhenSignatureDoesNotMatchThrowsException() { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x01, 0x02, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, // + 0x0A, 0x00, // + 0x0B, 0x00, // + 0x0C, 0x00, // + 0x0D, 0x00, // + 0x0E, 0x00, // + 0x0F, 0x00, 0x00, 0x00, // + 0x10, 0x00, 0x00, 0x00 }); // + assertThatIOException().isThrownBy(() -> ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0)) + .withMessageContaining("'Central Directory File Header Record' not found"); + } + + @Test + void sizeReturnsSize() { + ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2, + (short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13, + (short) 14, 15, 16); + assertThat(record.size()).isEqualTo(79L); + } + + @Test + void copyToCopiesDataToZipEntry() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x01, 0x02, // + 0x00, 0x00, // + 0x00, 0x00, // + 0x00, 0x00, // + 0x08, 0x00, // + 0x23, 0x74, // + 0x58, 0x36, // + (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, // + 0x01, 0x00, // + 0x01, 0x00, // + 0x01, 0x00, // + 0x00, 0x00, // + 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x61, // + 0x62, // + 0x63 }); // + ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0); + ZipEntry entry = new ZipEntry(""); + record.copyTo(dataBlock, 0, entry); + assertThat(entry.getMethod()).isEqualTo(ZipEntry.DEFLATED); + assertThat(entry.getTimeLocal()).hasYear(2007); + ZonedDateTime expectedTime = ZonedDateTime.of(2007, 02, 24, 14, 33, 06, 0, ZoneId.systemDefault()); + assertThat(entry.getTime()).isEqualTo(expectedTime.toEpochSecond() * 1000); + assertThat(entry.getCrc()).isEqualTo(0xFFFFFFFFL); + assertThat(entry.getCompressedSize()).isEqualTo(1); + assertThat(entry.getSize()).isEqualTo(2); + assertThat(entry.getExtra()).containsExactly(0x62); + assertThat(entry.getComment()).isEqualTo("c"); + } + + @Test + void withFileNameLengthReturnsUpdatedInstance() { + ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2, + (short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13, + (short) 14, 15, 16) + .withFileNameLength((short) 100); + assertThat(record.versionMadeBy()).isEqualTo((short) 1); + assertThat(record.versionNeededToExtract()).isEqualTo((short) 2); + assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3); + assertThat(record.compressionMethod()).isEqualTo((short) 4); + assertThat(record.lastModFileTime()).isEqualTo((short) 5); + assertThat(record.lastModFileDate()).isEqualTo((short) 6); + assertThat(record.crc32()).isEqualTo(7); + assertThat(record.compressedSize()).isEqualTo(8); + assertThat(record.uncompressedSize()).isEqualTo(9); + assertThat(record.fileNameLength()).isEqualTo((short) 100); + assertThat(record.extraFieldLength()).isEqualTo((short) 11); + assertThat(record.fileCommentLength()).isEqualTo((short) 12); + assertThat(record.diskNumberStart()).isEqualTo((short) 13); + assertThat(record.internalFileAttributes()).isEqualTo((short) 14); + assertThat(record.externalFileAttributes()).isEqualTo(15); + assertThat(record.offsetToLocalHeader()).isEqualTo(16); + } + + @Test + void withOffsetToLocalHeaderReturnsUpdatedInstance() { + ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2, + (short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13, + (short) 14, 15, 16) + .withOffsetToLocalHeader(100); + assertThat(record.versionMadeBy()).isEqualTo((short) 1); + assertThat(record.versionNeededToExtract()).isEqualTo((short) 2); + assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3); + assertThat(record.compressionMethod()).isEqualTo((short) 4); + assertThat(record.lastModFileTime()).isEqualTo((short) 5); + assertThat(record.lastModFileDate()).isEqualTo((short) 6); + assertThat(record.crc32()).isEqualTo(7); + assertThat(record.compressedSize()).isEqualTo(8); + assertThat(record.uncompressedSize()).isEqualTo(9); + assertThat(record.fileNameLength()).isEqualTo((short) 10); + assertThat(record.extraFieldLength()).isEqualTo((short) 11); + assertThat(record.fileCommentLength()).isEqualTo((short) 12); + assertThat(record.diskNumberStart()).isEqualTo((short) 13); + assertThat(record.internalFileAttributes()).isEqualTo((short) 14); + assertThat(record.externalFileAttributes()).isEqualTo(15); + assertThat(record.offsetToLocalHeader()).isEqualTo(100); + } + + @Test + void asByteArrayReturnsByteArray() throws Exception { + byte[] bytes = new byte[] { // + 0x50, 0x4b, 0x01, 0x02, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, // + 0x0A, 0x00, // + 0x0B, 0x00, // + 0x0C, 0x00, // + 0x0D, 0x00, // + 0x0E, 0x00, // + 0x0F, 0x00, 0x00, 0x00, // + 0x10, 0x00, 0x00, 0x00 }; + DataBlock dataBlock = new ByteArrayDataBlock(bytes); + ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0); + assertThat(record.asByteArray()).containsExactly(bytes); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java new file mode 100644 index 000000000000..f8fee9f81195 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java @@ -0,0 +1,454 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.zip; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.Iterator; +import java.util.Random; +import java.util.jar.Manifest; +import java.util.zip.CRC32; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.ZipContent.Entry; +import org.springframework.boot.loader.zip.ZipContent.Kind; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StreamUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link ZipContent}. + * + * @author Phillip Webb + * @author Martin Lau + * @author Andy Wilkinson + * @author Madhura Bhave + */ +class ZipContentTests { + + @TempDir + File tempDir; + + private File file; + + private ZipContent zipContent; + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.tempDir, "test.jar"); + TestJar.create(this.file); + this.zipContent = ZipContent.open(this.file.toPath()); + } + + @AfterEach + void tearDown() throws Exception { + if (this.zipContent != null) { + try { + this.zipContent.close(); + } + catch (IllegalStateException ex) { + } + } + } + + @Test + void getCommentReturnsComment() { + assertThat(this.zipContent.getComment()).isEqualTo("outer"); + } + + @Test + void getCommentWhenClosedThrowsException() throws IOException { + this.zipContent.close(); + assertThatIllegalStateException().isThrownBy(() -> this.zipContent.getComment()) + .withMessage("Zip content closed"); + } + + @Test + void getEntryWhenPresentReturnsEntry() { + Entry entry = this.zipContent.getEntry("1.dat"); + assertThat(entry).isNotNull(); + assertThat(entry.getName()).isEqualTo("1.dat"); + } + + @Test + void getEntryWhenMissingReturnsNull() { + assertThat(this.zipContent.getEntry("missing.dat")).isNull(); + } + + @Test + void getEntryWithPrefixWhenPresentReturnsEntry() { + Entry entry = this.zipContent.getEntry("1", ".dat"); + assertThat(entry).isNotNull(); + assertThat(entry.getName()).isEqualTo("1.dat"); + } + + @Test + void getEntryWithLongPrefixWhenNameIsShorterReturnsNull() { + Entry entry = this.zipContent.getEntry("iamaverylongprefixandiwontfindanything", "1.dat"); + assertThat(entry).isNull(); + } + + @Test + void getEntryWithPrefixWhenMissingReturnsNull() { + assertThat(this.zipContent.getEntry("miss", "ing.dat")).isNull(); + } + + @Test + void getEntryWhenUsingSlashesIsCompatibleWithZipFile() throws IOException { + try (ZipFile zipFile = new ZipFile(this.file)) { + assertThat(zipFile.getEntry("META-INF").getName()).isEqualTo("META-INF/"); + assertThat(this.zipContent.getEntry("META-INF").getName()).isEqualTo("META-INF/"); + assertThat(zipFile.getEntry("META-INF/").getName()).isEqualTo("META-INF/"); + assertThat(this.zipContent.getEntry("META-INF/").getName()).isEqualTo("META-INF/"); + assertThat(zipFile.getEntry("d/9.dat").getName()).isEqualTo("d/9.dat"); + assertThat(this.zipContent.getEntry("d/9.dat").getName()).isEqualTo("d/9.dat"); + assertThat(zipFile.getEntry("d/9.dat/")).isNull(); + assertThat(this.zipContent.getEntry("d/9.dat/")).isNull(); + } + } + + @Test + void getManifestEntry() throws Exception { + Entry entry = this.zipContent.getEntry("META-INF/MANIFEST.MF"); + try (CloseableDataBlock dataBlock = entry.openContent()) { + Manifest manifest = new Manifest(asInflaterInputStream(dataBlock)); + assertThat(manifest.getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + } + + @Test + void getEntryAsCreatesCompatibleEntries() throws IOException { + try (ZipFile zipFile = new ZipFile(this.file)) { + Iterator expected = zipFile.entries().asIterator(); + int i = 0; + while (expected.hasNext()) { + Entry actual = this.zipContent.getEntry(i++); + assertThatFieldsAreEqual(actual.as(ZipEntry::new), expected.next()); + } + } + } + + @Test + void getKindWhenZipReturnsZip() { + assertThat(this.zipContent.getKind()).isEqualTo(Kind.ZIP); + } + + @Test + void getKindWhenNestedZipReturnsNestedZip() throws IOException { + try (ZipContent nested = ZipContent.open(this.file.toPath(), "nested.jar")) { + assertThat(nested.getKind()).isEqualTo(Kind.NESTED_ZIP); + } + } + + @Test + void getKindWhenNestedDirectoryReturnsNestedDirectory() throws IOException { + try (ZipContent nested = ZipContent.open(this.file.toPath(), "d/")) { + assertThat(nested.getKind()).isEqualTo(Kind.NESTED_DIRECTORY); + } + } + + private void assertThatFieldsAreEqual(ZipEntry actual, ZipEntry expected) { + assertThat(actual.getName()).isEqualTo(expected.getName()); + assertThat(actual.getTime()).isEqualTo(expected.getTime()); + assertThat(actual.getLastModifiedTime()).isEqualTo(expected.getLastModifiedTime()); + assertThat(actual.getLastAccessTime()).isEqualTo(expected.getLastAccessTime()); + assertThat(actual.getCreationTime()).isEqualTo(expected.getCreationTime()); + assertThat(actual.getSize()).isEqualTo(expected.getSize()); + assertThat(actual.getCompressedSize()).isEqualTo(expected.getCompressedSize()); + assertThat(actual.getCrc()).isEqualTo(expected.getCrc()); + assertThat(actual.getMethod()).isEqualTo(expected.getMethod()); + assertThat(actual.getExtra()).isEqualTo(expected.getExtra()); + assertThat(actual.getComment()).isEqualTo(expected.getComment()); + } + + @Test + void sizeReturnsNumberOfEntries() { + assertThat(this.zipContent.size()).isEqualTo(12); + } + + @Test + void nestedJarFileReturnsNestedJar() throws IOException { + try (ZipContent nested = ZipContent.open(this.file.toPath(), "nested.jar")) { + assertThat(nested.size()).isEqualTo(5); + assertThat(nested.getComment()).isEqualTo("nested"); + assertThat(nested.size()).isEqualTo(5); + assertThat(nested.getEntry(0).getName()).isEqualTo("META-INF/"); + assertThat(nested.getEntry(1).getName()).isEqualTo("META-INF/MANIFEST.MF"); + assertThat(nested.getEntry(2).getName()).isEqualTo("3.dat"); + assertThat(nested.getEntry(3).getName()).isEqualTo("4.dat"); + assertThat(nested.getEntry(4).getName()).isEqualTo("\u00E4.dat"); + } + } + + @Test + void nestedJarFileWhenNameEndsInSlashThrowsException() { + assertThatIOException().isThrownBy(() -> ZipContent.open(this.file.toPath(), "nested.jar/")) + .withMessageStartingWith("Nested entry 'nested.jar/' not found in container zip"); + } + + @Test + void nestedDirectoryReturnsNestedJar() throws IOException { + try (ZipContent nested = ZipContent.open(this.file.toPath(), "d/")) { + assertThat(nested.size()).isEqualTo(1); + assertThat(nested.getEntry("9.dat")).isNotNull(); + assertThat(nested.getEntry(0).getName()).isEqualTo("9.dat"); + } + } + + @Test + void nestedDirectoryWhenNotEndingInSlashThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ZipContent.open(this.file.toPath(), "d")) + .withMessage("Nested entry name must end with '/'"); + } + + @Test + void getDataWhenNestedDirectoryReturnsVirtualZipDataBlock() throws IOException { + try (ZipContent nested = ZipContent.open(this.file.toPath(), "d/")) { + File file = new File(this.tempDir, "included.zip"); + write(file, nested.openRawZipData()); + try (ZipFile loadedZipFile = new ZipFile(file)) { + assertThat(loadedZipFile.size()).isEqualTo(1); + assertThat(loadedZipFile.stream().map(ZipEntry::getName)).containsExactly("9.dat"); + assertThat(loadedZipFile.getEntry("9.dat")).isNotNull(); + try (InputStream in = loadedZipFile.getInputStream(loadedZipFile.getEntry("9.dat"))) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + in.transferTo(out); + assertThat(out.toByteArray()).containsExactly(0x09); + } + } + } + } + + @Test + void loadWhenHasFrontMatterOpensZip() throws IOException { + File fileWithFrontMatter = new File(this.tempDir, "withfrontmatter.jar"); + FileOutputStream outputStream = new FileOutputStream(fileWithFrontMatter); + StreamUtils.copy("#/bin/bash", Charset.defaultCharset(), outputStream); + FileCopyUtils.copy(new FileInputStream(this.file), outputStream); + try (ZipContent zip = ZipContent.open(fileWithFrontMatter.toPath())) { + assertThat(zip.size()).isEqualTo(12); + assertThat(zip.getEntry(0).getName()).isEqualTo("META-INF/"); + assertThat(zip.getEntry(1).getName()).isEqualTo("META-INF/MANIFEST.MF"); + assertThat(zip.getEntry(2).getName()).isEqualTo("1.dat"); + assertThat(zip.getEntry(3).getName()).isEqualTo("2.dat"); + assertThat(zip.getEntry(4).getName()).isEqualTo("d/"); + assertThat(zip.getEntry(5).getName()).isEqualTo("d/9.dat"); + assertThat(zip.getEntry(6).getName()).isEqualTo("special/"); + assertThat(zip.getEntry(7).getName()).isEqualTo("special/\u00EB.dat"); + assertThat(zip.getEntry(8).getName()).isEqualTo("nested.jar"); + assertThat(zip.getEntry(9).getName()).isEqualTo("another-nested.jar"); + assertThat(zip.getEntry(10).getName()).isEqualTo("space nested.jar"); + assertThat(zip.getEntry(11).getName()).isEqualTo("multi-release.jar"); + } + } + + @Test + void openWhenZip64ThatExceedsZipEntryLimitOpensZip() throws Exception { + File zip64File = new File(this.tempDir, "zip64.zip"); + FileCopyUtils.copy(zip64Bytes(), zip64File); + try (ZipContent zip64Content = ZipContent.open(zip64File.toPath())) { + assertThat(zip64Content.size()).isEqualTo(65537); + for (int i = 0; i < zip64Content.size(); i++) { + Entry entry = zip64Content.getEntry(i); + try (CloseableDataBlock dataBlock = entry.openContent()) { + assertThat(asInflaterInputStream(dataBlock)).hasContent("Entry " + (i + 1)); + } + } + } + } + + @Test + void openWhenZip64ThatExceedsZipSizeLimitOpensZip() throws Exception { + Assumptions.assumeTrue(this.tempDir.getFreeSpace() > 6L * 1024 * 1024 * 1024, "Insufficient disk space"); + File zip64File = new File(this.tempDir, "zip64.zip"); + File entryFile = new File(this.tempDir, "entry.dat"); + CRC32 crc32 = new CRC32(); + try (FileOutputStream entryOut = new FileOutputStream(entryFile)) { + byte[] data = new byte[1024 * 1024]; + new Random().nextBytes(data); + for (int i = 0; i < 1024; i++) { + entryOut.write(data); + crc32.update(data); + } + } + try (ZipOutputStream zipOutput = new ZipOutputStream(new FileOutputStream(zip64File))) { + for (int i = 0; i < 6; i++) { + ZipEntry storedEntry = new ZipEntry("huge-" + i); + storedEntry.setSize(entryFile.length()); + storedEntry.setCompressedSize(entryFile.length()); + storedEntry.setCrc(crc32.getValue()); + storedEntry.setMethod(ZipEntry.STORED); + zipOutput.putNextEntry(storedEntry); + try (FileInputStream entryIn = new FileInputStream(entryFile)) { + StreamUtils.copy(entryIn, zipOutput); + } + zipOutput.closeEntry(); + } + } + try (ZipContent zip64Content = ZipContent.open(zip64File.toPath())) { + assertThat(zip64Content.size()).isEqualTo(6); + } + } + + @Test + void nestedZip64CanBeRead() throws Exception { + File containerFile = new File(this.tempDir, "outer.zip"); + try (ZipOutputStream jarOutput = new ZipOutputStream(new FileOutputStream(containerFile))) { + ZipEntry nestedEntry = new ZipEntry("nested-zip64.zip"); + byte[] contents = zip64Bytes(); + nestedEntry.setSize(contents.length); + nestedEntry.setCompressedSize(contents.length); + CRC32 crc32 = new CRC32(); + crc32.update(contents); + nestedEntry.setCrc(crc32.getValue()); + nestedEntry.setMethod(ZipEntry.STORED); + jarOutput.putNextEntry(nestedEntry); + jarOutput.write(contents); + jarOutput.closeEntry(); + } + try (ZipContent nestedZip = ZipContent.open(containerFile.toPath(), "nested-zip64.zip")) { + assertThat(nestedZip.size()).isEqualTo(65537); + for (int i = 0; i < nestedZip.size(); i++) { + Entry entry = nestedZip.getEntry(i); + try (CloseableDataBlock content = entry.openContent()) { + assertThat(asInflaterInputStream(content)).hasContent("Entry " + (i + 1)); + } + } + } + } + + private byte[] zip64Bytes() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zipOutput = new ZipOutputStream(bytes); + for (int i = 0; i < 65537; i++) { + zipOutput.putNextEntry(new ZipEntry(i + ".dat")); + zipOutput.write(("Entry " + (i + 1)).getBytes(StandardCharsets.UTF_8)); + zipOutput.closeEntry(); + } + zipOutput.close(); + return bytes.toByteArray(); + } + + @Test + void entryWithEpochTimeOfZeroShouldNotFail() throws Exception { + File file = createZipFileWithEpochTimeOfZero(); + try (ZipContent zip = ZipContent.open(file.toPath())) { + ZipEntry entry = zip.getEntry(0).as(ZipEntry::new); + assertThat(entry.getLastModifiedTime().toInstant()).isEqualTo(Instant.EPOCH); + assertThat(entry.getName()).isEqualTo("1.dat"); + } + } + + private File createZipFileWithEpochTimeOfZero() throws Exception { + File file = new File(this.tempDir, "temp.zip"); + String comment = "outer"; + try (ZipOutputStream zipOutput = new ZipOutputStream(new FileOutputStream(file))) { + zipOutput.setComment(comment); + ZipEntry entry = new ZipEntry("1.dat"); + entry.setLastModifiedTime(FileTime.from(Instant.EPOCH)); + zipOutput.putNextEntry(entry); + zipOutput.write(new byte[] { (byte) 1 }); + zipOutput.closeEntry(); + } + ByteBuffer data = ByteBuffer.wrap(Files.readAllBytes(file.toPath())); + data.order(ByteOrder.LITTLE_ENDIAN); + int endOfCentralDirectoryRecordPos = data.remaining() - ZipFile.ENDHDR - comment.getBytes().length; + data.position(endOfCentralDirectoryRecordPos + ZipFile.ENDOFF); + int startOfCentralDirectoryOffset = data.getInt(); + data.position(startOfCentralDirectoryOffset + ZipFile.CENOFF); + int localHeaderPosition = data.getInt(); + writeTimeBlock(data.array(), startOfCentralDirectoryOffset + ZipFile.CENTIM, 0); + writeTimeBlock(data.array(), localHeaderPosition + ZipFile.LOCTIM, 0); + File zerotimedFile = new File(this.tempDir, "zerotimed.zip"); + Files.write(zerotimedFile.toPath(), data.array()); + return zerotimedFile; + } + + @Test + void getInfoReturnsComputedInfo() { + ZipInfo info = this.zipContent.getInfo(ZipInfo.class, ZipInfo::get); + assertThat(info.size()).isEqualTo(12); + } + + private static void writeTimeBlock(byte[] data, int pos, int value) { + data[pos] = (byte) (value & 0xff); + data[pos + 1] = (byte) ((value >> 8) & 0xff); + data[pos + 2] = (byte) ((value >> 16) & 0xff); + data[pos + 3] = (byte) ((value >> 24) & 0xff); + } + + private InputStream asInflaterInputStream(DataBlock dataBlock) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate((int) dataBlock.size() + 1); + buffer.limit(buffer.limit() - 1); + dataBlock.readFully(buffer, 0); + ByteArrayInputStream in = new ByteArrayInputStream(buffer.array()); + return new InflaterInputStream(in, new Inflater(true)); + } + + private void write(File file, CloseableDataBlock dataBlock) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate((int) dataBlock.size()); + dataBlock.readFully(buffer, 0); + Files.write(file.toPath(), buffer.array()); + dataBlock.close(); + } + + private static class ZipInfo { + + private int size; + + ZipInfo(int size) { + this.size = size; + } + + int size() { + return this.size; + } + + static ZipInfo get(ZipContent content) { + return new ZipInfo(content.size()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecordTests.java new file mode 100644 index 000000000000..2af772eaccfc --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipDataDescriptorRecordTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ZipDataDescriptorRecord}. + * + * @author Phillip Webb + */ +class ZipDataDescriptorRecordTests { + + private static final short S0 = 0; + + @Test + void loadWhenHasSignatureLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x07, 0x08, // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, // + 0x03, 0x00, 0x00, 0x00 }); // + ZipDataDescriptorRecord record = ZipDataDescriptorRecord.load(dataBlock, 0); + assertThat(record.includeSignature()).isTrue(); + assertThat(record.crc32()).isEqualTo(1); + assertThat(record.compressedSize()).isEqualTo(2); + assertThat(record.uncompressedSize()).isEqualTo(3); + } + + @Test + void loadWhenHasNoSignatureLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, // + 0x03, 0x00, 0x00, 0x00 }); // + ZipDataDescriptorRecord record = ZipDataDescriptorRecord.load(dataBlock, 0); + assertThat(record.includeSignature()).isFalse(); + assertThat(record.crc32()).isEqualTo(1); + assertThat(record.compressedSize()).isEqualTo(2); + assertThat(record.uncompressedSize()).isEqualTo(3); + } + + @Test + void sizeWhenIncludeSignatureReturnsSize() { + ZipDataDescriptorRecord record = new ZipDataDescriptorRecord(true, 0, 0, 0); + assertThat(record.size()).isEqualTo(16); + } + + @Test + void sizeWhenNotIncludeSignatureReturnsSize() { + ZipDataDescriptorRecord record = new ZipDataDescriptorRecord(false, 0, 0, 0); + assertThat(record.size()).isEqualTo(12); + } + + @Test + void asByteArrayWhenIncludeSignatureReturnsByteArray() throws Exception { + byte[] bytes = new byte[] { // + 0x50, 0x4b, 0x07, 0x08, // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, // + 0x03, 0x00, 0x00, 0x00 }; // + ZipDataDescriptorRecord record = ZipDataDescriptorRecord.load(new ByteArrayDataBlock(bytes), 0); + assertThat(record.asByteArray()).isEqualTo(bytes); + } + + @Test + void asByteArrayWhenNotIncludeSignatureReturnsByteArray() throws Exception { + byte[] bytes = new byte[] { // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, // + 0x03, 0x00, 0x00, 0x00 }; // + ZipDataDescriptorRecord record = ZipDataDescriptorRecord.load(new ByteArrayDataBlock(bytes), 0); + assertThat(record.asByteArray()).isEqualTo(bytes); + } + + @Test + void isPresentBasedOnFlagWhenPresentReturnsTrue() { + testIsPresentBasedOnFlag((short) 0x8, true); + } + + @Test + void isPresentBasedOnFlagWhenNotPresentReturnsFalse() { + testIsPresentBasedOnFlag((short) 0x0, false); + } + + private void testIsPresentBasedOnFlag(short flag, boolean expected) { + ZipCentralDirectoryFileHeaderRecord centralRecord = new ZipCentralDirectoryFileHeaderRecord(S0, S0, flag, S0, + S0, S0, S0, S0, S0, S0, S0, S0, S0, S0, S0, S0); + ZipLocalFileHeaderRecord localRecord = new ZipLocalFileHeaderRecord(S0, flag, S0, S0, S0, S0, S0, S0, S0, S0); + assertThat(ZipDataDescriptorRecord.isPresentBasedOnFlag(flag)).isEqualTo(expected); + assertThat(ZipDataDescriptorRecord.isPresentBasedOnFlag(centralRecord)).isEqualTo(expected); + assertThat(ZipDataDescriptorRecord.isPresentBasedOnFlag(localRecord)).isEqualTo(expected); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java new file mode 100644 index 000000000000..4a52c0be9b5b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link ZipEndOfCentralDirectoryRecord}. + * + * @author Phillip Webb + */ +class ZipEndOfCentralDirectoryRecordTests { + + @Test + void loadLocatesAndLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x05, 0x06, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00 }); // + ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord.load(dataBlock); + assertThat(located.pos()).isEqualTo(0L); + ZipEndOfCentralDirectoryRecord record = located.endOfCentralDirectoryRecord(); + assertThat(record.numberOfThisDisk()).isEqualTo((short) 1); + assertThat(record.diskWhereCentralDirectoryStarts()).isEqualTo((short) 2); + assertThat(record.numberOfCentralDirectoryEntriesOnThisDisk()).isEqualTo((short) 3); + assertThat(record.totalNumberOfCentralDirectoryEntries()).isEqualTo((short) 4); + assertThat(record.sizeOfCentralDirectory()).isEqualTo(5); + assertThat(record.offsetToStartOfCentralDirectory()).isEqualTo(6); + assertThat(record.commentLength()).isEqualTo((short) 7); + } + + @Test + void loadWhenMultipleBuffersBackLoadsData() throws Exception { + byte[] bytes = new byte[ZipEndOfCentralDirectoryRecord.BUFFER_SIZE * 4]; + byte[] data = new byte[] { // + 0x50, 0x4b, 0x05, 0x06, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00 }; // + System.arraycopy(data, 0, bytes, 4, data.length); + ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord + .load(new ByteArrayDataBlock(bytes)); + assertThat(located.pos()).isEqualTo(4L); + } + + @Test + void loadWhenSignatureDoesNotMatchThrowsException() { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x05, 0x06, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00 }); // + assertThatIOException().isThrownBy(() -> ZipEndOfCentralDirectoryRecord.load(dataBlock)) + .withMessageContaining("'End Of Central Directory Record' not found"); + } + + @Test + void asByteArrayReturnsByteArray() throws Exception { + byte[] bytes = new byte[] { // + 0x50, 0x4b, 0x05, 0x06, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00 }; // + ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord + .load(new ByteArrayDataBlock(bytes)); + assertThat(located.endOfCentralDirectoryRecord().asByteArray()).isEqualTo(bytes); + } + + @Test + void sizeReturnsSize() { + ZipEndOfCentralDirectoryRecord record = new ZipEndOfCentralDirectoryRecord((short) 1, (short) 2, (short) 3, + (short) 4, 5, 6, (short) 7); + assertThat(record.size()).isEqualTo(29L); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java new file mode 100644 index 000000000000..02cc96fca27b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2012-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.boot.loader.zip; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link ZipLocalFileHeaderRecord}. + * + * @author Phillip Webb + */ +class ZipLocalFileHeaderRecordTests { + + @Test + void loadLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x03, 0x04, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, // + 0x0A, 0x00 }); // + ZipLocalFileHeaderRecord record = ZipLocalFileHeaderRecord.load(dataBlock, 0); + assertThat(record.versionNeededToExtract()).isEqualTo((short) 1); + assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 2); + assertThat(record.compressionMethod()).isEqualTo((short) 3); + assertThat(record.lastModFileTime()).isEqualTo((short) 4); + assertThat(record.lastModFileDate()).isEqualTo((short) 5); + assertThat(record.crc32()).isEqualTo(6); + assertThat(record.compressedSize()).isEqualTo(7); + assertThat(record.uncompressedSize()).isEqualTo(8); + assertThat(record.fileNameLength()).isEqualTo((short) 9); + assertThat(record.extraFieldLength()).isEqualTo((short) 10); + } + + @Test + void loadWhenSignatureDoesNotMatchThrowsException() { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x03, 0x04, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, // + 0x0A, 0x00 }); // + assertThatIOException().isThrownBy(() -> ZipLocalFileHeaderRecord.load(dataBlock, 0)) + .withMessageContaining("'Local File Header Record' not found"); + } + + @Test + void sizeReturnsSize() { + ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4, + (short) 5, 6, 7, 8, (short) 9, (short) 10); + assertThat(record.size()).isEqualTo(49L); + } + + @Test + void withExtraFieldLengthReturnsUpdatedInstance() { + ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4, + (short) 5, 6, 7, 8, (short) 9, (short) 10) + .withExtraFieldLength((short) 100); + assertThat(record.extraFieldLength()).isEqualTo((short) 100); + } + + @Test + void withFileNameLengthReturnsUpdatedInstance() { + ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4, + (short) 5, 6, 7, 8, (short) 9, (short) 10) + .withFileNameLength((short) 100); + assertThat(record.fileNameLength()).isEqualTo((short) 100); + } + + @Test + void asByteArrayReturnsByteArray() throws Exception { + byte[] bytes = new byte[] { // + 0x50, 0x4b, 0x03, 0x04, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, // + 0x0A, 0x00 }; // + ZipLocalFileHeaderRecord record = ZipLocalFileHeaderRecord.load(new ByteArrayDataBlock(bytes), 0); + assertThat(record.asByteArray()).isEqualTo(bytes); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java new file mode 100644 index 000000000000..1f35622d820c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java @@ -0,0 +1,195 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.zip; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.assertj.core.api.AbstractBooleanAssert; +import org.assertj.core.api.AbstractIntegerAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ZipString}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class ZipStringTests { + + @ParameterizedTest + @EnumSource + void hashGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, true, "abcABC123xyz!"); + testHash(sourceType, false, "abcABC123xyz!"); + } + + @ParameterizedTest + @EnumSource + void hashWhenHasSpecialCharsGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, true, "special/\u00EB.dat"); + } + + @ParameterizedTest + @EnumSource + void hashWhenHasCyrillicCharsGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, true, "\u0432\u0435\u0441\u043D\u0430"); + } + + @ParameterizedTest + @EnumSource + void hashWhenHasEmojiGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, true, "\ud83d\udca9"); + } + + @ParameterizedTest + @EnumSource + void hashWhenOnlyDifferenceIsEndSlashGeneratesSameHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, "", true, "/".hashCode()); + testHash(sourceType, "/", true, "/".hashCode()); + testHash(sourceType, "a/b", true, "a/b/".hashCode()); + testHash(sourceType, "a/b/", true, "a/b/".hashCode()); + } + + void testHash(HashSourceType sourceType, boolean addSlash, String source) throws Exception { + String expected = (addSlash && !source.endsWith("/")) ? source + "/" : source; + testHash(sourceType, source, addSlash, expected.hashCode()); + } + + void testHash(HashSourceType sourceType, String source, boolean addEndSlash, int expected) throws Exception { + switch (sourceType) { + case STRING -> { + assertThat(ZipString.hash(source, addEndSlash)).isEqualTo(expected); + } + case CHAR_SEQUENCE -> { + CharSequence charSequence = new StringBuilder(source); + assertThat(ZipString.hash(charSequence, addEndSlash)).isEqualTo(expected); + } + case DATA_BLOCK -> { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8)); + assertThat(ZipString.hash(null, dataBlock, 0, (int) dataBlock.size(), addEndSlash)).isEqualTo(expected); + } + case SINGLE_BYTE_READ_DATA_BLOCK -> { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8), 1); + assertThat(ZipString.hash(null, dataBlock, 0, (int) dataBlock.size(), addEndSlash)).isEqualTo(expected); + } + } + } + + @Test + void matchesWhenExactMatchReturnsTrue() throws Exception { + assertMatches("one/two/three", "one/two/three", false).isTrue(); + } + + @Test + void matchesWhenNotMatchWithSameLengthReturnsFalse() throws Exception { + assertMatches("one/two/three", "one/too/three", false).isFalse(); + } + + @Test + void matchesWhenExactMatchWithSpecialCharsReturnsTrue() throws Exception { + assertMatches("special/\u00EB.dat", "special/\u00EB.dat", false).isTrue(); + } + + @Test + void matchesWhenExactMatchWithCyrillicCharsReturnsTrue() throws Exception { + assertMatches("\u0432\u0435\u0441\u043D\u0430", "\u0432\u0435\u0441\u043D\u0430", false).isTrue(); + } + + @Test + void matchesWhenNoMatchWithCyrillicCharsReturnsFalse() throws Exception { + assertMatches("\u0432\u0435\u0441\u043D\u0430", "\u0432\u0435\u0441\u043D\u043D", false).isFalse(); + } + + @Test + void matchesWhenExactMatchWithEmojiCharsReturnsTrue() throws Exception { + assertMatches("\ud83d\udca9", "\ud83d\udca9", false).isTrue(); + } + + @Test + void matchesWithAddSlash() throws Exception { + assertMatches("META-INF/MANFIFEST.MF", "META-INF/MANFIFEST.MF", true).isTrue(); + assertMatches("one/two/three/", "one/two/three", true).isTrue(); + assertMatches("one/two/three", "one/two/three/", true).isFalse(); + assertMatches("one/two/three/", "one/too/three", true).isFalse(); + assertMatches("one/two/three", "one/too/three/", true).isFalse(); + assertMatches("one/two/three//", "one/two/three", true).isFalse(); + assertMatches("one/two/three", "one/two/three//", true).isFalse(); + } + + @Test + void matchesWhenDataBlockShorterThenCharSequenceReturnsFalse() throws Exception { + assertMatches("one/two/thre", "one/two/three", false).isFalse(); + } + + @Test + void matchesWhenCharSequenceShorterThanDataBlockReturnsFalse() throws Exception { + assertMatches("one/two/three", "one/two/thre", false).isFalse(); + } + + @Test + void startsWithWhenStartsWith() throws Exception { + assertStartsWith("one/two", "one/").isEqualTo(4); + } + + @Test + void startsWithWhenExact() throws Exception { + assertStartsWith("one/", "one/").isEqualTo(4); + } + + @Test + void startsWithWhenTooShort() throws Exception { + assertStartsWith("one/two", "one/two/three/").isEqualTo(-1); + } + + @Test + void startsWithWhenDoesNotStartWith() throws Exception { + assertStartsWith("one/three/", "one/two/").isEqualTo(-1); + } + + @Test + void zipStringWhenMultiCodePointAtBufferBoundary() throws Exception { + StringBuilder source = new StringBuilder(); + source.append("A".repeat(ZipString.BUFFER_SIZE - 1)); + source.append("\u1EFF"); + String charSequence = source.toString(); + source.append("suffix"); + assertStartsWith(source.toString(), charSequence); + } + + private AbstractBooleanAssert assertMatches(String source, CharSequence charSequence, boolean addSlash) + throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8)); + return assertThat(ZipString.matches(null, dataBlock, 0, (int) dataBlock.size(), charSequence, addSlash)); + } + + private AbstractIntegerAssert assertStartsWith(String source, CharSequence charSequence) throws IOException { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8)); + return assertThat(ZipString.startsWith(null, dataBlock, 0, (int) dataBlock.size(), charSequence)); + } + + enum HashSourceType { + + STRING, CHAR_SEQUENCE, DATA_BLOCK, SINGLE_BYTE_READ_DATA_BLOCK + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/launch/classpath-index-file.idx b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/launch/classpath-index-file.idx new file mode 100644 index 000000000000..b84b99a6b47e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/launch/classpath-index-file.idx @@ -0,0 +1,5 @@ +- "BOOT-INF/layers/one/lib/a.jar" +- "BOOT-INF/layers/one/lib/b.jar" +- "BOOT-INF/layers/one/lib/c.jar" +- "BOOT-INF/layers/two/lib/d.jar" +- "BOOT-INF/layers/two/lib/e.jar" diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle index c7f08b354ace..61b25f2dc5a2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle @@ -17,9 +17,8 @@ dependencies { compileOnly("org.apache.maven.plugin-tools:maven-plugin-annotations") compileOnly("org.sonatype.plexus:plexus-build-api") - compileOnly("org.apache.maven.shared:maven-common-artifact-filters") { + compileOnly("org.apache.maven:maven-core") { exclude(group: "javax.annotation", module: "javax.annotation-api") - exclude(group: "javax.enterprise", module: "cdi-api") exclude(group: "javax.inject", module: "javax.inject") } compileOnly("org.apache.maven:maven-plugin-api") { @@ -28,9 +27,14 @@ dependencies { exclude(group: "javax.inject", module: "javax.inject") } - implementation("org.springframework:spring-context") implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform")) implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools")) + implementation("org.apache.maven.shared:maven-common-artifact-filters") { + exclude(group: "javax.annotation", module: "javax.annotation-api") + exclude(group: "javax.enterprise", module: "cdi-api") + exclude(group: "javax.inject", module: "javax.inject") + } + implementation("org.springframework:spring-context") intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform")) intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools")) @@ -56,6 +60,15 @@ dependencies { runtimeOnly("org.sonatype.plexus:plexus-build-api") + testImplementation("org.apache.maven:maven-core") { + exclude(group: "javax.annotation", module: "javax.annotation-api") + exclude(group: "javax.inject", module: "javax.inject") + } + testImplementation("org.apache.maven.shared:maven-common-artifact-filters") { + exclude(group: "javax.annotation", module: "javax.annotation-api") + exclude(group: "javax.enterprise", module: "cdi-api") + exclude(group: "javax.inject", module: "javax.inject") + } testImplementation("org.assertj:assertj-core") testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.mockito:mockito-core") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/anchor-rewrite.properties b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/anchor-rewrite.properties index 4d55fec1e800..90545b8a40c2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/anchor-rewrite.properties +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/anchor-rewrite.properties @@ -152,6 +152,7 @@ goals-repackage-parameters-required=packaging.repackage-goal.required-parameters goals-run=run.run-goal goals-run-parameters-details=run.run-goal.parameter-details goals-run-parameters-details-addResources=run.run-goal.parameter-details.add-resources +goals-run-parameters-details-additionalClasspathElements=run.run-goal.parameter-details.additional-classpath-elements goals-run-parameters-details-agents=run.run-goal.parameter-details.agents goals-run-parameters-details-arguments=run.run-goal.parameter-details.arguments goals-run-parameters-details-classesDirectory=run.run-goal.parameter-details.classes-directory @@ -175,6 +176,7 @@ goals-run-parameters-required=run.run-goal.required-parameters goals-start=integration-tests.start-goal goals-start-parameters-details=integration-tests.start-goal.parameter-details goals-start-parameters-details-addResources=integration-tests.start-goal.parameter-details.add-resources +goals-start-parameters-details-additionalClasspathElements=integration-tests.start-goal.parameter-details.additional-classpath-elements goals-start-parameters-details-agents=integration-tests.start-goal.parameter-details.agents goals-start-parameters-details-arguments=integration-tests.start-goal.parameter-details.arguments goals-start-parameters-details-classesDirectory=integration-tests.start-goal.parameter-details.classes-directory @@ -207,6 +209,7 @@ goals-stop-parameters-optional=integration-tests.stop-goal.optional-parameters goals-test-run=run.test-run-goal goals-test-run-parameters-details=run.test-run-goal.parameter-details goals-test-run-parameters-details-addResources=run.test-run-goal.parameter-details.add-resources +goals-test-run-parameters-details-additionalClasspathElements=run.test-run-goal.parameter-details.additional-classpath-elements goals-test-run-parameters-details-agents=run.test-run-goal.parameter-details.agents goals-test-run-parameters-details-arguments=run.test-run-goal.parameter-details.arguments goals-test-run-parameters-details-classesDirectory=run.test-run-goal.parameter-details.classes-directory diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc index 4478303f4b2c..adc1127821fc 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/packaging-oci-image.adoc @@ -28,7 +28,8 @@ The `spring-boot-devtools` and `spring-boot-docker-compose` modules are automati [[build-image.docker-daemon]] == Docker Daemon The `build-image` goal requires access to a Docker daemon. -By default, it will communicate with a Docker daemon over a local connection. +The goal will inspect local Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] to determine the current https://docs.docker.com/engine/context/working-with-contexts/[context] and use the context connection information to communicate with a Docker daemon. +If the current context can not be determined or the context does not have connection information, then the goal will use a default local connection. This works with https://docs.docker.com/install/[Docker Engine] on all supported platforms without configuration. Environment variables can be set to configure the `build-image` goal to use an alternative local or remote connection. @@ -37,6 +38,12 @@ The following table shows the environment variables and their values: |=== | Environment variable | Description +| DOCKER_CONFIG +| Location of Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] used to determine the current context (defaults to `$HOME/.docker`) + +| DOCKER_CONTEXT +| Name of a https://docs.docker.com/engine/context/working-with-contexts/[context] that should be used to retrieve host information from Docker CLI configuration files (overrides `DOCKER_HOST`) + | DOCKER_HOST | URL containing the host and port for the Docker daemon - for example `tcp://192.168.99.100:2376` @@ -53,6 +60,9 @@ The following table summarizes the available parameters: |=== | Parameter | Description +| `context` +| Name of a https://docs.docker.com/engine/context/working-with-contexts/[context] that should be used to retrieve host information from Docker CLI https://docs.docker.com/engine/reference/commandline/cli/#configuration-files[configuration files] + | `host` | URL containing the host and port for the Docker daemon - for example `tcp://192.168.99.100:2376` @@ -193,12 +203,19 @@ The values provided to the `tags` option should be *full* image references. See <> for more details. | +| `buildWorkspace` +| A temporary workspace that will be used by the builder and buildpacks to store files during image building. +The value can be a named volume or a bind mount location. +| A named volume in the Docker daemon, with a name derived from the image name. + | `buildCache` | A cache containing layers created by buildpacks and used by the image building process. +The value can be a named volume or a bind mount location. | A named volume in the Docker daemon, with a name derived from the image name. | `launchCache` | A cache containing layers created by buildpacks and used by the image launching process. +The value can be a named volume or a bind mount location. | A named volume in the Docker daemon, with a name derived from the image name. | `createdDate` + @@ -214,6 +231,10 @@ The value must be a string in the ISO 8601 instant format, or `now` to use the c Application contents will also be in this location in the generated image. | `/workspace` +| `securityOptions` +| https://docs.docker.com/engine/reference/run/#security-configuration[Security options] that will be applied to the builder container, provided as an array of string values +| `["label=disable"]` on Linux and macOS, `[]` on Windows + |=== NOTE: The plugin detects the target Java compatibility of the project using the compiler's plugin configuration or the `maven.compiler.target` property. @@ -414,7 +435,7 @@ include::../maven/packaging-oci-image/docker-pom-authentication-command-line.xml [[build-image.examples.caches]] -=== Builder Cache Configuration +=== Builder Cache and Workspace Configuration The CNB builder caches layers that are used when building and launching an image. By default, these caches are stored as named volumes in the Docker daemon with names that are derived from the full name of the target image. If the image name changes frequently, for example when the project version is used as a tag in the image name, then the caches can be invalidated frequently. @@ -426,6 +447,16 @@ The cache volumes can be configured to use alternative names to give more contro include::../maven/packaging-oci-image/caches-pom.xml[tags=caches] ---- +Builders and buildpacks need a location to store temporary files during image building. +By default, this temporary build workspace is stored in a named volume. + +The caches and the build workspace can be configured to use bind mounts instead of named volumes, as shown in the following example: + +[source,xml,indent=0,subs="verbatim,attributes",tabsize=4] +---- +include::../maven/packaging-oci-image/bind-caches-pom.xml[tags=caches] +---- + [[build-image.examples.docker]] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/bind-caches-pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/bind-caches-pom.xml new file mode 100644 index 000000000000..a67c45a0ed5d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/packaging-oci-image/bind-caches-pom.xml @@ -0,0 +1,32 @@ + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + /tmp/cache-${project.artifactId}.work + + + + + /tmp/cache-${project.artifactId}.build + + + + + /tmp/cache-${project.artifactId}.launch + + + + + + + + + \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java index b80cdc0bc44e..b4d0fffeb5be 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/BuildImageTests.java @@ -26,6 +26,7 @@ import java.util.stream.IntStream; import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.condition.EnabledOnOs; import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,6 +38,7 @@ import org.springframework.boot.buildpack.platform.docker.type.VolumeName; import org.springframework.boot.testsupport.junit.DisabledOnOs; import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; +import org.springframework.util.FileSystemUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -305,7 +307,7 @@ void whenBuildImageIsInvokedWithZipPackaging(MavenBuild mavenBuild) { assertThat(jar).isFile(); assertThat(buildLog(project)).contains("Building image") .contains("docker.io/library/build-image-zip-packaging:0.0.1.BUILD-SNAPSHOT") - .contains("Main-Class: org.springframework.boot.loader.PropertiesLauncher") + .contains("Main-Class: org.springframework.boot.loader.launch.PropertiesLauncher") .contains("Successfully built image"); removeImage("build-image-zip-packaging", "0.0.1.BUILD-SNAPSHOT"); }); @@ -385,19 +387,43 @@ void whenBuildImageIsInvokedWithTags(MavenBuild mavenBuild) { @TestTemplate void whenBuildImageIsInvokedWithVolumeCaches(MavenBuild mavenBuild) { String testBuildId = randomString(); - mavenBuild.project("build-image-caches") + mavenBuild.project("build-image-volume-caches") .goals("package") .systemProperty("spring-boot.build-image.pullPolicy", "IF_NOT_PRESENT") .systemProperty("test-build-id", testBuildId) .execute((project) -> { assertThat(buildLog(project)).contains("Building image") - .contains("docker.io/library/build-image-caches:0.0.1.BUILD-SNAPSHOT") + .contains("docker.io/library/build-image-volume-caches:0.0.1.BUILD-SNAPSHOT") .contains("Successfully built image"); - removeImage("build-image-caches", "0.0.1.BUILD-SNAPSHOT"); + removeImage("build-image-volume-caches", "0.0.1.BUILD-SNAPSHOT"); deleteVolumes("cache-" + testBuildId + ".build", "cache-" + testBuildId + ".launch"); }); } + @TestTemplate + @EnabledOnOs(value = OS.LINUX, disabledReason = "Works with Docker Engine on Linux but is not reliable with " + + "Docker Desktop on other OSs") + void whenBuildImageIsInvokedWithBindCaches(MavenBuild mavenBuild) { + String testBuildId = randomString(); + mavenBuild.project("build-image-bind-caches") + .goals("package") + .systemProperty("spring-boot.build-image.pullPolicy", "IF_NOT_PRESENT") + .systemProperty("test-build-id", testBuildId) + .execute((project) -> { + assertThat(buildLog(project)).contains("Building image") + .contains("docker.io/library/build-image-bind-caches:0.0.1.BUILD-SNAPSHOT") + .contains("Successfully built image"); + removeImage("build-image-bind-caches", "0.0.1.BUILD-SNAPSHOT"); + String tempDir = System.getProperty("java.io.tmpdir"); + Path buildCachePath = Paths.get(tempDir, "junit-image-cache-" + testBuildId + "-build"); + Path launchCachePath = Paths.get(tempDir, "junit-image-cache-" + testBuildId + "-launch"); + assertThat(buildCachePath).exists().isDirectory(); + assertThat(launchCachePath).exists().isDirectory(); + FileSystemUtils.deleteRecursively(buildCachePath); + FileSystemUtils.deleteRecursively(launchCachePath); + }); + } + @TestTemplate void whenBuildImageIsInvokedWithCreatedDate(MavenBuild mavenBuild) { String testBuildId = randomString(); @@ -454,6 +480,21 @@ void whenBuildImageIsInvokedWithApplicationDirectory(MavenBuild mavenBuild) { }); } + @TestTemplate + void whenBuildImageIsInvokedWithEmptySecurityOptions(MavenBuild mavenBuild) { + String testBuildId = randomString(); + mavenBuild.project("build-image-security-opts") + .goals("package") + .systemProperty("spring-boot.build-image.pullPolicy", "IF_NOT_PRESENT") + .systemProperty("test-build-id", testBuildId) + .execute((project) -> { + assertThat(buildLog(project)).contains("Building image") + .contains("docker.io/library/build-image-security-opts:0.0.1.BUILD-SNAPSHOT") + .contains("Successfully built image"); + removeImage("build-image-security-opts", "0.0.1.BUILD-SNAPSHOT"); + }); + } + @TestTemplate void failsWhenBuildImageIsInvokedOnMultiModuleProjectWithBuildImageGoal(MavenBuild mavenBuild) { mavenBuild.project("build-image-multi-module") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java index e964c2ecee78..d96b959fe64c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java @@ -57,7 +57,7 @@ void whenJarIsRepackagedInPlaceOnlyRepackagedJarIsInstalled(MavenBuild mavenBuil File repackaged = new File(project, "target/jar-0.0.1.BUILD-SNAPSHOT.jar"); assertThat(launchScript(repackaged)).isEmpty(); assertThat(jar(repackaged)).manifest((manifest) -> { - manifest.hasMainClass("org.springframework.boot.loader.JarLauncher"); + manifest.hasMainClass("org.springframework.boot.loader.launch.JarLauncher"); manifest.hasStartClass("some.random.Main"); manifest.hasAttribute("Not-Used", "Foo"); }) @@ -66,7 +66,27 @@ void whenJarIsRepackagedInPlaceOnlyRepackagedJarIsInstalled(MavenBuild mavenBuil .hasEntryWithNameStartingWith("BOOT-INF/lib/spring-jcl") .hasEntryWithNameStartingWith("BOOT-INF/lib/jakarta.servlet-api-6") .hasEntryWithName("BOOT-INF/classes/org/test/SampleApplication.class") - .hasEntryWithName("org/springframework/boot/loader/JarLauncher.class"); + .hasEntryWithName("org/springframework/boot/loader/launch/JarLauncher.class"); + assertThat(buildLog(project)) + .contains("Replacing main artifact " + repackaged + " with repackaged archive,") + .contains("The original artifact has been renamed to " + original) + .contains("Installing " + repackaged + " to") + .doesNotContain("Installing " + original + " to"); + }); + } + + @TestTemplate + void whenJarWithClassicLoaderIsRepackagedInPlaceOnlyRepackagedJarIsInstalled(MavenBuild mavenBuild) { + mavenBuild.project("jar-with-classic-loader").goals("install").execute((project) -> { + File original = new File(project, "target/jar-with-classic-loader-0.0.1.BUILD-SNAPSHOT.jar.original"); + assertThat(original).isFile(); + File repackaged = new File(project, "target/jar-with-classic-loader-0.0.1.BUILD-SNAPSHOT.jar"); + assertThat(launchScript(repackaged)).isEmpty(); + assertThat(jar(repackaged)).manifest((manifest) -> { + manifest.hasMainClass("org.springframework.boot.loader.launch.JarLauncher"); + manifest.hasStartClass("some.random.Main"); + manifest.hasAttribute("Not-Used", "Foo"); + }).hasEntryWithName("org/springframework/boot/loader/launch/JarLauncher.class"); assertThat(buildLog(project)) .contains("Replacing main artifact " + repackaged + " with repackaged archive,") .contains("The original artifact has been renamed to " + original) @@ -273,9 +293,9 @@ void whenAProjectIsBuiltWithALayoutPropertyTheSpecifiedLayoutIsUsed(MavenBuild m .goals("package", "-Dspring-boot.repackage.layout=ZIP") .execute((project) -> { File main = new File(project, "target/jar-with-layout-property-0.0.1.BUILD-SNAPSHOT.jar"); - assertThat(jar(main)) - .manifest((manifest) -> manifest.hasMainClass("org.springframework.boot.loader.PropertiesLauncher") - .hasStartClass("org.test.SampleApplication")); + assertThat(jar(main)).manifest( + (manifest) -> manifest.hasMainClass("org.springframework.boot.loader.launch.PropertiesLauncher") + .hasStartClass("org.test.SampleApplication")); assertThat(buildLog(project)).contains("Layout: ZIP"); }); } @@ -284,9 +304,9 @@ void whenAProjectIsBuiltWithALayoutPropertyTheSpecifiedLayoutIsUsed(MavenBuild m void whenALayoutIsConfiguredTheSpecifiedLayoutIsUsed(MavenBuild mavenBuild) { mavenBuild.project("jar-with-zip-layout").execute((project) -> { File main = new File(project, "target/jar-with-zip-layout-0.0.1.BUILD-SNAPSHOT.jar"); - assertThat(jar(main)) - .manifest((manifest) -> manifest.hasMainClass("org.springframework.boot.loader.PropertiesLauncher") - .hasStartClass("org.test.SampleApplication")); + assertThat(jar(main)).manifest( + (manifest) -> manifest.hasMainClass("org.springframework.boot.loader.launch.PropertiesLauncher") + .hasStartClass("org.test.SampleApplication")); assertThat(buildLog(project)).contains("Layout: ZIP"); }); } @@ -429,7 +449,8 @@ private String buildJarWithOutputTimestamp(MavenBuild mavenBuild) { void whenJarIsRepackagedWithOutputTimestampConfiguredThenLibrariesAreSorted(MavenBuild mavenBuild) { mavenBuild.project("jar-output-timestamp").execute((project) -> { File repackaged = new File(project, "target/jar-output-timestamp-0.0.1.BUILD-SNAPSHOT.jar"); - List sortedLibs = Arrays.asList("BOOT-INF/lib/jakarta.servlet-api", "BOOT-INF/lib/spring-aop", + List sortedLibs = Arrays.asList("BOOT-INF/lib/jakarta.servlet-api", + "BOOT-INF/lib/micrometer-commons", "BOOT-INF/lib/micrometer-observation", "BOOT-INF/lib/spring-aop", "BOOT-INF/lib/spring-beans", "BOOT-INF/lib/spring-boot-jarmode-layertools", "BOOT-INF/lib/spring-context", "BOOT-INF/lib/spring-core", "BOOT-INF/lib/spring-expression", "BOOT-INF/lib/spring-jcl"); @@ -439,4 +460,12 @@ void whenJarIsRepackagedWithOutputTimestampConfiguredThenLibrariesAreSorted(Mave }); } + @TestTemplate + void whenSigned(MavenBuild mavenBuild) { + mavenBuild.project("jar-signed").execute((project) -> { + File repackaged = new File(project, "target/jar-signed-0.0.1.BUILD-SNAPSHOT.jar"); + assertThat(jar(repackaged)).hasEntryWithName("META-INF/BOOT.SF"); + }); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/RunIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/RunIntegrationTests.java index ec9f69901b2f..e3e93e0ee197 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/RunIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/RunIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,7 @@ * Integration tests for the Maven plugin's run goal. * * @author Andy Wilkinson + * @author Stephane Nicoll */ @ExtendWith(MavenBuildExtension.class) class RunIntegrationTests { @@ -37,7 +38,7 @@ class RunIntegrationTests { @TestTemplate void whenTheRunGoalIsExecutedTheApplicationIsForkedWithOptimizedJvmArguments(MavenBuild mavenBuild) { mavenBuild.project("run").goals("spring-boot:run", "-X").execute((project) -> { - String jvmArguments = "JVM argument(s): -XX:TieredStopAtLevel=1"; + String jvmArguments = "JVM argument: -XX:TieredStopAtLevel=1"; assertThat(buildLog(project)).contains("I haz been run").contains(jvmArguments); }); } @@ -107,6 +108,28 @@ void whenAWorkingDirectoryIsConfiguredTheApplicationIsRunFromThatDirectory(Maven .execute((project) -> assertThat(buildLog(project)).containsPattern("I haz been run from.*src.main.java")); } + @TestTemplate + @Deprecated(since = "3.2.0", forRemoval = true) + void whenDirectoriesAreConfiguredTheyAreAvailableToTheApplication(MavenBuild mavenBuild) { + mavenBuild.project("run-directories") + .goals("spring-boot:run") + .execute((project) -> assertThat(buildLog(project)).contains("I haz been run")); + } + + @TestTemplate + void whenAdditionalClasspathDirectoryIsConfiguredItsResourcesAreAvailableToTheApplication(MavenBuild mavenBuild) { + mavenBuild.project("run-additional-classpath-directory") + .goals("spring-boot:run") + .execute((project) -> assertThat(buildLog(project)).contains("I haz been run")); + } + + @TestTemplate + void whenAdditionalClasspathFileIsConfiguredItsContentIsAvailableToTheApplication(MavenBuild mavenBuild) { + mavenBuild.project("run-additional-classpath-jar") + .goals("spring-boot:run") + .execute((project) -> assertThat(buildLog(project)).contains("I haz been run")); + } + @TestTemplate @DisabledOnOs(OS.WINDOWS) void whenAToolchainIsConfiguredItIsUsedToRunTheApplication(MavenBuild mavenBuild) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java index 01a4d8cb54df..56a266b5bfd6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java @@ -57,10 +57,10 @@ void warRepackaging(MavenBuild mavenBuild) { .hasEntryWithNameStartingWith("WEB-INF/lib/spring-core") .hasEntryWithNameStartingWith("WEB-INF/lib/spring-jcl") .hasEntryWithNameStartingWith("WEB-INF/lib-provided/jakarta.servlet-api-6") - .hasEntryWithName("org/springframework/boot/loader/WarLauncher.class") + .hasEntryWithName("org/springframework/boot/loader/launch/WarLauncher.class") .hasEntryWithName("WEB-INF/classes/org/test/SampleApplication.class") .hasEntryWithName("index.html") - .manifest((manifest) -> manifest.hasMainClass("org.springframework.boot.loader.WarLauncher") + .manifest((manifest) -> manifest.hasMainClass("org.springframework.boot.loader.launch.WarLauncher") .hasStartClass("org.test.SampleApplication") .hasAttribute("Not-Used", "Foo"))); } @@ -122,8 +122,9 @@ void whenWarIsRepackagedWithOutputTimestampConfiguredThenLibrariesAreSorted(Mave List sortedLibs = Arrays.asList( // these libraries are copied from the original war, sorted when // packaged by Maven - "WEB-INF/lib/spring-aop", "WEB-INF/lib/spring-beans", "WEB-INF/lib/spring-context", - "WEB-INF/lib/spring-core", "WEB-INF/lib/spring-expression", "WEB-INF/lib/spring-jcl", + "WEB-INF/lib/micrometer-commons", "WEB-INF/lib/micrometer-observation", "WEB-INF/lib/spring-aop", + "WEB-INF/lib/spring-beans", "WEB-INF/lib/spring-context", "WEB-INF/lib/spring-core", + "WEB-INF/lib/spring-expression", "WEB-INF/lib/spring-jcl", // these libraries are contributed by Spring Boot repackaging, and // sorted separately "WEB-INF/lib/spring-boot-jarmode-layertools"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/pom.xml new file mode 100644 index 000000000000..7f09ff829236 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + org.springframework.boot.maven.it + build-image-bind-caches + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + build-image-no-fork + + + + projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2 + + + ${java.io.tmpdir}/junit-image-cache-${test-build-id}-work + + + + + ${java.io.tmpdir}/junit-image-cache-${test-build-id}-build + + + + + ${java.io.tmpdir}/junit-image-cache-${test-build-id}-launch + + + + + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/src/main/java/org/test/SampleApplication.java similarity index 93% rename from spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java rename to spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/src/main/java/org/test/SampleApplication.java index e964724deacd..03544b74e463 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/src/main/java/org/test/SampleApplication.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-bind-caches/src/main/java/org/test/SampleApplication.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/pom.xml new file mode 100644 index 000000000000..5eee589a4660 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + org.springframework.boot.maven.it + build-image-security-opts + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + build-image-no-fork + + + + projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2 + + + + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..58ebebbbb234 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-security-opts/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,28 @@ +/* + * Copyright 2012-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.test; + +public class SampleApplication { + + public static void main(String[] args) throws Exception { + System.out.println("Launched"); + synchronized(args) { + args.wait(); // Prevent exit" + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/pom.xml similarity index 87% rename from spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml rename to spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/pom.xml index f95eb39f874e..5a3d3ec76e86 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-caches/pom.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/pom.xml @@ -3,7 +3,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.springframework.boot.maven.it - build-image-caches + build-image-volume-caches 0.0.1.BUILD-SNAPSHOT UTF-8 @@ -24,6 +24,11 @@ projects.registry.vmware.com/springboot/spring-boot-cnb-builder:0.0.2 + + + cache-${test-build-id}.work + + cache-${test-build-id}.build diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..03544b74e463 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/build-image-volume-caches/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,28 @@ +/* + * Copyright 2012-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.test; + +public class SampleApplication { + + public static void main(String[] args) throws Exception { + System.out.println("Launched"); + synchronized(args) { + args.wait(); // Prevent exit + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-layered-custom/jar/src/layers.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-layered-custom/jar/src/layers.xml index 418078fe423e..cd2a48c9b4cd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-layered-custom/jar/src/layers.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-layered-custom/jar/src/layers.xml @@ -1,7 +1,7 @@ + https://www.springframework.org/schema/layers/layers-3.3.xsd"> **/application*.* diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/pom.xml new file mode 100644 index 000000000000..375d3c60b3dc --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + org.springframework.boot.maven.it + jar-signed + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + repackage + + + + + + org.apache.maven.plugins + maven-jar-plugin + @maven-jar-plugin.version@ + + + + some.random.Main + + + Foo + + + + + + + + + org.springframework + spring-context + @spring-framework.version@ + + + jakarta.servlet + jakarta.servlet-api + @jakarta-servlet.version@ + provided + + + org.bouncycastle + bcprov-jdk18on + 1.76 + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..5e51546d4e0d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-signed/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-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.test; + +public class SampleApplication { + + public static void main(String[] args) { + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/pom.xml new file mode 100644 index 000000000000..ce29e60f4029 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + org.springframework.boot.maven.it + jar-with-classic-loader + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + repackage + + + CLASSIC + + + + + + org.apache.maven.plugins + maven-jar-plugin + @maven-jar-plugin.version@ + + + + some.random.Main + + + Foo + + + + + + + + + org.springframework + spring-context + @spring-framework.version@ + + + jakarta.servlet + jakarta.servlet-api + @jakarta-servlet.version@ + provided + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..5e51546d4e0d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-with-classic-loader/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-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.test; + +public class SampleApplication { + + public static void main(String[] args) { + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/pom.xml new file mode 100644 index 000000000000..a03170ba7d46 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + org.springframework.boot.maven.it + run-additional-classpath-directory + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + src/main/additional-elements/ + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/another/two.txt b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/another/two.txt new file mode 100644 index 000000000000..d8263ee98605 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/another/two.txt @@ -0,0 +1 @@ +2 \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/one.txt b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/one.txt new file mode 100644 index 000000000000..56a6051ca2b0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/additional-elements/one.txt @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..944441df246d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-directory/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-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.test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; + +public class SampleApplication { + + public static void main(String[] args) { + if (!readContent("one.txt").contains("1")) { + throw new IllegalArgumentException("Invalid content for one.txt"); + } + if (!readContent("another/two.txt").contains("2")) { + throw new IllegalArgumentException("Invalid content for another/two.txt"); + } + System.out.println("I haz been run"); + } + + private static String readContent(String location) { + InputStream in = SampleApplication.class.getClassLoader().getResourceAsStream(location); + if (in == null) { + throw new IllegalArgumentException("Not found: '" + location + "'"); + } + try (Scanner scanner = new Scanner(in, StandardCharsets.UTF_8)) { + return scanner.useDelimiter("\\A").next(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/pom.xml new file mode 100644 index 000000000000..7e1887b93fc4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + org.springframework.boot.maven.it + run-additional-classpath-directory + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + src/main/additional-jar/resources-1.0.0.jar + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/additional-jar/resources-1.0.0.jar b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/additional-jar/resources-1.0.0.jar new file mode 100644 index 000000000000..f6e05369c57d Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/additional-jar/resources-1.0.0.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..944441df246d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-additional-classpath-jar/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-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.test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; + +public class SampleApplication { + + public static void main(String[] args) { + if (!readContent("one.txt").contains("1")) { + throw new IllegalArgumentException("Invalid content for one.txt"); + } + if (!readContent("another/two.txt").contains("2")) { + throw new IllegalArgumentException("Invalid content for another/two.txt"); + } + System.out.println("I haz been run"); + } + + private static String readContent(String location) { + InputStream in = SampleApplication.class.getClassLoader().getResourceAsStream(location); + if (in == null) { + throw new IllegalArgumentException("Not found: '" + location + "'"); + } + try (Scanner scanner = new Scanner(in, StandardCharsets.UTF_8)) { + return scanner.useDelimiter("\\A").next(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/pom.xml new file mode 100644 index 000000000000..4029ed38e431 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + org.springframework.boot.maven.it + run-directories + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + src/main/additional-elements/ + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/another/two.txt b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/another/two.txt new file mode 100644 index 000000000000..d8263ee98605 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/another/two.txt @@ -0,0 +1 @@ +2 \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/one.txt b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/one.txt new file mode 100644 index 000000000000..56a6051ca2b0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/additional-elements/one.txt @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/java/org/test/SampleApplication.java new file mode 100644 index 000000000000..944441df246d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/run-directories/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-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.test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; + +public class SampleApplication { + + public static void main(String[] args) { + if (!readContent("one.txt").contains("1")) { + throw new IllegalArgumentException("Invalid content for one.txt"); + } + if (!readContent("another/two.txt").contains("2")) { + throw new IllegalArgumentException("Invalid content for another/two.txt"); + } + System.out.println("I haz been run"); + } + + private static String readContent(String location) { + InputStream in = SampleApplication.class.getClassLoader().getResourceAsStream(location); + if (in == null) { + throw new IllegalArgumentException("Not found: '" + location + "'"); + } + try (Scanner scanner = new Scanner(in, StandardCharsets.UTF_8)) { + return scanner.useDelimiter("\\A").next(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/settings.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/settings.xml index d63e2d6b8d06..8c1aed58a6cb 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/settings.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/settings.xml @@ -17,6 +17,7 @@ true + ignore spring-milestones @@ -42,6 +43,12 @@ true + ignore + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-layered-custom/war/src/layers.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-layered-custom/war/src/layers.xml index 418078fe423e..cd2a48c9b4cd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-layered-custom/war/src/layers.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-layered-custom/war/src/layers.xml @@ -1,7 +1,7 @@ + https://www.springframework.org/schema/layers/layers-3.3.xsd"> **/application*.* diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractPackagerMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractPackagerMojo.java index b6dfdc04bb08..28d55d213a16 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractPackagerMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractPackagerMojo.java @@ -47,6 +47,7 @@ import org.springframework.boot.loader.tools.Layouts.None; import org.springframework.boot.loader.tools.Layouts.War; import org.springframework.boot.loader.tools.Libraries; +import org.springframework.boot.loader.tools.LoaderImplementation; import org.springframework.boot.loader.tools.Packager; import org.springframework.boot.loader.tools.layer.CustomLayers; @@ -128,6 +129,15 @@ protected LayoutType getLayout() { return null; } + /** + * Return the loader implementation that should be used. + * @return the loader implementation or {@code null} + * @since 3.2.0 + */ + protected LoaderImplementation getLoaderImplementation() { + return null; + } + /** * Return the layout factory that will be used to determine the {@link LayoutType} if * no explicit layout is set. @@ -145,6 +155,7 @@ protected LayoutFactory getLayoutFactory() { */ protected

P getConfiguredPackager(Supplier

supplier) { P packager = supplier.get(); + packager.setLoaderImplementation(getLoaderImplementation()); packager.setLayoutFactory(getLayoutFactory()); packager.addMainClassTimeoutWarningListener(new LoggingMainClassTimeoutWarningListener(this::getLog)); packager.setMainClass(this.mainClass); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java index 98688bcaecbb..f2b9c7bc2614 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ import org.apache.maven.toolchain.ToolchainManager; import org.springframework.boot.loader.tools.FileUtils; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * Base class to run a Spring Boot application. @@ -165,13 +167,24 @@ public abstract class AbstractRunMojo extends AbstractDependencyFilterMojo { private String mainClass; /** - * Additional directories besides the classes directory that should be added to the + * Additional directories containing classes or resources that should be added to the * classpath. * @since 1.0.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * 'additionalClasspathElements' */ @Parameter(property = "spring-boot.run.directories") + @Deprecated(since = "3.2.0", forRemoval = true) private String[] directories; + /** + * Additional classpath elements that should be added to the classpath. An element can + * be a directory with classes and resources or a jar file. + * @since 3.2.0 + */ + @Parameter(property = "spring-boot.run.additional-classpath-elements") + private String[] additionalClasspathElements; + /** * Directory containing the classes and resource files that should be used to run the * application. @@ -264,12 +277,12 @@ protected EnvVariables resolveEnvVariables() { private void addArgs(List args) { RunArguments applicationArguments = resolveApplicationArguments(); Collections.addAll(args, applicationArguments.asArray()); - logArguments("Application argument(s): ", applicationArguments.asArray()); + logArguments("Application argument", applicationArguments.asArray()); } private Map determineEnvironmentVariables() { EnvVariables envVariables = resolveEnvVariables(); - logArguments("Environment variable(s): ", envVariables.asArray()); + logArguments("Environment variable", envVariables.asArray()); return envVariables.asMap(); } @@ -294,7 +307,7 @@ protected RunArguments resolveJvmArguments() { private void addJvmArgs(List args) { RunArguments jvmArguments = resolveJvmArguments(); Collections.addAll(args, jvmArguments.asArray()); - logArguments("JVM argument(s): ", jvmArguments.asArray()); + logArguments("JVM argument", jvmArguments.asArray()); } private void addAgents(List args) { @@ -321,7 +334,7 @@ private void addActiveProfileArgument(RunArguments arguments) { } } arguments.getArgs().addFirst(arg.toString()); - logArguments("Active profile(s): ", this.profiles); + logArguments("Active profile", this.profiles); } } @@ -348,7 +361,7 @@ private void addClasspath(List args) throws MojoExecutionException { protected URL[] getClassPathUrls() throws MojoExecutionException { try { List urls = new ArrayList<>(); - addUserDefinedDirectories(urls); + addAdditionalClasspathLocations(urls); addResources(urls); addProjectClasses(urls); addDependencies(urls); @@ -359,10 +372,15 @@ protected URL[] getClassPathUrls() throws MojoExecutionException { } } - private void addUserDefinedDirectories(List urls) throws MalformedURLException { - if (this.directories != null) { - for (String directory : this.directories) { - urls.add(new File(directory).toURI().toURL()); + @SuppressWarnings("removal") + private void addAdditionalClasspathLocations(List urls) throws MalformedURLException { + Assert.state(ObjectUtils.isEmpty(this.directories) || ObjectUtils.isEmpty(this.additionalClasspathElements), + "Either additionalClasspathElements or directories (deprecated) should be set, not both"); + String[] elements = !ObjectUtils.isEmpty(this.additionalClasspathElements) ? this.additionalClasspathElements + : this.directories; + if (elements != null) { + for (String element : elements) { + urls.add(new File(element).toURI().toURL()); } } } @@ -395,8 +413,9 @@ private void addDependencies(List urls) throws MalformedURLException, MojoE } } - private void logArguments(String message, String[] args) { + private void logArguments(String name, String[] args) { if (getLog().isDebugEnabled()) { + String message = (args.length == 1) ? name + ": " : name + "s: "; getLog().debug(Arrays.stream(args).collect(Collectors.joining(" ", message, ""))); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArtifactsLibraries.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArtifactsLibraries.java index 372f74a1b566..e6573112e4e9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArtifactsLibraries.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArtifactsLibraries.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -85,7 +85,7 @@ public ArtifactsLibraries(Set artifacts, Collection loca /** * Creates a new {@code ArtifactsLibraries} from the given {@code artifacts}. * @param artifacts all artifacts that can be represented as libraries - * @param includedArtifacts the actual artifacts to include in the fat jar + * @param includedArtifacts the actual artifacts to include in the uber jar * @param localProjects projects for which {@link Library#isLocal() local} libraries * should be created * @param unpacks artifacts that should be unpacked on launch diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java index 84589c01891f..79b62bf53030 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/BuildImageMojo.java @@ -48,6 +48,7 @@ import org.springframework.boot.loader.tools.ImagePackager; import org.springframework.boot.loader.tools.LayoutFactory; import org.springframework.boot.loader.tools.Libraries; +import org.springframework.boot.loader.tools.LoaderImplementation; import org.springframework.util.StringUtils; /** @@ -187,6 +188,13 @@ public abstract class BuildImageMojo extends AbstractPackagerMojo { @Parameter private LayoutType layout; + /** + * The loader implementation that should be used. + * @since 3.2.0 + */ + @Parameter + private LoaderImplementation loaderImplementation; + /** * The layout factory that will be used to create the executable archive if no * explicit layout is set. Alternative layouts implementations can be provided by 3rd @@ -206,6 +214,11 @@ protected LayoutType getLayout() { return this.layout; } + @Override + protected LoaderImplementation getLoaderImplementation() { + return this.loaderImplementation; + } + /** * Return the layout factory that will be used to determine the * {@link AbstractPackagerMojo.LayoutType} if no explicit layout is set. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java index 491deabe28eb..a64c0387073d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CacheInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -32,8 +32,8 @@ public class CacheInfo { public CacheInfo() { } - CacheInfo(VolumeCacheInfo volumeCacheInfo) { - this.cache = Cache.volume(volumeCacheInfo.getName()); + private CacheInfo(Cache cache) { + this.cache = cache; } public void setVolume(VolumeCacheInfo info) { @@ -41,10 +41,23 @@ public void setVolume(VolumeCacheInfo info) { this.cache = Cache.volume(info.getName()); } + public void setBind(BindCacheInfo info) { + Assert.state(this.cache == null, "Each image building cache can be configured only once"); + this.cache = Cache.bind(info.getSource()); + } + Cache asCache() { return this.cache; } + static CacheInfo fromVolume(VolumeCacheInfo cacheInfo) { + return new CacheInfo(Cache.volume(cacheInfo.getName())); + } + + static CacheInfo fromBind(BindCacheInfo cacheInfo) { + return new CacheInfo(Cache.bind(cacheInfo.getSource())); + } + /** * Encapsulates configuration of an image building cache stored in a volume. */ @@ -69,4 +82,28 @@ void setName(String name) { } + /** + * Encapsulates configuration of an image building cache stored in a bind mount. + */ + public static class BindCacheInfo { + + private String source; + + public BindCacheInfo() { + } + + BindCacheInfo(String name) { + this.source = name; + } + + public String getSource() { + return this.source; + } + + void setSource(String source) { + this.source = source; + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CustomLayersProvider.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CustomLayersProvider.java index e78d817e34aa..5f3d6e6c87b6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CustomLayersProvider.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CustomLayersProvider.java @@ -123,7 +123,7 @@ private ContentSelector getSelector(Element element, Function(layer, includes, excludes, filterFactory); } - private ContentSelector getLibrarySelector(Element element, + private ContentSelector getLibrarySelector(Element element, Function> filterFactory) { Layer layer = new Layer(element.getAttribute("layer")); List includes = getChildNodeTextContent(element, "include"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java index 78e8b5b89b98..53618609d4d7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Docker.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -29,6 +29,8 @@ public class Docker { private String host; + private String context; + private boolean tlsVerify; private String certPath; @@ -51,6 +53,18 @@ void setHost(String host) { this.host = host; } + /** + * The Docker context to use to retrieve host configuration. + * @return the Docker context + */ + public String getContext() { + return this.context; + } + + public void setContext(String context) { + this.context = context; + } + /** * Whether the Docker daemon requires TLS communication. * @return {@code true} to enable TLS @@ -138,6 +152,13 @@ DockerConfiguration asDockerConfiguration() { } private DockerConfiguration customizeHost(DockerConfiguration dockerConfiguration) { + if (this.context != null && this.host != null) { + throw new IllegalArgumentException( + "Invalid Docker configuration, either context or host can be provided but not both"); + } + if (this.context != null) { + return dockerConfiguration.withContext(this.context); + } if (this.host != null) { return dockerConfiguration.withHost(this.host, this.tlsVerify, this.certPath); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java index 2d5cf6b24728..c19ac62465a4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/Image.java @@ -69,6 +69,8 @@ public class Image { List tags; + CacheInfo buildWorkspace; + CacheInfo buildCache; CacheInfo launchCache; @@ -77,6 +79,8 @@ public class Image { String applicationDirectory; + List securityOptions; + /** * The name of the created image. * @return the image name @@ -243,6 +247,9 @@ private BuildRequest customize(BuildRequest request) { if (!CollectionUtils.isEmpty(this.tags)) { request = request.withTags(this.tags.stream().map(ImageReference::of).toList()); } + if (this.buildWorkspace != null) { + request = request.withBuildWorkspace(this.buildWorkspace.asCache()); + } if (this.buildCache != null) { request = request.withBuildCache(this.buildCache.asCache()); } @@ -255,6 +262,9 @@ private BuildRequest customize(BuildRequest request) { if (StringUtils.hasText(this.applicationDirectory)) { request = request.withApplicationDirectory(this.applicationDirectory); } + if (this.securityOptions != null) { + request = request.withSecurityOptions(this.securityOptions); + } return request; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java index 1e3df71dd495..13a16c2a144a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java @@ -36,6 +36,7 @@ import org.springframework.boot.loader.tools.LaunchScript; import org.springframework.boot.loader.tools.LayoutFactory; import org.springframework.boot.loader.tools.Libraries; +import org.springframework.boot.loader.tools.LoaderImplementation; import org.springframework.boot.loader.tools.Repackager; /** @@ -106,7 +107,7 @@ public class RepackageMojo extends AbstractPackagerMojo { private boolean attach = true; /** - * A list of the libraries that must be unpacked from fat jars in order to run. + * A list of the libraries that must be unpacked from uber jars in order to run. * Specify each library as a {@code } with a {@code } and a * {@code } and they will be unpacked at runtime. * @since 1.1.0 @@ -161,6 +162,13 @@ public class RepackageMojo extends AbstractPackagerMojo { @Parameter(property = "spring-boot.repackage.layout") private LayoutType layout; + /** + * The loader implementation that should be used. + * @since 3.2.0 + */ + @Parameter + private LoaderImplementation loaderImplementation; + /** * The layout factory that will be used to create the executable archive if no * explicit layout is set. Alternative layouts implementations can be provided by 3rd @@ -180,6 +188,11 @@ protected LayoutType getLayout() { return this.layout; } + @Override + protected LoaderImplementation getLoaderImplementation() { + return this.loaderImplementation; + } + /** * Return the layout factory that will be used to determine the * {@link AbstractPackagerMojo.LayoutType} if no explicit layout is set. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.2.xsd b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.2.xsd new file mode 100644 index 000000000000..20219b9bd8b1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.2.xsd @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.3.xsd b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.3.xsd new file mode 100644 index 000000000000..20219b9bd8b1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/xsd/layers-3.3.xsd @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java index 1fd381ee28de..f2258b915dc3 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/DockerTests.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration; -import org.springframework.boot.buildpack.platform.docker.configuration.DockerHost; +import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -54,10 +54,11 @@ void asDockerConfigurationWithHostConfiguration() { docker.setTlsVerify(true); docker.setCertPath("/tmp/ca-cert"); DockerConfiguration dockerConfiguration = docker.asDockerConfiguration(); - DockerHost host = dockerConfiguration.getHost(); + DockerHostConfiguration host = dockerConfiguration.getHost(); assertThat(host.getAddress()).isEqualTo("docker.example.com"); assertThat(host.isSecure()).isTrue(); assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert"); + assertThat(host.getContext()).isNull(); assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); assertThat(docker.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) @@ -67,6 +68,34 @@ void asDockerConfigurationWithHostConfiguration() { .contains("\"serveraddress\" : \"\""); } + @Test + void asDockerConfigurationWithContextConfiguration() { + Docker docker = new Docker(); + docker.setContext("test-context"); + DockerConfiguration dockerConfiguration = docker.asDockerConfiguration(); + DockerHostConfiguration host = dockerConfiguration.getHost(); + assertThat(host.getContext()).isEqualTo("test-context"); + assertThat(host.getAddress()).isNull(); + assertThat(host.isSecure()).isFalse(); + assertThat(host.getCertificatePath()).isNull(); + assertThat(dockerConfiguration.isBindHostToBuilder()).isFalse(); + assertThat(docker.asDockerConfiguration().getBuilderRegistryAuthentication()).isNull(); + assertThat(decoded(dockerConfiguration.getPublishRegistryAuthentication().getAuthHeader())) + .contains("\"username\" : \"\"") + .contains("\"password\" : \"\"") + .contains("\"email\" : \"\"") + .contains("\"serveraddress\" : \"\""); + } + + @Test + void asDockerConfigurationWithHostAndContextFails() { + Docker docker = new Docker(); + docker.setContext("test-context"); + docker.setHost("docker.example.com"); + assertThatIllegalArgumentException().isThrownBy(docker::asDockerConfiguration) + .withMessageContaining("Invalid Docker configuration"); + } + @Test void asDockerConfigurationWithBindHostToBuilder() { Docker docker = new Docker(); @@ -75,7 +104,7 @@ void asDockerConfigurationWithBindHostToBuilder() { docker.setCertPath("/tmp/ca-cert"); docker.setBindHostToBuilder(true); DockerConfiguration dockerConfiguration = docker.asDockerConfiguration(); - DockerHost host = dockerConfiguration.getHost(); + DockerHostConfiguration host = dockerConfiguration.getHost(); assertThat(host.getAddress()).isEqualTo("docker.example.com"); assertThat(host.isSecure()).isTrue(); assertThat(host.getCertificatePath()).isEqualTo("/tmp/ca-cert"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java index acc548829a41..1ec018db8608 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ImageTests.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.function.Function; import org.apache.maven.artifact.Artifact; @@ -34,6 +35,7 @@ import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.io.Owner; import org.springframework.boot.buildpack.platform.io.TarArchive; +import org.springframework.boot.maven.CacheInfo.BindCacheInfo; import org.springframework.boot.maven.CacheInfo.VolumeCacheInfo; import static org.assertj.core.api.Assertions.assertThat; @@ -67,7 +69,7 @@ void getBuildRequestWhenNameIsSetUsesName() { void getBuildRequestWhenNoCustomizationsUsesDefaults() { BuildRequest request = new Image().getBuildRequest(createArtifact(), mockApplicationContent()); assertThat(request.getName()).hasToString("docker.io/library/my-app:0.0.1-SNAPSHOT"); - assertThat(request.getBuilder().toString()).contains("paketobuildpacks/builder-jammy-base:latest"); + assertThat(request.getBuilder().toString()).contains("paketobuildpacks/builder-jammy-base"); assertThat(request.getRunImage()).isNull(); assertThat(request.getEnv()).isEmpty(); assertThat(request.isCleanCache()).isFalse(); @@ -170,21 +172,53 @@ void getBuildRequestWhenHasTagsUsesTags() { } @Test - void getBuildRequestWhenHasBuildVolumeCacheUsesCache() { + void getBuildRequestWhenHasBuildWorkspaceVolumeUsesWorkspace() { Image image = new Image(); - image.buildCache = new CacheInfo(new VolumeCacheInfo("build-cache-vol")); + image.buildWorkspace = CacheInfo.fromVolume(new VolumeCacheInfo("build-work-vol")); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getBuildWorkspace()).isEqualTo(Cache.volume("build-work-vol")); + } + + @Test + void getBuildRequestWhenHasBuildCacheVolumeUsesCache() { + Image image = new Image(); + image.buildCache = CacheInfo.fromVolume(new VolumeCacheInfo("build-cache-vol")); BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); assertThat(request.getBuildCache()).isEqualTo(Cache.volume("build-cache-vol")); } @Test - void getBuildRequestWhenHasLaunchVolumeCacheUsesCache() { + void getBuildRequestWhenHasLaunchCacheVolumeUsesCache() { Image image = new Image(); - image.launchCache = new CacheInfo(new VolumeCacheInfo("launch-cache-vol")); + image.launchCache = CacheInfo.fromVolume(new VolumeCacheInfo("launch-cache-vol")); BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); assertThat(request.getLaunchCache()).isEqualTo(Cache.volume("launch-cache-vol")); } + @Test + void getBuildRequestWhenHasBuildWorkspaceBindUsesWorkspace() { + Image image = new Image(); + image.buildWorkspace = CacheInfo.fromBind(new BindCacheInfo("build-work-dir")); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getBuildWorkspace()).isEqualTo(Cache.bind("build-work-dir")); + } + + @Test + void getBuildRequestWhenHasBuildCacheBindUsesCache() { + Image image = new Image(); + image.buildCache = CacheInfo.fromBind(new BindCacheInfo("build-cache-dir")); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getBuildCache()).isEqualTo(Cache.bind("build-cache-dir")); + } + + @Test + void getBuildRequestWhenHasLaunchCacheBindUsesCache() { + Image image = new Image(); + image.launchCache = CacheInfo.fromBind(new BindCacheInfo("launch-cache-dir")); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getLaunchCache()).isEqualTo(Cache.bind("launch-cache-dir")); + } + @Test void getBuildRequestWhenHasCreatedDateUsesCreatedDate() { Image image = new Image(); @@ -201,6 +235,22 @@ void getBuildRequestWhenHasApplicationDirectoryUsesApplicationDirectory() { assertThat(request.getApplicationDirectory()).isEqualTo("/application"); } + @Test + void getBuildRequestWhenHasSecurityOptionsUsesSecurityOptions() { + Image image = new Image(); + image.securityOptions = List.of("label=user:USER", "label=role:ROLE"); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getSecurityOptions()).containsExactly("label=user:USER", "label=role:ROLE"); + } + + @Test + void getBuildRequestWhenHasEmptySecurityOptionsUsesSecurityOptions() { + Image image = new Image(); + image.securityOptions = Collections.emptyList(); + BuildRequest request = image.getBuildRequest(createArtifact(), mockApplicationContent()); + assertThat(request.getSecurityOptions()).isEmpty(); + } + private Artifact createArtifact() { return new DefaultArtifact("com.example", "my-app", VersionRange.createFromVersion("0.0.1-SNAPSHOT"), "compile", "jar", null, new DefaultArtifactHandler()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/dependencies-layer-no-filter.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/dependencies-layer-no-filter.xml index 1398e8320206..e5014dce4393 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/dependencies-layer-no-filter.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/dependencies-layer-no-filter.xml @@ -1,7 +1,7 @@ + https://www.springframework.org/schema/boot/layers/layers-3.3.xsd"> diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/layers.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/layers.xml index b7427f83a79b..7f12e4fc63d8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/layers.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/layers.xml @@ -1,7 +1,7 @@ + https://www.springframework.org/schema/boot/layers/layers-3.3.xsd"> META-INF/resources/** diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/resource-layer-no-filter.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/resource-layer-no-filter.xml index 0614492c4982..bb698a6323e5 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/resource-layer-no-filter.xml +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/resources/resource-layer-no-filter.xml @@ -1,7 +1,7 @@ + https://www.springframework.org/schema/boot/layers/layers-3.3.xsd"> diff --git a/spring-boot-project/spring-boot-tools/spring-boot-properties-migrator/src/main/java/org/springframework/boot/context/properties/migrator/PropertiesMigrationReporter.java b/spring-boot-project/spring-boot-tools/spring-boot-properties-migrator/src/main/java/org/springframework/boot/context/properties/migrator/PropertiesMigrationReporter.java index 85603a7bb0c5..c1f7e61796d5 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-properties-migrator/src/main/java/org/springframework/boot/context/properties/migrator/PropertiesMigrationReporter.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-properties-migrator/src/main/java/org/springframework/boot/context/properties/migrator/PropertiesMigrationReporter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,8 +127,7 @@ private Map> getMatchingProperties( new PropertyMigration(match, metadata, determineReplacementMetadata(metadata), false)); } // Prefix match for maps - if (isMapType(metadata) && propertySource instanceof IterableConfigurationPropertySource) { - IterableConfigurationPropertySource iterableSource = (IterableConfigurationPropertySource) propertySource; + if (isMapType(metadata) && propertySource instanceof IterableConfigurationPropertySource iterableSource) { iterableSource.stream() .filter(metadataName::isAncestorOf) .map(propertySource::getConfigurationProperty) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-properties-migrator/src/main/java/org/springframework/boot/context/properties/migrator/PropertyMigration.java b/spring-boot-project/spring-boot-tools/spring-boot-properties-migrator/src/main/java/org/springframework/boot/context/properties/migrator/PropertyMigration.java index 936467946980..ab69d8213072 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-properties-migrator/src/main/java/org/springframework/boot/context/properties/migrator/PropertyMigration.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-properties-migrator/src/main/java/org/springframework/boot/context/properties/migrator/PropertyMigration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,11 +85,8 @@ private static boolean determineCompatibleType(ConfigurationMetadataProperty met if (replacementType.equals(currentType)) { return true; } - if (replacementType.equals(Duration.class.getName()) - && (currentType.equals(Long.class.getName()) || currentType.equals(Integer.class.getName()))) { - return true; - } - return false; + return replacementType.equals(Duration.class.getName()) + && (currentType.equals(Long.class.getName()) || currentType.equals(Integer.class.getName())); } private static String determineType(ConfigurationMetadataProperty metadata) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssert.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssert.java new file mode 100644 index 000000000000..8b6b25e7a543 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssert.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-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.boot.testsupport.assertj; + +import java.lang.reflect.Field; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.Assert; + +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.util.ReflectionUtils; + +/** + * AssertJ {@link Assert} for {@link SimpleAsyncTaskExecutor}. + * + * @author Moritz Halbritter + */ +public final class SimpleAsyncTaskExecutorAssert + extends AbstractAssert { + + private SimpleAsyncTaskExecutorAssert(SimpleAsyncTaskExecutor actual) { + super(actual, SimpleAsyncTaskExecutorAssert.class); + } + + /** + * Verifies that the actual executor uses platform threads. + * @return {@code this} assertion object + * @throws AssertionError if the actual executor doesn't use platform threads + */ + public SimpleAsyncTaskExecutorAssert usesPlatformThreads() { + isNotNull(); + if (producesVirtualThreads()) { + failWithMessage("Expected executor to use platform threads, but it uses virtual threads"); + } + return this; + } + + /** + * Verifies that the actual executor uses virtual threads. + * @return {@code this} assertion object + * @throws AssertionError if the actual executor doesn't use virtual threads + */ + public SimpleAsyncTaskExecutorAssert usesVirtualThreads() { + isNotNull(); + if (!producesVirtualThreads()) { + failWithMessage("Expected executor to use virtual threads, but it uses platform threads"); + } + return this; + } + + private boolean producesVirtualThreads() { + Field field = ReflectionUtils.findField(SimpleAsyncTaskExecutor.class, "virtualThreadDelegate"); + if (field == null) { + throw new IllegalStateException("Field SimpleAsyncTaskExecutor.virtualThreadDelegate not found"); + } + ReflectionUtils.makeAccessible(field); + Object virtualThreadDelegate = ReflectionUtils.getField(field, this.actual); + return virtualThreadDelegate != null; + } + + /** + * Creates a new assertion class with the given {@link SimpleAsyncTaskExecutor}. + * @param actual the {@link SimpleAsyncTaskExecutor} + * @return the assertion class + */ + public static SimpleAsyncTaskExecutorAssert assertThat(SimpleAsyncTaskExecutor actual) { + return new SimpleAsyncTaskExecutorAssert(actual); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/package-info.java new file mode 100644 index 000000000000..9eb3d15f8081 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/assertj/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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. + */ + +/** + * Custom AssertJ assertions. + */ +package org.springframework.boot.testsupport.assertj; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java index ddbfee168a87..bc27172eb4cc 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ClassPathExclusions.java @@ -25,6 +25,8 @@ import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.core.annotation.AliasFor; + /** * Annotation used to exclude entries from the classpath. * @@ -36,6 +38,18 @@ @ExtendWith(ModifiedClassPathExtension.class) public @interface ClassPathExclusions { + /** + * Alias for {@code files}. + *

+ * One or more Ant-style patterns that identify entries to be excluded from the class + * path. Matching is performed against an entry's {@link File#getName() file name}. + * For example, to exclude Hibernate Validator from the classpath, + * {@code "hibernate-validator-*.jar"} can be used. + * @return the exclusion patterns + */ + @AliasFor("files") + String[] value() default {}; + /** * One or more Ant-style patterns that identify entries to be excluded from the class * path. Matching is performed against an entry's {@link File#getName() file name}. @@ -43,6 +57,13 @@ * {@code "hibernate-validator-*.jar"} can be used. * @return the exclusion patterns */ - String[] value(); + @AliasFor("value") + String[] files() default {}; + + /** + * One or more packages that should be excluded from the classpath. + * @return the excluded packages + */ + String[] packages() default {}; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java index a5db6b97e9c4..5ddbe97ab585 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathClassLoader.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -55,6 +56,7 @@ import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.util.AntPathMatcher; +import org.springframework.util.ClassUtils; import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -73,10 +75,14 @@ final class ModifiedClassPathClassLoader extends URLClassLoader { private static final int MAX_RESOLUTION_ATTEMPTS = 5; + private final Set excludedPackages; + private final ClassLoader junitLoader; - ModifiedClassPathClassLoader(URL[] urls, ClassLoader parent, ClassLoader junitLoader) { + ModifiedClassPathClassLoader(URL[] urls, Set excludedPackages, ClassLoader parent, + ClassLoader junitLoader) { super(urls, parent); + this.excludedPackages = excludedPackages; this.junitLoader = junitLoader; } @@ -86,6 +92,10 @@ public Class loadClass(String name) throws ClassNotFoundException { || name.startsWith("io.netty.internal.tcnative")) { return Class.forName(name, false, this.junitLoader); } + String packageName = ClassUtils.getPackageName(name); + if (this.excludedPackages.contains(packageName)) { + throw new ClassNotFoundException(); + } return super.loadClass(name); } @@ -129,7 +139,7 @@ private static ModifiedClassPathClassLoader compute(ClassLoader classLoader, .map((source) -> MergedAnnotations.from(source, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY)) .toList(); return new ModifiedClassPathClassLoader(processUrls(extractUrls(classLoader), annotations), - classLoader.getParent(), classLoader); + excludedPackages(annotations), classLoader.getParent(), classLoader); } private static URL[] extractUrls(ClassLoader classLoader) { @@ -276,6 +286,17 @@ private static List createDependencies(String[] allCoordinates) { return dependencies; } + private static Set excludedPackages(List annotations) { + Set excludedPackages = new HashSet<>(); + for (MergedAnnotations candidate : annotations) { + MergedAnnotation annotation = candidate.get(ClassPathExclusions.class); + if (annotation.isPresent()) { + excludedPackages.addAll(Arrays.asList(annotation.getStringArray("packages"))); + } + } + return excludedPackages; + } + /** * Filter for class path entries. */ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/junit/DisabledOnOsCondition.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/junit/DisabledOnOsCondition.java index 32a08b0e88e8..3d1f6dc6b34a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/junit/DisabledOnOsCondition.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/junit/DisabledOnOsCondition.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ class DisabledOnOsCondition implements ExecutionCondition { @Override public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { - if (!context.getElement().isPresent()) { + if (context.getElement().isEmpty()) { return ConditionEvaluationResult.enabled("No element for @DisabledOnOs found"); } MergedAnnotation annotation = MergedAnnotations @@ -53,7 +53,7 @@ private ConditionEvaluationResult evaluate(DisabledOnOs annotation) { String architecture = System.getProperty("os.arch"); String os = System.getProperty("os.name"); boolean onDisabledOs = Arrays.stream(annotation.os()).anyMatch(OS::isCurrentOs); - boolean onDisabledArchitecture = Arrays.stream(annotation.architecture()).anyMatch(architecture::equals); + boolean onDisabledArchitecture = Arrays.asList(annotation.architecture()).contains(architecture); if (onDisabledOs && onDisabledArchitecture) { String reason = annotation.disabledReason().isEmpty() ? String.format("Disabled on OS = %s, architecture = %s", os, architecture) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java index 304f2c7b1a24..81a5673d8fec 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ * @author Stephane Nicoll * @author Eddú Meléndez * @author Moritz Halbritter + * @author Chris Bono */ public final class DockerImageNames { @@ -47,8 +48,16 @@ public final class DockerImageNames { private static final String NEO4J_VERSION = "4.4.11"; + private static final String OPEN_LDAP_VERSION = "1.5.0"; + + private static final String ORACLE_FREE_VERSION = "23.3-slim"; + private static final String ORACLE_XE_VERSION = "18.4.0-slim"; + private static final String OPENTELEMETRY_VERSION = "0.75.0"; + + private static final String PULSAR_VERSION = "3.1.0"; + private static final String POSTGRESQL_VERSION = "14.0"; private static final String RABBIT_VERSION = "3.11-alpine"; @@ -112,6 +121,14 @@ public static DockerImageName kafka() { return DockerImageName.parse("confluentinc/cp-kafka").withTag(KAFKA_VERSION); } + /** + * Return a {@link DockerImageName} suitable for running OpenLDAP. + * @return a docker image name for running OpenLDAP + */ + public static DockerImageName openLdap() { + return DockerImageName.parse("osixia/openldap").withTag(OPEN_LDAP_VERSION); + } + /** * Return a {@link DockerImageName} suitable for running MariaDB. * @return a docker image name for running MariaDB @@ -144,6 +161,14 @@ public static DockerImageName neo4j() { return DockerImageName.parse("neo4j").withTag(NEO4J_VERSION); } + /** + * Return a {@link DockerImageName} suitable for running the Oracle database. + * @return a docker image name for running the Oracle database + */ + public static DockerImageName oracleFree() { + return DockerImageName.parse("gvenzl/oracle-free").withTag(ORACLE_FREE_VERSION); + } + /** * Return a {@link DockerImageName} suitable for running the Oracle database. * @return a docker image name for running the Oracle database @@ -152,6 +177,22 @@ public static DockerImageName oracleXe() { return DockerImageName.parse("gvenzl/oracle-xe").withTag(ORACLE_XE_VERSION); } + /** + * Return a {@link DockerImageName} suitable for running OpenTelemetry. + * @return a docker image name for running OpenTelemetry + */ + public static DockerImageName opentelemetry() { + return DockerImageName.parse("otel/opentelemetry-collector-contrib").withTag(OPENTELEMETRY_VERSION); + } + + /** + * Return a {@link DockerImageName} suitable for running Apache Pulsar. + * @return a docker image name for running pulsar + */ + public static DockerImageName pulsar() { + return DockerImageName.parse("apachepulsar/pulsar").withTag(PULSAR_VERSION); + } + /** * Return a {@link DockerImageName} suitable for running PostgreSQL. * @return a docker image name for running postgresql diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/OpenLdapContainer.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/OpenLdapContainer.java new file mode 100644 index 000000000000..93ac6e8abf3a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/OpenLdapContainer.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.testsupport.testcontainers; + +import org.testcontainers.containers.GenericContainer; + +/** + * A {@link GenericContainer} for OpenLDAP. + * + * @author Philipp Kessler + */ +public class OpenLdapContainer extends GenericContainer { + + private static final int DEFAULT_LDAP_PORT = 389; + + public OpenLdapContainer() { + super(DockerImageNames.openLdap()); + addExposedPorts(DEFAULT_LDAP_PORT); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssertTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssertTests.java new file mode 100644 index 000000000000..a3775298f84b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/assertj/SimpleAsyncTaskExecutorAssertTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-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.boot.testsupport.assertj; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +import org.springframework.core.task.SimpleAsyncTaskExecutor; + +/** + * Tests for {@link SimpleAsyncTaskExecutorAssert}. + * + * @author Moritz Halbritter + */ +class SimpleAsyncTaskExecutorAssertTests { + + @Test + void usesPlatformThreads() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + executor.setVirtualThreads(false); + SimpleAsyncTaskExecutorAssert.assertThat(executor).usesPlatformThreads(); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void usesVirtualThreads() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor(); + executor.setVirtualThreads(true); + SimpleAsyncTaskExecutorAssert.assertThat(executor).usesVirtualThreads(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionExclusionsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionExclusionsTests.java index 5cfdd53c0af0..0e0741bd2ace 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionExclusionsTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/ModifiedClassPathExtensionExclusionsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -19,6 +19,8 @@ import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; +import org.springframework.util.ClassUtils; + import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.isA; @@ -26,22 +28,34 @@ * Tests for {@link ModifiedClassPathExtension} excluding entries from the class path. * * @author Christoph Dreis + * @author Andy Wilkinson */ -@ClassPathExclusions("hibernate-validator-*.jar") +@ClassPathExclusions(files = "hibernate-validator-*.jar", packages = "java.net.http") class ModifiedClassPathExtensionExclusionsTests { private static final String EXCLUDED_RESOURCE = "META-INF/services/jakarta.validation.spi.ValidationProvider"; @Test - void entriesAreFilteredFromTestClassClassLoader() { + void fileExclusionsAreFilteredFromTestClassClassLoader() { assertThat(getClass().getClassLoader().getResource(EXCLUDED_RESOURCE)).isNull(); } @Test - void entriesAreFilteredFromThreadContextClassLoader() { + void fileExclusionsAreFilteredFromThreadContextClassLoader() { assertThat(Thread.currentThread().getContextClassLoader().getResource(EXCLUDED_RESOURCE)).isNull(); } + @Test + void packageExclusionsAreFilteredFromTestClassClassLoader() { + assertThat(ClassUtils.isPresent("java.net.http.HttpClient", getClass().getClassLoader())).isFalse(); + } + + @Test + void packageExclusionsAreFilteredFromThreadContextClassLoader() { + assertThat(ClassUtils.isPresent("java.net.http.HttpClient", Thread.currentThread().getContextClassLoader())) + .isFalse(); + } + @Test void testsThatUseHamcrestWorkCorrectly() { Matcher matcher = isA(IllegalStateException.class); diff --git a/spring-boot-project/spring-boot/build.gradle b/spring-boot-project/spring-boot/build.gradle index ecd156e99b34..0e2f235e55b7 100644 --- a/spring-boot-project/spring-boot/build.gradle +++ b/spring-boot-project/spring-boot/build.gradle @@ -56,17 +56,13 @@ dependencies { optional("org.assertj:assertj-core") optional("org.apache.groovy:groovy") optional("org.apache.groovy:groovy-xml") - optional("org.eclipse.jetty:jetty-servlets") + optional("org.crac:crac") + optional("org.eclipse.jetty:jetty-alpn-conscrypt-server") + optional("org.eclipse.jetty:jetty-client") optional("org.eclipse.jetty:jetty-util") - optional("org.eclipse.jetty:jetty-webapp") { - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - } - optional("org.eclipse.jetty:jetty-alpn-conscrypt-server") { - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - } - optional("org.eclipse.jetty.http2:http2-server") { - exclude(group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api") - } + optional("org.eclipse.jetty.ee10:jetty-ee10-servlets") + optional("org.eclipse.jetty.ee10:jetty-ee10-webapp") + optional("org.eclipse.jetty.http2:jetty-http2-server") optional("org.flywaydb:flyway-core") optional("org.hamcrest:hamcrest-library") optional("org.hibernate.orm:hibernate-core") @@ -118,8 +114,8 @@ dependencies { testImplementation("org.awaitility:awaitility") testImplementation("org.codehaus.janino:janino") testImplementation("org.eclipse.jetty:jetty-client") - testImplementation("org.eclipse.jetty.http2:http2-client") - testImplementation("org.eclipse.jetty.http2:http2-http-client-transport") + testImplementation("org.eclipse.jetty.http2:jetty-http2-client") + testImplementation("org.eclipse.jetty.http2:jetty-http2-client-transport") testImplementation("org.firebirdsql.jdbc:jaybird") { exclude group: "javax.resource", module: "connector-api" } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java index 0fc3b65d862f..554be69a1738 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java @@ -17,6 +17,7 @@ package org.springframework.boot; import java.lang.StackWalker.StackFrame; +import java.lang.management.ManagementFactory; import java.lang.reflect.Method; import java.time.Duration; import java.util.ArrayList; @@ -34,10 +35,12 @@ import java.util.Properties; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.crac.management.CRaCMXBean; import org.springframework.aot.AotDetector; import org.springframework.beans.BeansException; @@ -71,6 +74,9 @@ import org.springframework.context.annotation.ClassPathBeanDefinitionScanner; import org.springframework.context.annotation.ConfigurationClassPostProcessor; import org.springframework.context.aot.AotApplicationContextInitializer; +import org.springframework.context.event.ApplicationContextEvent; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.context.support.AbstractApplicationContext; import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.GenericTypeResolver; @@ -170,8 +176,10 @@ * @author Brian Clozel * @author Ethan Rubinson * @author Chris Bono + * @author Moritz Halbritter * @author Tadaya Tsuyukubo * @author Lasse Wulff + * @author Yanming Zhou * @since 1.0.0 * @see #run(Class, String[]) * @see #run(Class[], String[]) @@ -249,6 +257,8 @@ public class SpringApplication { private ApplicationStartup applicationStartup = ApplicationStartup.DEFAULT; + private boolean keepAlive; + /** * Create a new {@link SpringApplication} instance. The application context will load * beans from the specified primary sources (see {@link SpringApplication class-level} @@ -305,10 +315,10 @@ private Optional> findMainClass(Stream stack) { * @return a running {@link ApplicationContext} */ public ConfigurableApplicationContext run(String... args) { + Startup startup = Startup.create(); if (this.registerShutdownHook) { SpringApplication.shutdownHook.enableShutdownHookAddition(); } - long startTime = System.nanoTime(); DefaultBootstrapContext bootstrapContext = createBootstrapContext(); ConfigurableApplicationContext context = null; configureHeadlessProperty(); @@ -323,11 +333,11 @@ public ConfigurableApplicationContext run(String... args) { prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner); refreshContext(context); afterRefresh(context, applicationArguments); - Duration timeTakenToStartup = Duration.ofNanos(System.nanoTime() - startTime); + startup.started(); if (this.logStartupInfo) { - new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), timeTakenToStartup); + new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), startup); } - listeners.started(context, timeTakenToStartup); + listeners.started(context, startup.timeTakenToStarted()); callRunners(context, applicationArguments); } catch (Throwable ex) { @@ -335,8 +345,7 @@ public ConfigurableApplicationContext run(String... args) { } try { if (context.isRunning()) { - Duration timeTakenToReady = Duration.ofNanos(System.nanoTime() - startTime); - listeners.ready(context, timeTakenToReady); + listeners.ready(context, startup.ready()); } } catch (Throwable ex) { @@ -410,6 +419,9 @@ private void prepareContext(DefaultBootstrapContext bootstrapContext, Configurab if (this.lazyInitialization) { context.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor()); } + if (this.keepAlive) { + context.addApplicationListener(new KeepAlive()); + } context.addBeanFactoryPostProcessor(new PropertySourceOrderingBeanFactoryPostProcessor(context)); if (!AotDetector.useGeneratedArtifacts()) { // Load the sources @@ -426,6 +438,10 @@ private void addAotGeneratedInitializerIfNecessary(List { + + private final AtomicReference thread = new AtomicReference<>(); + + @Override + public void onApplicationEvent(ApplicationContextEvent event) { + if (event instanceof ContextRefreshedEvent) { + startKeepAliveThread(); + } + else if (event instanceof ContextClosedEvent) { + stopKeepAliveThread(); + } + } + + private void startKeepAliveThread() { + Thread thread = new Thread(() -> { + while (true) { + try { + Thread.sleep(Long.MAX_VALUE); + } + catch (InterruptedException ex) { + break; + } + } + }); + if (this.thread.compareAndSet(null, thread)) { + thread.setDaemon(false); + thread.setName("keep-alive"); + thread.start(); + } + } + + private void stopKeepAliveThread() { + Thread thread = this.thread.getAndSet(null); + if (thread == null) { + return; + } + thread.interrupt(); + } + + } + + /** + * Strategy used to handle startup concerns. + */ + abstract static class Startup { + + private Duration timeTakenToStarted; + + protected abstract long startTime(); + + protected abstract Long processUptime(); + + protected abstract String action(); + + final Duration started() { + long now = System.currentTimeMillis(); + this.timeTakenToStarted = Duration.ofMillis(now - startTime()); + return this.timeTakenToStarted; + } + + Duration timeTakenToStarted() { + return this.timeTakenToStarted; + } + + private Duration ready() { + long now = System.currentTimeMillis(); + return Duration.ofMillis(now - startTime()); + } + + static Startup create() { + ClassLoader classLoader = Startup.class.getClassLoader(); + return (ClassUtils.isPresent("jdk.crac.management.CRaCMXBean", classLoader) + && ClassUtils.isPresent("org.crac.management.CRaCMXBean", classLoader)) + ? new CoordinatedRestoreAtCheckpointStartup() : new StandardStartup(); + } + + } + + /** + * Standard {@link Startup} implementation. + */ + private static final class StandardStartup extends Startup { + + private final Long startTime = System.currentTimeMillis(); + + @Override + protected long startTime() { + return this.startTime; + } + + @Override + protected Long processUptime() { + try { + return ManagementFactory.getRuntimeMXBean().getUptime(); + } + catch (Throwable ex) { + return null; + } + } + + @Override + protected String action() { + return "Started"; + } + + } + + /** + * Coordinated-Restore-At-Checkpoint {@link Startup} implementation. + */ + private static final class CoordinatedRestoreAtCheckpointStartup extends Startup { + + private final StandardStartup fallback = new StandardStartup(); + + @Override + protected Long processUptime() { + long uptime = CRaCMXBean.getCRaCMXBean().getUptimeSinceRestore(); + return (uptime >= 0) ? uptime : this.fallback.processUptime(); + } + + @Override + protected String action() { + return (restoreTime() >= 0) ? "Restored" : this.fallback.action(); + } + + private long restoreTime() { + return CRaCMXBean.getCRaCMXBean().getRestoreTime(); + } + + @Override + protected long startTime() { + long restoreTime = restoreTime(); + return (restoreTime >= 0) ? restoreTime : this.fallback.startTime(); + } + + } + /** * {@link OrderSourceProvider} used to obtain factory method and target type order * sources. Based on internal {@link DefaultListableBeanFactory} code. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java index 804319fbb0c0..8b6c5f3439f5 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java @@ -16,13 +16,12 @@ package org.springframework.boot; -import java.lang.management.ManagementFactory; -import java.time.Duration; import java.util.concurrent.Callable; import org.apache.commons.logging.Log; import org.springframework.aot.AotDetector; +import org.springframework.boot.SpringApplication.Startup; import org.springframework.boot.system.ApplicationHome; import org.springframework.boot.system.ApplicationPid; import org.springframework.context.ApplicationContext; @@ -52,9 +51,9 @@ void logStarting(Log applicationLog) { applicationLog.debug(LogMessage.of(this::getRunningMessage)); } - void logStarted(Log applicationLog, Duration timeTakenToStartup) { + void logStarted(Log applicationLog, Startup startup) { if (applicationLog.isInfoEnabled()) { - applicationLog.info(getStartedMessage(timeTakenToStartup)); + applicationLog.info(getStartedMessage(startup)); } } @@ -79,20 +78,18 @@ private CharSequence getRunningMessage() { return message; } - private CharSequence getStartedMessage(Duration timeTakenToStartup) { + private CharSequence getStartedMessage(Startup startup) { StringBuilder message = new StringBuilder(); - message.append("Started"); + message.append(startup.action()); appendApplicationName(message); message.append(" in "); - message.append(timeTakenToStartup.toMillis() / 1000.0); + message.append(startup.timeTakenToStarted().toMillis() / 1000.0); message.append(" seconds"); - try { - double uptime = ManagementFactory.getRuntimeMXBean().getUptime() / 1000.0; + Long uptimeMs = startup.processUptime(); + if (uptimeMs != null) { + double uptime = uptimeMs / 1000.0; message.append(" (process running for ").append(uptime).append(")"); } - catch (Throwable ex) { - // No JVM time available - } return message; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/builder/SpringApplicationBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/builder/SpringApplicationBuilder.java index 712caa5e6f15..b479568fcb53 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/builder/SpringApplicationBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/builder/SpringApplicationBuilder.java @@ -76,7 +76,7 @@ public class SpringApplicationBuilder { private final SpringApplication application; - private ConfigurableApplicationContext context; + private volatile ConfigurableApplicationContext context; private SpringApplicationBuilder parent; @@ -145,10 +145,8 @@ public ConfigurableApplicationContext run(String... args) { } configureAsChildIfNecessary(args); if (this.running.compareAndSet(false, true)) { - synchronized (this.running) { - // If not already running copy the sources over and then run. - this.context = build().run(args); - } + // If not already running copy the sources over and then run. + this.context = build().run(args); } return this.context; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigData.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigData.java index a4a0cfad1d2b..1a9dcf068c66 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigData.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigData.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -281,7 +281,7 @@ public enum Option { * profile specific sibling imports. * @since 2.4.5 */ - PROFILE_SPECIFIC; + PROFILE_SPECIFIC } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentContributor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentContributor.java index 4311c48e051c..dc2dcd45aa4a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentContributor.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentContributor.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -470,7 +470,7 @@ enum Kind { /** * A valid location that contained nothing to load. */ - EMPTY_LOCATION; + EMPTY_LOCATION } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentContributors.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentContributors.java index b9c9b0ca2915..ca6bf96784c3 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentContributors.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironmentContributors.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -326,7 +326,7 @@ enum BinderOption { /** * Throw an exception if an inactive contributor contains a bound value. */ - FAIL_ON_BIND_TO_INACTIVE_SOURCE; + FAIL_ON_BIND_TO_INACTIVE_SOURCE } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLoaders.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLoaders.java index 2857c534648f..2276d440b37e 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLoaders.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLoaders.java @@ -100,15 +100,14 @@ ConfigData load(ConfigDataLoaderContext context, private ConfigDataLoader getLoader(ConfigDataLoaderContext context, R resource) { ConfigDataLoader result = null; for (int i = 0; i < this.loaders.size(); i++) { - ConfigDataLoader candidate = this.loaders.get(i); + ConfigDataLoader candidate = this.loaders.get(i); if (this.resourceTypes.get(i).isInstance(resource)) { - ConfigDataLoader loader = (ConfigDataLoader) candidate; - if (loader.isLoadable(context, resource)) { + if (candidate.isLoadable(context, resource)) { if (result != null) { throw new IllegalStateException("Multiple loaders found for resource '" + resource + "' [" + candidate.getClass().getName() + "," + result.getClass().getName() + "]"); } - result = loader; + result = candidate; } } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationRuntimeHints.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationRuntimeHints.java index 78f71e458d58..7998837e0b6b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationRuntimeHints.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataLocationRuntimeHints.java @@ -50,8 +50,8 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + locations); } FilePatternResourceHintsRegistrar.forClassPathLocations(locations) - .withFileExtensions(extensions) .withFilePrefixes(fileNames) + .withFileExtensions(extensions) .registerHints(hints.resources(), classLoader); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataProperties.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataProperties.java index 24f45d58f7b4..cdf18ae00a37 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataProperties.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ * * @author Phillip Webb * @author Madhura Bhave + * @author Yanming Zhou */ class ConfigDataProperties { @@ -118,8 +119,8 @@ boolean isActive(ConfigDataActivationContext activationContext) { if (activationContext == null) { return false; } - boolean activate = true; - activate = activate && isActive(activationContext.getCloudPlatform()); + CloudPlatform cloudPlatform = activationContext.getCloudPlatform(); + boolean activate = isActive((cloudPlatform != null) ? cloudPlatform : CloudPlatform.NONE); activate = activate && isActive(activationContext.getProfiles()); return activate; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializer.java index 5f0cf707a7ee..3cf1721734bf 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -38,7 +38,10 @@ * @author Dave Syer * @author Phillip Webb * @since 1.0.0 + * @deprecated since 3.2.0 for removal in 3.4.0 as property based initialization is no + * longer recommended */ +@Deprecated(since = "3.2.0", forRemoval = true) public class DelegatingApplicationContextInitializer implements ApplicationContextInitializer, Ordered { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationListener.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationListener.java index 634962b0b6fa..41c85cc354bc 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationListener.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/DelegatingApplicationListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -40,7 +40,10 @@ * @author Dave Syer * @author Phillip Webb * @since 1.0.0 + * @deprecated since 3.2.0 for removal in 3.4.0 as property based initialization is no + * longer recommended */ +@Deprecated(since = "3.2.0", forRemoval = true) public class DelegatingApplicationListener implements ApplicationListener, Ordered { // NOTE: Similar to org.springframework.web.context.ContextLoader diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java index f63065527122..35a7a71552b7 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java @@ -23,7 +23,6 @@ import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.NoSuchBeanDefinitionException; @@ -42,8 +41,6 @@ import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; -import org.springframework.util.ReflectionUtils; import org.springframework.validation.annotation.Validated; /** @@ -103,17 +100,6 @@ Class getType() { return this.bindTarget.getType().resolve(); } - /** - * Return the property binding method that was used for the bean. - * @return the bind method - * @deprecated since 3.0.8 for removal in 3.3.0 in favor of {@link #asBindTarget} and - * {@link Bindable#getBindMethod} - */ - @Deprecated(since = "3.0.8", forRemoval = true) - public BindMethod getBindMethod() { - return BindMethod.from(this.bindTarget.getBindMethod()); - } - /** * Return the {@link ConfigurationProperties} annotation for the bean. The annotation * may be defined on the bean itself or from the factory method that create the bean @@ -238,36 +224,12 @@ private static Method findFactoryMethod(ConfigurableListableBeanFactory beanFact if (beanFactory.containsBeanDefinition(beanName)) { BeanDefinition beanDefinition = beanFactory.getMergedBeanDefinition(beanName); if (beanDefinition instanceof RootBeanDefinition rootBeanDefinition) { - Method resolvedFactoryMethod = rootBeanDefinition.getResolvedFactoryMethod(); - if (resolvedFactoryMethod != null) { - return resolvedFactoryMethod; - } + return rootBeanDefinition.getResolvedFactoryMethod(); } - return findFactoryMethodUsingReflection(beanFactory, beanDefinition); } return null; } - private static Method findFactoryMethodUsingReflection(ConfigurableListableBeanFactory beanFactory, - BeanDefinition beanDefinition) { - String factoryMethodName = beanDefinition.getFactoryMethodName(); - String factoryBeanName = beanDefinition.getFactoryBeanName(); - if (factoryMethodName == null || factoryBeanName == null) { - return null; - } - Class factoryType = beanFactory.getType(factoryBeanName); - if (factoryType.getName().contains(ClassUtils.CGLIB_CLASS_SEPARATOR)) { - factoryType = factoryType.getSuperclass(); - } - AtomicReference factoryMethod = new AtomicReference<>(); - ReflectionUtils.doWithMethods(factoryType, (method) -> { - if (method.getName().equals(factoryMethodName)) { - factoryMethod.set(method); - } - }); - return factoryMethod.get(); - } - static ConfigurationPropertiesBean forValueObject(Class beanType, String beanName) { Bindable bindTarget = createBindTarget(null, beanType, null); Assert.state(bindTarget != null && deduceBindMethod(bindTarget) == VALUE_OBJECT_BIND_METHOD, @@ -340,35 +302,4 @@ private static org.springframework.boot.context.properties.bind.BindMethod deduc return (bindConstructor != null) ? VALUE_OBJECT_BIND_METHOD : JAVA_BEAN_BIND_METHOD; } - /** - * The binding method that is used for the bean. - * - * @deprecated since 3.0.8 for removal in 3.3.0 in favor of - * {@link org.springframework.boot.context.properties.bind.BindMethod} - */ - @Deprecated(since = "3.0.8", forRemoval = true) - public enum BindMethod { - - /** - * Java Bean using getter/setter binding. - */ - JAVA_BEAN, - - /** - * Value object using constructor binding. - */ - VALUE_OBJECT; - - static BindMethod from(org.springframework.boot.context.properties.bind.BindMethod bindMethod) { - if (bindMethod == null) { - return null; - } - return switch (bindMethod) { - case VALUE_OBJECT -> BindMethod.VALUE_OBJECT; - case JAVA_BEAN -> BindMethod.JAVA_BEAN; - }; - } - - } - } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanFactoryInitializationAotProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanFactoryInitializationAotProcessor.java index 41efc8cd9174..3786fc3acad9 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanFactoryInitializationAotProcessor.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanFactoryInitializationAotProcessor.java @@ -16,18 +16,14 @@ package org.springframework.boot.context.properties; -import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.List; import org.springframework.aot.generate.GenerationContext; -import org.springframework.beans.BeansException; import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor; import org.springframework.beans.factory.aot.BeanFactoryInitializationCode; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.beans.factory.config.SmartInstantiationAwareBeanPostProcessor; -import org.springframework.boot.context.properties.bind.BindConstructorProvider; import org.springframework.boot.context.properties.bind.BindMethod; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.BindableRuntimeHintsRegistrar; @@ -47,7 +43,6 @@ class ConfigurationPropertiesBeanFactoryInitializationAotProcessor implements Be @Override public ConfigurationPropertiesReflectionHintsContribution processAheadOfTime( ConfigurableListableBeanFactory beanFactory) { - beanFactory.addBeanPostProcessor(new BindConstructorAwareBeanPostProcessor(beanFactory)); String[] beanNames = beanFactory.getBeanNamesForAnnotation(ConfigurationProperties.class); List> bindables = new ArrayList<>(); for (String beanName : beanNames) { @@ -63,37 +58,6 @@ public ConfigurationPropertiesReflectionHintsContribution processAheadOfTime( return (!bindables.isEmpty()) ? new ConfigurationPropertiesReflectionHintsContribution(bindables) : null; } - /** - * {@link SmartInstantiationAwareBeanPostProcessor} implementation to work around - * framework's constructor resolver for immutable configuration properties. - *

- * Constructor binding supports multiple constructors as long as one is identified as - * the candidate for binding. Unfortunately, framework is not aware of such feature - * and attempts to resolve the autowired constructor to use. - */ - static class BindConstructorAwareBeanPostProcessor implements SmartInstantiationAwareBeanPostProcessor { - - private final ConfigurableListableBeanFactory beanFactory; - - BindConstructorAwareBeanPostProcessor(ConfigurableListableBeanFactory beanFactory) { - this.beanFactory = beanFactory; - } - - @Override - public Constructor[] determineCandidateConstructors(Class beanClass, String beanName) - throws BeansException { - BindMethod bindMethod = BindMethodAttribute.get(this.beanFactory, beanName); - if (bindMethod != null && bindMethod == BindMethod.VALUE_OBJECT) { - Constructor bindConstructor = BindConstructorProvider.DEFAULT.getBindConstructor(beanClass, false); - if (bindConstructor != null) { - return new Constructor[] { bindConstructor }; - } - } - return null; - } - - } - static final class ConfigurationPropertiesReflectionHintsContribution implements BeanFactoryInitializationAotContribution { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java index 623626154b5c..5e96c1ae963f 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java @@ -16,7 +16,6 @@ package org.springframework.boot.context.properties; -import java.lang.reflect.Executable; import java.util.function.Predicate; import javax.lang.model.element.Modifier; @@ -34,6 +33,7 @@ import org.springframework.beans.factory.support.RegisteredBean; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.boot.context.properties.bind.BindMethod; +import org.springframework.javapoet.ClassName; import org.springframework.javapoet.CodeBlock; /** @@ -80,10 +80,14 @@ public CodeBlock generateSetBeanDefinitionPropertiesCode(GenerationContext gener beanDefinition, attributeFilter.or(BindMethodAttribute.NAME::equals)); } + @Override + public ClassName getTarget(RegisteredBean registeredBean) { + return ClassName.get(this.registeredBean.getBeanClass()); + } + @Override public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext, - BeanRegistrationCode beanRegistrationCode, Executable constructorOrFactoryMethod, - boolean allowDirectSupplierShortcut) { + BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) { GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> { Class beanClass = this.registeredBean.getBeanClass(); method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName()) diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesCharSequenceToObjectConverter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesCharSequenceToObjectConverter.java new file mode 100644 index 000000000000..c89ce7a3a665 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesCharSequenceToObjectConverter.java @@ -0,0 +1,103 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.properties; + +import java.util.Collections; +import java.util.Set; + +import org.springframework.boot.convert.ApplicationConversionService; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalGenericConverter; + +/** + * Copy of package-private + * {@code org.springframework.boot.convert.CharSequenceToObjectConverter}, renamed for + * differentiation. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +final class ConfigurationPropertiesCharSequenceToObjectConverter implements ConditionalGenericConverter { + + private static final TypeDescriptor STRING = TypeDescriptor.valueOf(String.class); + + private static final TypeDescriptor BYTE_ARRAY = TypeDescriptor.valueOf(byte[].class); + + private static final Set TYPES; + + private final ThreadLocal disable = new ThreadLocal<>(); + + static { + TYPES = Collections.singleton(new ConvertiblePair(CharSequence.class, Object.class)); + } + + private final ConversionService conversionService; + + ConfigurationPropertiesCharSequenceToObjectConverter(ConversionService conversionService) { + this.conversionService = conversionService; + } + + @Override + public Set getConvertibleTypes() { + return TYPES; + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + if (sourceType.getType() == String.class || this.disable.get() == Boolean.TRUE) { + return false; + } + this.disable.set(Boolean.TRUE); + try { + boolean canDirectlyConvertCharSequence = this.conversionService.canConvert(sourceType, targetType); + if (canDirectlyConvertCharSequence && !isStringConversionBetter(sourceType, targetType)) { + return false; + } + return this.conversionService.canConvert(STRING, targetType); + } + finally { + this.disable.remove(); + } + } + + /** + * Return if String based conversion is better based on the target type. This is + * required when ObjectTo... conversion produces incorrect results. + * @param sourceType the source type to test + * @param targetType the target type to test + * @return if string conversion is better + */ + private boolean isStringConversionBetter(TypeDescriptor sourceType, TypeDescriptor targetType) { + if (this.conversionService instanceof ApplicationConversionService applicationConversionService) { + if (applicationConversionService.isConvertViaObjectSourceType(sourceType, targetType)) { + // If an ObjectTo... converter is being used then there might be a + // better StringTo... version + return true; + } + } + // StringToArrayConverter / StringToCollectionConverter are better than + // ObjectToArrayConverter / ObjectToCollectionConverter + return (targetType.isArray() || targetType.isCollection()) && !targetType.equals(BYTE_ARRAY); + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + return this.conversionService.convert(source.toString(), STRING, targetType); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConstructorBinding.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConstructorBinding.java deleted file mode 100644 index 49c95249ee97..000000000000 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConstructorBinding.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2012-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.boot.context.properties; - -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; - -/** - * Annotation that can be used to indicate which constructor to use when binding - * configuration properties using constructor arguments rather than by calling setters. A - * single parameterized constructor implicitly indicates that constructor binding should - * be used unless the constructor is annotated with `@Autowired`. - *

- * Note: To use constructor binding the class must be enabled using - * {@link EnableConfigurationProperties @EnableConfigurationProperties} or configuration - * property scanning. Constructor binding cannot be used with beans that are created by - * the regular Spring mechanisms (e.g. - * {@link org.springframework.stereotype.Component @Component} beans, beans created via - * {@link org.springframework.context.annotation.Bean @Bean} methods or beans loaded using - * {@link org.springframework.context.annotation.Import @Import}). - * - * @author Phillip Webb - * @since 2.2.0 - * @see ConfigurationProperties - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link org.springframework.boot.context.properties.bind.ConstructorBinding} - */ -@Target({ ElementType.CONSTRUCTOR, ElementType.ANNOTATION_TYPE }) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@Deprecated(since = "3.0.0", forRemoval = true) -@org.springframework.boot.context.properties.bind.ConstructorBinding -public @interface ConstructorBinding { - -} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConversionServiceDeducer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConversionServiceDeducer.java index 3593faf4230a..3f29066f03ce 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConversionServiceDeducer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConversionServiceDeducer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,8 +29,10 @@ import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.converter.Converter; import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.format.Formatter; import org.springframework.format.FormatterRegistry; +import org.springframework.format.support.FormattingConversionService; /** * Utility to deduce the {@link ConversionService} to use for configuration properties @@ -59,15 +61,25 @@ List getConversionServices() { private List getConversionServices(ConfigurableApplicationContext applicationContext) { List conversionServices = new ArrayList<>(); - if (applicationContext.getBeanFactory().getConversionService() != null) { - conversionServices.add(applicationContext.getBeanFactory().getConversionService()); - } ConverterBeans converterBeans = new ConverterBeans(applicationContext); if (!converterBeans.isEmpty()) { - ApplicationConversionService beansConverterService = new ApplicationConversionService(); + FormattingConversionService beansConverterService = new FormattingConversionService(); + DefaultConversionService.addCollectionConverters(beansConverterService); + beansConverterService + .addConverter(new ConfigurationPropertiesCharSequenceToObjectConverter(beansConverterService)); converterBeans.addTo(beansConverterService); conversionServices.add(beansConverterService); } + if (applicationContext.getBeanFactory().getConversionService() != null) { + conversionServices.add(applicationContext.getBeanFactory().getConversionService()); + } + if (!converterBeans.isEmpty()) { + // Converters beans used to be added to a custom ApplicationConversionService + // after the BeanFactory's ConversionService. For backwards compatibility, we + // add an ApplicationConversationService as a fallback in the same place in + // the list. + conversionServices.add(ApplicationConversionService.getSharedInstance()); + } return conversionServices; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/DeprecatedConfigurationProperty.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/DeprecatedConfigurationProperty.java index 23940cde1271..f3962b551515 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/DeprecatedConfigurationProperty.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/DeprecatedConfigurationProperty.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -31,6 +31,7 @@ * This annotation must be used on the getter of the deprecated element. * * @author Phillip Webb + * @author Scott Frederick * @since 1.3.0 */ @Target(ElementType.METHOD) @@ -50,4 +51,10 @@ */ String replacement() default ""; + /** + * The version in which the property became deprecated. + * @return the version + */ + String since() default ""; + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/IncompatibleConfigurationFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/IncompatibleConfigurationFailureAnalyzer.java index 8df1d1e2378d..fad96a58123e 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/IncompatibleConfigurationFailureAnalyzer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/IncompatibleConfigurationFailureAnalyzer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author 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,6 @@ package org.springframework.boot.context.properties; -import java.util.stream.Collectors; - import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; import org.springframework.boot.diagnostics.FailureAnalysis; @@ -32,7 +30,7 @@ class IncompatibleConfigurationFailureAnalyzer extends AbstractFailureAnalyzer constructor) - ? isConstructorBindingConfigurationProperties(constructor) : false; + return injectionPoint != null && injectionPoint.getMember() instanceof Constructor constructor + && isConstructorBindingConfigurationProperties(constructor); } private boolean isConstructorBindingConfigurationProperties(Constructor constructor) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java index cdabe1d990d5..d56c03e312d2 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -42,6 +42,7 @@ import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.ConditionalGenericConverter; import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.core.io.Resource; import org.springframework.util.CollectionUtils; /** @@ -154,8 +155,8 @@ private static class ResolvableTypeDescriptor extends TypeDescriptor { private static class TypeConverterConversionService extends GenericConversionService { TypeConverterConversionService(Consumer initializer) { - addConverter(new TypeConverterConverter(initializer)); ApplicationConversionService.addDelimitedStringConverters(this); + addConverter(new TypeConverterConverter(initializer)); } @Override @@ -196,16 +197,23 @@ private static class TypeConverterConverter implements ConditionalGenericConvert @Override public Set getConvertibleTypes() { - return Collections.singleton(new ConvertiblePair(String.class, Object.class)); + return Set.of(new ConvertiblePair(String.class, Object.class), + new ConvertiblePair(String.class, Resource[].class), + new ConvertiblePair(String.class, Collection.class)); } @Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { Class type = targetType.getType(); - if (type == null || type == Object.class || Collection.class.isAssignableFrom(type) - || Map.class.isAssignableFrom(type)) { + if (type == null || type == Object.class || Map.class.isAssignableFrom(type)) { return false; } + if (Collection.class.isAssignableFrom(type)) { + TypeDescriptor elementType = targetType.getElementTypeDescriptor(); + if (elementType == null || (!Resource.class.isAssignableFrom(elementType.getType()))) { + return false; + } + } PropertyEditor editor = this.matchesOnlyTypeConverter.getDefaultEditor(type); if (editor == null) { editor = this.matchesOnlyTypeConverter.findCustomEditor(type, null); @@ -218,7 +226,7 @@ public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { @Override public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { - return createTypeConverter().convertIfNecessary(source, targetType.getType()); + return createTypeConverter().convertIfNecessary(source, targetType.getType(), targetType); } private SimpleTypeConverter createTypeConverter() { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindMethod.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindMethod.java index 7039ca43ec72..0277907965eb 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindMethod.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/BindMethod.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,6 @@ public enum BindMethod { /** * Value object using constructor binding. */ - VALUE_OBJECT; + VALUE_OBJECT } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java index d40937abb32d..9e76a84e82c1 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Bindable.java @@ -156,13 +156,7 @@ public boolean equals(Object obj) { @Override public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ObjectUtils.nullSafeHashCode(this.type); - result = prime * result + ObjectUtils.nullSafeHashCode(this.annotations); - result = prime * result + ObjectUtils.nullSafeHashCode(this.bindRestrictions); - result = prime * result + ObjectUtils.nullSafeHashCode(this.bindMethod); - return result; + return ObjectUtils.nullSafeHash(this.type, this.annotations, this.bindRestrictions, this.bindMethod); } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java index c0924484651b..4d8fbf0133d2 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java @@ -25,8 +25,10 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; import org.springframework.beans.PropertyEditorRegistry; @@ -360,25 +362,22 @@ private T handleBindResult(ConfigurationPropertyName name, Bindable targe result = context.getConverter().convert(result, target); } if (result == null && create) { - result = create(target, context); + result = fromDataObjectBinders(target.getBindMethod(), + (dataObjectBinder) -> dataObjectBinder.create(target, context)); result = handler.onCreate(name, target, context, result); result = context.getConverter().convert(result, target); - Assert.state(result != null, () -> "Unable to create instance for " + target.getType()); + if (result == null) { + IllegalStateException ex = new IllegalStateException( + "Unable to create instance for " + target.getType()); + this.dataObjectBinders.get(target.getBindMethod()) + .forEach((dataObjectBinder) -> dataObjectBinder.onUnableToCreateInstance(target, context, ex)); + throw ex; + } } handler.onFinish(name, target, context, result); return context.getConverter().convert(result, target); } - private Object create(Bindable target, Context context) { - for (DataObjectBinder dataObjectBinder : this.dataObjectBinders.get(target.getBindMethod())) { - Object instance = dataObjectBinder.create(target, context); - if (instance != null) { - return instance; - } - } - return null; - } - private T handleBindError(ConfigurationPropertyName name, Bindable target, BindHandler handler, Context context, Exception error) { try { @@ -477,15 +476,17 @@ private Object bindDataObject(ConfigurationPropertyName name, Bindable target } DataObjectPropertyBinder propertyBinder = (propertyName, propertyTarget) -> bind(name.append(propertyName), propertyTarget, handler, context, false, false); - return context.withDataObject(type, () -> { - for (DataObjectBinder dataObjectBinder : this.dataObjectBinders.get(bindMethod)) { - Object instance = dataObjectBinder.bind(name, target, context, propertyBinder); - if (instance != null) { - return instance; - } - } - return null; - }); + return context.withDataObject(type, () -> fromDataObjectBinders(bindMethod, + (dataObjectBinder) -> dataObjectBinder.bind(name, target, context, propertyBinder))); + } + + private Object fromDataObjectBinders(BindMethod bindMethod, Function operation) { + return this.dataObjectBinders.get(bindMethod) + .stream() + .map(operation) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } private boolean isUnbindableBean(ConfigurationPropertyName name, Bindable target, Context context) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java index 4bf63dd665d7..f57b1c4c4467 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectBinder.java @@ -33,11 +33,11 @@ interface DataObjectBinder { /** * Return a bound instance or {@code null} if the {@link DataObjectBinder} does not * support the specified {@link Bindable}. + * @param the source type * @param name the name being bound * @param target the bindable to bind * @param context the bind context * @param propertyBinder property binder - * @param the source type * @return a bound instance or {@code null} */ T bind(ConfigurationPropertyName name, Bindable target, Context context, @@ -46,11 +46,22 @@ T bind(ConfigurationPropertyName name, Bindable target, Context context, /** * Return a newly created instance or {@code null} if the {@link DataObjectBinder} * does not support the specified {@link Bindable}. + * @param the source type * @param target the bindable to create * @param context the bind context - * @param the source type * @return the created instance */ T create(Bindable target, Context context); + /** + * Callback that can be used to add additional suppressed exceptions when an instance + * cannot be created. + * @param the source type + * @param target the bindable that was being created + * @param context the bind context + * @param exception the exception about to be thrown + */ + default void onUnableToCreateInstance(Bindable target, Binder.Context context, RuntimeException exception) { + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DefaultBindConstructorProvider.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DefaultBindConstructorProvider.java index 54b454ebcafe..de23e928a2fa 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DefaultBindConstructorProvider.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DefaultBindConstructorProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,7 +127,7 @@ private static boolean isAutowiredPresent(Class type) { return true; } Class userClass = ClassUtils.getUserClass(type); - return (userClass != type) ? isAutowiredPresent(userClass) : false; + return (userClass != type) && isAutowiredPresent(userClass); } private static Constructor[] getCandidateConstructors(Class type) { @@ -135,7 +135,7 @@ private static Constructor[] getCandidateConstructors(Class type) { return new Constructor[0]; } return Arrays.stream(type.getDeclaredConstructors()) - .filter((constructor) -> isNonSynthetic(constructor, type)) + .filter(Constructors::isNonSynthetic) .toArray(Constructor[]::new); } @@ -148,7 +148,7 @@ private static boolean isInnerClass(Class type) { } } - private static boolean isNonSynthetic(Constructor constructor, Class type) { + private static boolean isNonSynthetic(Constructor constructor) { return !constructor.isSynthetic(); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java index 7a68330b7b28..aecac3e612db 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java @@ -19,6 +19,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Array; import java.lang.reflect.Constructor; +import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; import java.util.ArrayList; @@ -27,12 +28,16 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Consumer; import kotlin.reflect.KFunction; import kotlin.reflect.KParameter; import kotlin.reflect.jvm.ReflectJvmMapping; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.beans.BeanUtils; +import org.springframework.boot.context.properties.bind.Binder.Context; import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.core.CollectionFactory; import org.springframework.core.DefaultParameterNameDiscoverer; @@ -43,6 +48,7 @@ import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.convert.ConversionException; +import org.springframework.core.log.LogMessage; import org.springframework.util.Assert; /** @@ -55,6 +61,8 @@ */ class ValueObjectBinder implements DataObjectBinder { + private static final Log logger = LogFactory.getLog(ValueObjectBinder.class); + private final BindConstructorProvider constructorProvider; ValueObjectBinder(BindConstructorProvider constructorProvider) { @@ -64,7 +72,7 @@ class ValueObjectBinder implements DataObjectBinder { @Override public T bind(ConfigurationPropertyName name, Bindable target, Binder.Context context, DataObjectPropertyBinder propertyBinder) { - ValueObject valueObject = ValueObject.get(target, this.constructorProvider, context); + ValueObject valueObject = ValueObject.get(target, this.constructorProvider, context, Discoverer.LENIENT); if (valueObject == null) { return null; } @@ -85,7 +93,7 @@ public T bind(ConfigurationPropertyName name, Bindable target, Binder.Con @Override public T create(Bindable target, Binder.Context context) { - ValueObject valueObject = ValueObject.get(target, this.constructorProvider, context); + ValueObject valueObject = ValueObject.get(target, this.constructorProvider, context, Discoverer.LENIENT); if (valueObject == null) { return null; } @@ -97,6 +105,16 @@ public T create(Bindable target, Binder.Context context) { return valueObject.instantiate(args); } + @Override + public void onUnableToCreateInstance(Bindable target, Context context, RuntimeException exception) { + try { + ValueObject.get(target, this.constructorProvider, context, Discoverer.STRICT); + } + catch (Exception ex) { + exception.addSuppressed(ex); + } + } + private T getDefaultValue(Binder.Context context, ConstructorParameter parameter) { ResolvableType type = parameter.getType(); Annotation[] annotations = parameter.getAnnotations(); @@ -182,7 +200,7 @@ T instantiate(List args) { @SuppressWarnings("unchecked") static ValueObject get(Bindable bindable, BindConstructorProvider constructorProvider, - Binder.Context context) { + Binder.Context context, ParameterNameDiscoverer parameterNameDiscoverer) { Class type = (Class) bindable.getType().resolve(); if (type == null || type.isEnum() || Modifier.isAbstract(type.getModifiers())) { return null; @@ -193,9 +211,10 @@ static ValueObject get(Bindable bindable, BindConstructorProvider cons return null; } if (KotlinDetector.isKotlinType(type)) { - return KotlinValueObject.get((Constructor) bindConstructor, bindable.getType()); + return KotlinValueObject.get((Constructor) bindConstructor, bindable.getType(), + parameterNameDiscoverer); } - return DefaultValueObject.get(bindConstructor, bindable.getType()); + return DefaultValueObject.get(bindConstructor, bindable.getType(), parameterNameDiscoverer); } } @@ -241,12 +260,13 @@ List getConstructorParameters() { return this.constructorParameters; } - static ValueObject get(Constructor bindConstructor, ResolvableType type) { + static ValueObject get(Constructor bindConstructor, ResolvableType type, + ParameterNameDiscoverer parameterNameDiscoverer) { KFunction kotlinConstructor = ReflectJvmMapping.getKotlinFunction(bindConstructor); if (kotlinConstructor != null) { return new KotlinValueObject<>(bindConstructor, kotlinConstructor, type); } - return DefaultValueObject.get(bindConstructor, type); + return DefaultValueObject.get(bindConstructor, type, parameterNameDiscoverer); } } @@ -257,19 +277,31 @@ static ValueObject get(Constructor bindConstructor, ResolvableType typ */ private static final class DefaultValueObject extends ValueObject { - private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); - private final List constructorParameters; - private DefaultValueObject(Constructor constructor, ResolvableType type) { + private DefaultValueObject(Constructor constructor, List constructorParameters) { super(constructor); - this.constructorParameters = parseConstructorParameters(constructor, type); + this.constructorParameters = constructorParameters; + } + + @Override + List getConstructorParameters() { + return this.constructorParameters; + } + + @SuppressWarnings("unchecked") + static ValueObject get(Constructor bindConstructor, ResolvableType type, + ParameterNameDiscoverer parameterNameDiscoverer) { + String[] names = parameterNameDiscoverer.getParameterNames(bindConstructor); + if (names == null) { + return null; + } + List constructorParameters = parseConstructorParameters(bindConstructor, type, names); + return new DefaultValueObject<>((Constructor) bindConstructor, constructorParameters); } private static List parseConstructorParameters(Constructor constructor, - ResolvableType type) { - String[] names = PARAMETER_NAME_DISCOVERER.getParameterNames(constructor); - Assert.state(names != null, () -> "Failed to extract parameter names for " + constructor); + ResolvableType type, String[] names) { Parameter[] parameters = constructor.getParameters(); List result = new ArrayList<>(parameters.length); for (int i = 0; i < parameters.length; i++) { @@ -285,16 +317,6 @@ private static List parseConstructorParameters(Constructor return Collections.unmodifiableList(result); } - @Override - List getConstructorParameters() { - return this.constructorParameters; - } - - @SuppressWarnings("unchecked") - static ValueObject get(Constructor bindConstructor, ResolvableType type) { - return new DefaultValueObject<>((Constructor) bindConstructor, type); - } - } /** @@ -328,4 +350,49 @@ ResolvableType getType() { } + /** + * {@link ParameterNameDiscoverer} used for value data object binding. + */ + static final class Discoverer implements ParameterNameDiscoverer { + + private static final ParameterNameDiscoverer DEFAULT_DELEGATE = new DefaultParameterNameDiscoverer(); + + private static final ParameterNameDiscoverer LENIENT = new Discoverer(DEFAULT_DELEGATE, (message) -> { + }); + + private static final ParameterNameDiscoverer STRICT = new Discoverer(DEFAULT_DELEGATE, (message) -> { + throw new IllegalStateException(message.toString()); + }); + + private final ParameterNameDiscoverer delegate; + + private final Consumer noParameterNamesHandler; + + private Discoverer(ParameterNameDiscoverer delegate, Consumer noParameterNamesHandler) { + this.delegate = delegate; + this.noParameterNamesHandler = noParameterNamesHandler; + } + + @Override + public String[] getParameterNames(Method method) { + throw new UnsupportedOperationException(); + } + + @Override + public String[] getParameterNames(Constructor constructor) { + String[] names = this.delegate.getParameterNames(constructor); + if (names != null) { + return names; + } + LogMessage message = LogMessage.format( + "Unable to use value object binding with constructor [%s] as parameter names cannot be discovered. " + + "Ensure that the compiler uses the '-parameters' flag", + constructor); + this.noParameterNamesHandler.accept(message); + logger.debug(message); + return null; + } + + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/FailureAnalyzers.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/FailureAnalyzers.java index 7a905a8fcbe8..a0e1aa7eda07 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/FailureAnalyzers.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/FailureAnalyzers.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,16 +22,13 @@ import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.BeanFactory; -import org.springframework.beans.factory.BeanFactoryAware; import org.springframework.boot.SpringBootExceptionReporter; import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.EnvironmentAware; import org.springframework.core.env.Environment; import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.core.io.support.SpringFactoriesLoader.ArgumentResolver; import org.springframework.core.io.support.SpringFactoriesLoader.FailureHandler; import org.springframework.core.log.LogMessage; -import org.springframework.util.StringUtils; /** * Utility to trigger {@link FailureAnalyzer} and {@link FailureAnalysisReporter} @@ -66,34 +63,8 @@ public FailureAnalyzers(ConfigurableApplicationContext context) { private static List loadFailureAnalyzers(ConfigurableApplicationContext context, SpringFactoriesLoader springFactoriesLoader) { - List analyzers = springFactoriesLoader.load(FailureAnalyzer.class, - getArgumentResolver(context), FailureHandler.logging(logger)); - List awareAnalyzers = analyzers.stream() - .filter((analyzer) -> analyzer instanceof BeanFactoryAware || analyzer instanceof EnvironmentAware) - .toList(); - if (!awareAnalyzers.isEmpty()) { - String awareAnalyzerNames = StringUtils.collectionToCommaDelimitedString( - awareAnalyzers.stream().map((analyzer) -> analyzer.getClass().getName()).toList()); - logger.warn(LogMessage.format( - "FailureAnalyzers [%s] implement BeanFactoryAware or EnvironmentAware. " - + "Support for these interfaces on FailureAnalyzers is deprecated, " - + "and will be removed in a future release. " - + "Instead provide a constructor that accepts BeanFactory or Environment parameters.", - awareAnalyzerNames)); - if (context == null) { - logger.trace(LogMessage.format("Skipping [%s] due to missing context", awareAnalyzerNames)); - return analyzers.stream().filter((analyzer) -> !awareAnalyzers.contains(analyzer)).toList(); - } - awareAnalyzers.forEach((analyzer) -> { - if (analyzer instanceof BeanFactoryAware beanFactoryAware) { - beanFactoryAware.setBeanFactory(context.getBeanFactory()); - } - if (analyzer instanceof EnvironmentAware environmentAware) { - environmentAware.setEnvironment(context.getEnvironment()); - } - }); - } - return analyzers; + return springFactoriesLoader.load(FailureAnalyzer.class, getArgumentResolver(context), + FailureHandler.logging(logger)); } private static ArgumentResolver getArgumentResolver(ConfigurableApplicationContext context) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BeanDefinitionOverrideFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BeanDefinitionOverrideFailureAnalyzer.java index ca536fcc9a7b..439f9d23a5ed 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BeanDefinitionOverrideFailureAnalyzer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BeanDefinitionOverrideFailureAnalyzer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -46,11 +46,11 @@ private String getDescription(BeanDefinitionOverrideException ex) { if (ex.getBeanDefinition().getResourceDescription() != null) { printer.printf(", defined in %s,", ex.getBeanDefinition().getResourceDescription()); } - printer.printf(" could not be registered. A bean with that name has already been defined "); + printer.print(" could not be registered. A bean with that name has already been defined "); if (ex.getExistingDefinition().getResourceDescription() != null) { printer.printf("in %s ", ex.getExistingDefinition().getResourceDescription()); } - printer.printf("and overriding is disabled."); + printer.print("and overriding is disabled."); return description.toString(); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BindFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BindFailureAnalyzer.java index 2ee3fa5ae6b8..e5b5efe43619 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BindFailureAnalyzer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/BindFailureAnalyzer.java @@ -49,15 +49,20 @@ protected FailureAnalysis analyze(Throwable rootFailure, BindException cause) { || rootCause instanceof UnboundConfigurationPropertiesException) { return null; } - return analyzeGenericBindException(cause); + return analyzeGenericBindException(rootFailure, cause); } - private FailureAnalysis analyzeGenericBindException(BindException cause) { + private FailureAnalysis analyzeGenericBindException(Throwable rootFailure, BindException cause) { + FailureAnalysis missingParametersAnalysis = MissingParameterNamesFailureAnalyzer + .analyzeForMissingParameters(rootFailure); StringBuilder description = new StringBuilder(String.format("%s:%n", cause.getMessage())); ConfigurationProperty property = cause.getProperty(); buildDescription(description, property); description.append(String.format("%n Reason: %s", getMessage(cause))); - return getFailureAnalysis(description, cause); + if (missingParametersAnalysis != null) { + MissingParameterNamesFailureAnalyzer.appendPossibility(description); + } + return getFailureAnalysis(description.toString(), cause, missingParametersAnalysis); } private void buildDescription(StringBuilder description, ConfigurationProperty property) { @@ -98,14 +103,18 @@ private String getExceptionTypeAndMessage(Throwable ex) { return ex.getClass().getName() + (StringUtils.hasText(message) ? ": " + message : ""); } - private FailureAnalysis getFailureAnalysis(Object description, BindException cause) { - StringBuilder message = new StringBuilder("Update your application's configuration"); + private FailureAnalysis getFailureAnalysis(String description, BindException cause, + FailureAnalysis missingParametersAnalysis) { + StringBuilder action = new StringBuilder("Update your application's configuration"); Collection validValues = findValidValues(cause); if (!validValues.isEmpty()) { - message.append(String.format(". The following values are valid:%n")); - validValues.forEach((value) -> message.append(String.format("%n %s", value))); + action.append(String.format(". The following values are valid:%n")); + validValues.forEach((value) -> action.append(String.format("%n %s", value))); + } + if (missingParametersAnalysis != null) { + action.append(String.format("%n%n%s", missingParametersAnalysis.getAction())); } - return new FailureAnalysis(description.toString(), message.toString(), cause); + return new FailureAnalysis(description, action.toString(), cause); } private Collection findValidValues(BindException ex) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MissingParameterNamesFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MissingParameterNamesFailureAnalyzer.java new file mode 100644 index 000000000000..68a7aca5b628 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MissingParameterNamesFailureAnalyzer.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-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.boot.diagnostics.analyzer; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.boot.diagnostics.FailureAnalysis; +import org.springframework.boot.diagnostics.FailureAnalyzer; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.util.StringUtils; + +/** + * {@link FailureAnalyzer} for exceptions caused by missing parameter names. This analyzer + * is ordered last, if other analyzers wish to also report parameter actions they can use + * the {@link #analyzeForMissingParameters(Throwable)} static method. + * + * @author Phillip Webb + */ +@Order(Ordered.LOWEST_PRECEDENCE) +class MissingParameterNamesFailureAnalyzer implements FailureAnalyzer { + + private static final String USE_PARAMETERS_MESSAGE = "Ensure that the compiler uses the '-parameters' flag"; + + static final String POSSIBILITY = "This may be due to missing parameter name information"; + + static String ACTION = """ + Ensure that your compiler is configured to use the '-parameters' flag. + You may need to update both your build tool settings as well as your IDE. + (See https://github.com/spring-projects/spring-framework/wiki/Upgrading-to-Spring-Framework-6.x#parameter-name-retention) + """; + + @Override + public FailureAnalysis analyze(Throwable failure) { + return analyzeForMissingParameters(failure); + } + + /** + * Analyze the given failure for missing parameter name exceptions. + * @param failure the failure to analyze + * @return a failure analysis or {@code null} + */ + static FailureAnalysis analyzeForMissingParameters(Throwable failure) { + return analyzeForMissingParameters(failure, failure, new HashSet<>()); + } + + private static FailureAnalysis analyzeForMissingParameters(Throwable rootFailure, Throwable cause, + Set seen) { + if (cause != null && seen.add(cause)) { + if (isSpringParametersException(cause)) { + return getAnalysis(rootFailure, cause); + } + FailureAnalysis analysis = analyzeForMissingParameters(rootFailure, cause.getCause(), seen); + if (analysis != null) { + return analysis; + } + for (Throwable suppressed : cause.getSuppressed()) { + analysis = analyzeForMissingParameters(rootFailure, suppressed, seen); + if (analysis != null) { + return analysis; + } + } + } + return null; + } + + private static boolean isSpringParametersException(Throwable failure) { + String message = failure.getMessage(); + return message != null && message.contains(USE_PARAMETERS_MESSAGE) && isSpringException(failure); + } + + private static boolean isSpringException(Throwable failure) { + StackTraceElement[] elements = failure.getStackTrace(); + return elements.length > 0 && isSpringClass(elements[0].getClassName()); + } + + private static boolean isSpringClass(String className) { + return className != null && className.startsWith("org.springframework."); + } + + private static FailureAnalysis getAnalysis(Throwable rootFailure, Throwable cause) { + StringBuilder description = new StringBuilder(String.format("%s:%n", cause.getMessage())); + if (rootFailure != cause) { + description.append(String.format("%n Resulting Failure: %s", getExceptionTypeAndMessage(rootFailure))); + } + return new FailureAnalysis(description.toString(), ACTION, rootFailure); + } + + private static String getExceptionTypeAndMessage(Throwable ex) { + String message = ex.getMessage(); + return ex.getClass().getName() + (StringUtils.hasText(message) ? ": " + message : ""); + } + + static void appendPossibility(StringBuilder description) { + if (!description.toString().endsWith(System.lineSeparator())) { + description.append("%n".formatted()); + } + description.append("%n%s".formatted(POSSIBILITY)); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java index e3d95d3658f0..324c02083740 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java @@ -99,7 +99,7 @@ private void appendDetails(StringBuilder message, MutuallyExclusiveConfiguration configuredDescriptions.forEach(message::append); } - private Set sortedStrings(Collection input) { + private Set sortedStrings(Collection input) { return sortedStrings(input, Function.identity()); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/NoUniqueBeanDefinitionFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/NoUniqueBeanDefinitionFailureAnalyzer.java index 77c37fb0ad20..dee8f8eb9fd9 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/NoUniqueBeanDefinitionFailureAnalyzer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/NoUniqueBeanDefinitionFailureAnalyzer.java @@ -56,11 +56,12 @@ protected FailureAnalysis analyze(Throwable rootFailure, NoUniqueBeanDefinitionE for (String beanName : beanNames) { buildMessage(message, beanName); } - return new FailureAnalysis(message.toString(), - "Consider marking one of the beans as @Primary, updating the consumer to" - + " accept multiple beans, or using @Qualifier to identify the" - + " bean that should be consumed", - cause); + MissingParameterNamesFailureAnalyzer.appendPossibility(message); + StringBuilder action = new StringBuilder( + "Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, " + + "or using @Qualifier to identify the bean that should be consumed"); + action.append("%n%n%s".formatted(MissingParameterNamesFailureAnalyzer.ACTION)); + return new FailureAnalysis(message.toString(), action.toString(), cause); } private void buildMessage(StringBuilder message, String beanName) { @@ -69,7 +70,7 @@ private void buildMessage(StringBuilder message, String beanName) { message.append(getDefinitionDescription(beanName, definition)); } catch (NoSuchBeanDefinitionException ex) { - message.append(String.format("\t- %s: a programmatically registered singleton", beanName)); + message.append(String.format("\t- %s: a programmatically registered singleton%n", beanName)); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/ConfigTreePropertySource.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/ConfigTreePropertySource.java index 866404b46674..ae3737c547b3 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/ConfigTreePropertySource.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/ConfigTreePropertySource.java @@ -29,6 +29,8 @@ import java.util.Map; import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Stream; import org.springframework.boot.convert.ApplicationConversionService; @@ -258,6 +260,8 @@ private static final class PropertyFileContent implements Value, OriginProvider private final Path path; + private final Lock resourceLock = new ReentrantLock(); + private final Resource resource; private final Origin origin; @@ -341,11 +345,15 @@ private byte[] getBytes() { } if (this.content == null) { assertStillExists(); - synchronized (this.resource) { + this.resourceLock.lock(); + try { if (this.content == null) { this.content = FileCopyUtils.copyToByteArray(this.resource.getInputStream()); } } + finally { + this.resourceLock.unlock(); + } } return this.content; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/RandomValuePropertySource.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/RandomValuePropertySource.java index 7c2f23f63153..b01f16fb3d6f 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/RandomValuePropertySource.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/env/RandomValuePropertySource.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -16,6 +16,7 @@ package org.springframework.boot.env; +import java.util.HexFormat; import java.util.OptionalInt; import java.util.OptionalLong; import java.util.Random; @@ -31,7 +32,6 @@ import org.springframework.core.env.StandardEnvironment; import org.springframework.core.log.LogMessage; import org.springframework.util.Assert; -import org.springframework.util.DigestUtils; import org.springframework.util.StringUtils; /** @@ -58,6 +58,7 @@ * @author Dave Syer * @author Matt Benson * @author Madhura Bhave + * @author Moritz Halbritter * @since 1.0.0 */ public class RandomValuePropertySource extends PropertySource { @@ -136,9 +137,9 @@ private void assertPresent(boolean present, Range range) { } private Object getRandomBytes() { - byte[] bytes = new byte[32]; + byte[] bytes = new byte[16]; getSource().nextBytes(bytes); - return DigestUtils.md5DigestAsHex(bytes); + return HexFormat.of().withLowerCase().formatHex(bytes); } public static void addToEnvironment(ConfigurableEnvironment environment) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/ProcessInfo.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/ProcessInfo.java new file mode 100644 index 000000000000..96099d479e4b --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/ProcessInfo.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.info; + +/** + * Information about the process of the application. + * + * @author Jonatan Ivanov + * @since 3.3.0 + */ +public class ProcessInfo { + + private static final Runtime runtime = Runtime.getRuntime(); + + private final long pid; + + private final long parentPid; + + private final String owner; + + public ProcessInfo() { + ProcessHandle process = ProcessHandle.current(); + this.pid = process.pid(); + this.parentPid = process.parent().map(ProcessHandle::pid).orElse(-1L); + this.owner = process.info().user().orElse(null); + } + + /** + * Number of processors available to the process. This value may change between + * invocations especially in (containerized) environments where resource usage can be + * isolated (for example using control groups). + * @return result of {@link Runtime#availableProcessors()} + * @see Runtime#availableProcessors() + */ + public int getCpus() { + return runtime.availableProcessors(); + } + + public long getPid() { + return this.pid; + } + + public long getParentPid() { + return this.parentPid; + } + + public String getOwner() { + return this.owner; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java index 09ed12dd40b7..9364c73bf70a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModule.java @@ -16,14 +16,9 @@ package org.springframework.boot.jackson; -import java.util.Collection; - import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.module.SimpleModule; -import org.springframework.context.ApplicationContext; -import org.springframework.util.Assert; - /** * Spring Bean and Jackson {@link Module} to find and * {@link SimpleModule#setMixInAnnotation(Class, Class) register} @@ -36,22 +31,6 @@ */ public class JsonMixinModule extends SimpleModule { - public JsonMixinModule() { - } - - /** - * Create a new {@link JsonMixinModule} instance. - * @param context the source application context - * @param basePackages the packages to check for annotated classes - * @deprecated since 3.0.0 in favor of - * {@link #registerEntries(JsonMixinModuleEntries, ClassLoader)} - */ - @Deprecated(since = "3.0.0", forRemoval = true) - public JsonMixinModule(ApplicationContext context, Collection basePackages) { - Assert.notNull(context, "Context must not be null"); - registerEntries(JsonMixinModuleEntries.scan(context, basePackages), context.getClassLoader()); - } - /** * Register the specified {@link JsonMixinModuleEntries entries}. * @param entries the entries to register to this instance diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java index 980928d762ee..93749dd160a2 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jackson/JsonMixinModuleEntriesBeanRegistrationAotProcessor.java @@ -16,7 +16,6 @@ package org.springframework.boot.jackson; -import java.lang.reflect.Executable; import java.util.LinkedHashSet; import java.util.Set; @@ -33,6 +32,7 @@ import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments; import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator; import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.javapoet.ClassName; import org.springframework.javapoet.CodeBlock; /** @@ -54,6 +54,8 @@ public BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registe static class AotContribution extends BeanRegistrationCodeFragmentsDecorator { + private static final Class BEAN_TYPE = JsonMixinModuleEntries.class; + private final RegisteredBean registeredBean; private final ClassLoader classLoader; @@ -64,18 +66,21 @@ static class AotContribution extends BeanRegistrationCodeFragmentsDecorator { this.classLoader = registeredBean.getBeanFactory().getBeanClassLoader(); } + @Override + public ClassName getTarget(RegisteredBean registeredBean) { + return ClassName.get(BEAN_TYPE); + } + @Override public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext, - BeanRegistrationCode beanRegistrationCode, Executable constructorOrFactoryMethod, - boolean allowDirectSupplierShortcut) { + BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) { JsonMixinModuleEntries entries = this.registeredBean.getBeanFactory() .getBean(this.registeredBean.getBeanName(), JsonMixinModuleEntries.class); contributeHints(generationContext.getRuntimeHints(), entries); GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> { - Class beanType = JsonMixinModuleEntries.class; method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName()); method.addModifiers(Modifier.PRIVATE, Modifier.STATIC); - method.returns(beanType); + method.returns(BEAN_TYPE); CodeBlock.Builder code = CodeBlock.builder(); code.add("return $T.create(", JsonMixinModuleEntries.class).beginControlFlow("(mixins) ->"); entries.doWithEntry(this.classLoader, (type, mixin) -> addEntryCode(code, type, mixin)); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DataSourceBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DataSourceBuilder.java index 60cc940a8409..816a6a8a372f 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DataSourceBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DataSourceBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -644,8 +644,8 @@ private static class MappedDbcp2DataSource extends MappedDataSourceProperties hasOpenConnections; + + private final HikariDataSource dataSource; + + /** + * Creates a new {@code HikariCheckpointRestoreLifecycle} that will allow the given + * {@code dataSource} to participate in checkpoint-restore. The {@code dataSource} is + * {@link DataSourceUnwrapper#unwrap unwrapped} to a {@link HikariDataSource}. If such + * unwrapping is not possible, the lifecycle will have no effect. + * @param dataSource the checkpoint-restore participant + */ + public HikariCheckpointRestoreLifecycle(DataSource dataSource) { + this.dataSource = DataSourceUnwrapper.unwrap(dataSource, HikariConfigMXBean.class, HikariDataSource.class); + this.hasOpenConnections = (pool) -> { + ThreadPoolExecutor closeConnectionExecutor = (ThreadPoolExecutor) ReflectionUtils + .getField(CLOSE_CONNECTION_EXECUTOR, pool); + Assert.notNull(closeConnectionExecutor, "CloseConnectionExecutor was null"); + return closeConnectionExecutor.getActiveCount() > 0; + }; + } + + @Override + public void start() { + if (this.dataSource == null || this.dataSource.isRunning()) { + return; + } + Assert.state(!this.dataSource.isClosed(), "DataSource has been closed and cannot be restarted"); + if (this.dataSource.isAllowPoolSuspension()) { + logger.info("Resuming Hikari pool"); + this.dataSource.getHikariPoolMXBean().resumePool(); + } + } + + @Override + public void stop() { + if (this.dataSource == null || !this.dataSource.isRunning()) { + return; + } + if (this.dataSource.isAllowPoolSuspension()) { + logger.info("Suspending Hikari pool"); + this.dataSource.getHikariPoolMXBean().suspendPool(); + } + closeConnections(Duration.ofMillis(this.dataSource.getConnectionTimeout() + 250)); + } + + private void closeConnections(Duration shutdownTimeout) { + logger.info("Evicting Hikari connections"); + this.dataSource.getHikariPoolMXBean().softEvictConnections(); + logger.debug("Waiting for Hikari connections to be closed"); + CompletableFuture allConnectionsClosed = CompletableFuture.runAsync(this::waitForConnectionsToClose); + try { + allConnectionsClosed.get(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS); + logger.debug("Hikari connections closed"); + } + catch (InterruptedException ex) { + logger.warn("Interrupted while waiting for connections to be closed", ex); + Thread.currentThread().interrupt(); + } + catch (TimeoutException ex) { + logger.warn(LogMessage.format("Hikari connections could not be closed within %s", shutdownTimeout), ex); + } + catch (ExecutionException ex) { + throw new RuntimeException("Failed to close Hikari connections", ex); + } + } + + private void waitForConnectionsToClose() { + while (this.hasOpenConnections.apply((HikariPool) this.dataSource.getHikariPoolMXBean())) { + try { + TimeUnit.MILLISECONDS.sleep(50); + } + catch (InterruptedException ex) { + logger.error("Interrupted while waiting for datasource connections to be closed", ex); + Thread.currentThread().interrupt(); + } + } + } + + @Override + public boolean isRunning() { + return this.dataSource != null && this.dataSource.isRunning(); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/SpringJdbcDependsOnDatabaseInitializationDetector.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/SpringJdbcDependsOnDatabaseInitializationDetector.java index 13ed613745a6..dedfa0004070 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/SpringJdbcDependsOnDatabaseInitializationDetector.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/SpringJdbcDependsOnDatabaseInitializationDetector.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -16,14 +16,13 @@ package org.springframework.boot.jdbc; -import java.util.Arrays; -import java.util.HashSet; import java.util.Set; import org.springframework.boot.sql.init.dependency.AbstractBeansOfTypeDependsOnDatabaseInitializationDetector; import org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitializationDetector; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.jdbc.core.simple.JdbcClient; /** * {@link DependsOnDatabaseInitializationDetector} for Spring Framework's JDBC support. @@ -35,7 +34,7 @@ class SpringJdbcDependsOnDatabaseInitializationDetector @Override protected Set> getDependsOnDatabaseInitializationBeanTypes() { - return new HashSet<>(Arrays.asList(JdbcOperations.class, NamedParameterJdbcOperations.class)); + return Set.of(JdbcClient.class, JdbcOperations.class, NamedParameterJdbcOperations.class); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/AbstractLoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/AbstractLoggingSystem.java index 48be4c91f46a..1d22b9aee8c6 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/AbstractLoggingSystem.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/AbstractLoggingSystem.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -22,6 +22,7 @@ import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; +import java.util.function.Function; import org.springframework.core.env.Environment; import org.springframework.core.io.ClassPathResource; @@ -174,7 +175,35 @@ protected final String getPackagedConfigFile(String fileName) { } protected final void applySystemProperties(Environment environment, LogFile logFile) { - new LoggingSystemProperties(environment).apply(logFile); + new LoggingSystemProperties(environment, getDefaultValueResolver(environment), null).apply(logFile); + } + + /** + * Return the default value resolver to use when resolving system properties. + * @param environment the environment + * @return the default value resolver + * @since 3.2.0 + */ + protected Function getDefaultValueResolver(Environment environment) { + String defaultLogCorrelationPattern = getDefaultLogCorrelationPattern(); + return (name) -> { + if (StringUtils.hasLength(defaultLogCorrelationPattern) + && LoggingSystemProperty.CORRELATION_PATTERN.getApplicationPropertyName().equals(name) + && environment.getProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, Boolean.class, false)) { + return defaultLogCorrelationPattern; + } + return null; + }; + } + + /** + * Return the default log correlation pattern or {@code null} if log correlation + * patterns are not supported. + * @return the default log correlation pattern + * @since 3.2.0 + */ + protected String getDefaultLogCorrelationPattern() { + return null; } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/CorrelationIdFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/CorrelationIdFormatter.java new file mode 100644 index 000000000000..e701fba05cff --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/CorrelationIdFormatter.java @@ -0,0 +1,200 @@ +/* + * Copyright 2012-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.boot.logging; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * Utility class that can be used to format a correlation identifier for logging based on + * W3C + * recommendations. + *

+ * The formatter can be configured with a comma-separated list of names and the expected + * length of their resolved value. Each item should be specified in the form + * {@code "(length)"}. For example, {@code "traceId(32),spanId(16)"} specifies the + * names {@code "traceId"} and {@code "spanId"} with expected lengths of {@code 32} and + * {@code 16} respectively. + *

+ * Correlation IDs are formatted as dash separated strings surrounded in square brackets. + * Formatted output is always of a fixed width and with trailing space. Dashes are omitted + * if none of the named items can be resolved. + *

+ * The following example would return a formatted result of + * {@code "[01234567890123456789012345678901-0123456789012345] "}:

+ * CorrelationIdFormatter formatter = CorrelationIdFormatter.of("traceId(32),spanId(16)");
+ * Map<String, String> mdc = Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345");
+ * return formatter.format(mdc::get);
+ * 
+ *

+ * If {@link #of(String)} is called with an empty spec the {@link #DEFAULT} formatter will + * be used. + * + * @author Phillip Webb + * @since 3.2.0 + * @see #of(String) + * @see #of(Collection) + */ +public final class CorrelationIdFormatter { + + /** + * Default {@link CorrelationIdFormatter}. + */ + public static final CorrelationIdFormatter DEFAULT = CorrelationIdFormatter.of("traceId(32),spanId(16)"); + + private final List parts; + + private final String blank; + + private CorrelationIdFormatter(List parts) { + this.parts = parts; + this.blank = String.format("[%s] ", parts.stream().map(Part::blank).collect(Collectors.joining(" "))); + } + + /** + * Format a correlation from the values in the given resolver. + * @param resolver the resolver used to resolve named values + * @return a formatted correlation id + */ + public String format(UnaryOperator resolver) { + StringBuilder result = new StringBuilder(); + formatTo(resolver, result); + return result.toString(); + } + + /** + * Format a correlation from the values in the given resolver and append it to the + * given {@link Appendable}. + * @param resolver the resolver used to resolve named values + * @param appendable the appendable for the formatted correlation id + */ + public void formatTo(UnaryOperator resolver, Appendable appendable) { + Predicate canResolve = (part) -> StringUtils.hasLength(resolver.apply(part.name())); + try { + if (this.parts.stream().anyMatch(canResolve)) { + appendable.append('['); + for (Iterator iterator = this.parts.iterator(); iterator.hasNext();) { + appendable.append(iterator.next().resolve(resolver)); + if (iterator.hasNext()) { + appendable.append('-'); + } + } + appendable.append("] "); + } + else { + appendable.append(this.blank); + } + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + @Override + public String toString() { + return this.parts.stream().map(Part::toString).collect(Collectors.joining(",")); + } + + /** + * Create a new {@link CorrelationIdFormatter} instance from the given specification. + * @param spec a comma-separated specification + * @return a new {@link CorrelationIdFormatter} instance + */ + public static CorrelationIdFormatter of(String spec) { + try { + return (!StringUtils.hasText(spec)) ? DEFAULT : of(List.of(spec.split(","))); + } + catch (Exception ex) { + throw new IllegalStateException("Unable to parse correlation formatter spec '%s'".formatted(spec), ex); + } + } + + /** + * Create a new {@link CorrelationIdFormatter} instance from the given specification. + * @param spec a pre-separated specification + * @return a new {@link CorrelationIdFormatter} instance + */ + public static CorrelationIdFormatter of(String[] spec) { + return of((spec != null) ? List.of(spec) : Collections.emptyList()); + } + + /** + * Create a new {@link CorrelationIdFormatter} instance from the given specification. + * @param spec a pre-separated specification + * @return a new {@link CorrelationIdFormatter} instance + */ + public static CorrelationIdFormatter of(Collection spec) { + if (CollectionUtils.isEmpty(spec)) { + return DEFAULT; + } + List parts = spec.stream().map(Part::of).toList(); + return new CorrelationIdFormatter(parts); + } + + /** + * A part of the correlation id. + * + * @param name the name of the correlation part + * @param length the expected length of the correlation part + */ + record Part(String name, int length) { + + private static final Pattern pattern = Pattern.compile("^(.+?)\\((\\d+)\\)$"); + + String resolve(UnaryOperator resolver) { + String resolved = resolver.apply(name()); + if (resolved == null) { + return blank(); + } + int padding = length() - resolved.length(); + return (padding <= 0) ? resolved : resolved + " ".repeat(padding); + } + + String blank() { + return " ".repeat(this.length); + } + + @Override + public String toString() { + return "%s(%s)".formatted(name(), length()); + } + + static Part of(String part) { + Matcher matcher = pattern.matcher(part.trim()); + Assert.state(matcher.matches(), () -> "Invalid specification part '%s'".formatted(part)); + String name = matcher.group(1); + int length = Integer.parseInt(matcher.group(2)); + return new Part(name, length); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/DeferredLog.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/DeferredLog.java index cf5f7b4713f1..05026a362d62 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/DeferredLog.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/DeferredLog.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -35,7 +35,7 @@ */ public class DeferredLog implements Log { - private volatile Log destination; + private Log destination; private final Supplier destinationSupplier; @@ -175,7 +175,9 @@ private void log(LogLevel level, Object message, Throwable t) { } void switchOver() { - this.destination = this.destinationSupplier.get(); + synchronized (this.lines) { + this.destination = this.destinationSupplier.get(); + } } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LogFile.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LogFile.java index b973bc90019d..a1a201202e72 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LogFile.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LogFile.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -86,13 +86,13 @@ public void applyToSystemProperties() { * @param properties the properties to apply to */ public void applyTo(Properties properties) { - put(properties, LoggingSystemProperties.LOG_PATH, this.path); - put(properties, LoggingSystemProperties.LOG_FILE, toString()); + put(properties, LoggingSystemProperty.LOG_PATH, this.path); + put(properties, LoggingSystemProperty.LOG_FILE, toString()); } - private void put(Properties properties, String key, String value) { + private void put(Properties properties, LoggingSystemProperty property, String value) { if (StringUtils.hasLength(value)) { - properties.put(key, value); + properties.put(property.getEnvironmentVariableName(), value); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystem.java index d35b5ba4c64b..1fd3a398378d 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystem.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystem.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -23,6 +23,7 @@ import java.util.Set; import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -58,6 +59,13 @@ public abstract class LoggingSystem { private static final LoggingSystemFactory SYSTEM_FACTORY = LoggingSystemFactory.fromSpringFactories(); + /** + * The name of an {@link Environment} property used to indicate that a correlation ID + * is expected to be logged at some point. + * @since 3.2.0 + */ + public static final String EXPECT_CORRELATION_ID_PROPERTY = "logging.expect-correlation-id"; + /** * Return the {@link LoggingSystemProperties} that should be applied. * @param environment the {@link ConfigurableEnvironment} used to obtain value diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java index ff4c6c7eebe1..17b2e1a3aec9 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperties.java @@ -19,6 +19,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.function.BiConsumer; +import java.util.function.Function; import org.springframework.boot.system.ApplicationPid; import org.springframework.core.env.ConfigurableEnvironment; @@ -26,6 +27,7 @@ import org.springframework.core.env.PropertyResolver; import org.springframework.core.env.PropertySourcesPropertyResolver; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * Utility to set system properties that can later be used by log configuration files. @@ -36,69 +38,122 @@ * @author Vedran Pavic * @author Robert Thornton * @author Eddú Meléndez + * @author Jonatan Ivanov * @since 2.0.0 + * @see LoggingSystemProperty */ public class LoggingSystemProperties { /** * The name of the System property that contains the process ID. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#PID} */ - public static final String PID_KEY = "PID"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String PID_KEY = LoggingSystemProperty.PID.getEnvironmentVariableName(); /** * The name of the System property that contains the exception conversion word. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#EXCEPTION_CONVERSION_WORD} */ - public static final String EXCEPTION_CONVERSION_WORD = "LOG_EXCEPTION_CONVERSION_WORD"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String EXCEPTION_CONVERSION_WORD = LoggingSystemProperty.EXCEPTION_CONVERSION_WORD + .getEnvironmentVariableName(); /** * The name of the System property that contains the log file. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#LOG_FILE} */ - public static final String LOG_FILE = "LOG_FILE"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String LOG_FILE = LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName(); /** * The name of the System property that contains the log path. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#LOG_PATH} */ - public static final String LOG_PATH = "LOG_PATH"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String LOG_PATH = LoggingSystemProperty.LOG_PATH.getEnvironmentVariableName(); /** * The name of the System property that contains the console log pattern. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#CONSOLE_PATTERN} */ - public static final String CONSOLE_LOG_PATTERN = "CONSOLE_LOG_PATTERN"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String CONSOLE_LOG_PATTERN = LoggingSystemProperty.CONSOLE_PATTERN.getEnvironmentVariableName(); /** * The name of the System property that contains the console log charset. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#CONSOLE_CHARSET} */ - public static final String CONSOLE_LOG_CHARSET = "CONSOLE_LOG_CHARSET"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String CONSOLE_LOG_CHARSET = LoggingSystemProperty.CONSOLE_CHARSET.getEnvironmentVariableName(); /** * The log level threshold for console log. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#CONSOLE_THRESHOLD} */ - public static final String CONSOLE_LOG_THRESHOLD = "CONSOLE_LOG_THRESHOLD"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String CONSOLE_LOG_THRESHOLD = LoggingSystemProperty.CONSOLE_THRESHOLD + .getEnvironmentVariableName(); /** * The name of the System property that contains the file log pattern. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#FILE_PATTERN} */ - public static final String FILE_LOG_PATTERN = "FILE_LOG_PATTERN"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String FILE_LOG_PATTERN = LoggingSystemProperty.FILE_PATTERN.getEnvironmentVariableName(); /** * The name of the System property that contains the file log charset. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#FILE_CHARSET} */ - public static final String FILE_LOG_CHARSET = "FILE_LOG_CHARSET"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String FILE_LOG_CHARSET = LoggingSystemProperty.FILE_CHARSET.getEnvironmentVariableName(); /** * The log level threshold for file log. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#FILE_THRESHOLD} */ - public static final String FILE_LOG_THRESHOLD = "FILE_LOG_THRESHOLD"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String FILE_LOG_THRESHOLD = LoggingSystemProperty.FILE_THRESHOLD.getEnvironmentVariableName(); /** * The name of the System property that contains the log level pattern. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#LEVEL_PATTERN} */ - public static final String LOG_LEVEL_PATTERN = "LOG_LEVEL_PATTERN"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String LOG_LEVEL_PATTERN = LoggingSystemProperty.LEVEL_PATTERN.getEnvironmentVariableName(); /** * The name of the System property that contains the log date-format pattern. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link LoggingSystemProperty#getEnvironmentVariableName()} on + * {@link LoggingSystemProperty#DATEFORMAT_PATTERN} */ - public static final String LOG_DATEFORMAT_PATTERN = "LOG_DATEFORMAT_PATTERN"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String LOG_DATEFORMAT_PATTERN = LoggingSystemProperty.DATEFORMAT_PATTERN + .getEnvironmentVariableName(); private static final BiConsumer systemPropertySetter = (name, value) -> { if (System.getProperty(name) == null && value != null) { @@ -108,6 +163,8 @@ public class LoggingSystemProperties { private final Environment environment; + private final Function defaultValueResolver; + private final BiConsumer setter; /** @@ -115,20 +172,34 @@ public class LoggingSystemProperties { * @param environment the source environment */ public LoggingSystemProperties(Environment environment) { - this(environment, systemPropertySetter); + this(environment, null); } /** * Create a new {@link LoggingSystemProperties} instance. * @param environment the source environment - * @param setter setter used to apply the property + * @param setter setter used to apply the property or {@code null} for system + * properties * @since 2.4.2 */ public LoggingSystemProperties(Environment environment, BiConsumer setter) { + this(environment, null, setter); + } + + /** + * Create a new {@link LoggingSystemProperties} instance. + * @param environment the source environment + * @param defaultValueResolver function used to resolve default values or {@code null} + * @param setter setter used to apply the property or {@code null} for system + * properties + * @since 3.2.0 + */ + public LoggingSystemProperties(Environment environment, Function defaultValueResolver, + BiConsumer setter) { Assert.notNull(environment, "Environment must not be null"); - Assert.notNull(setter, "Setter must not be null"); this.environment = environment; - this.setter = setter; + this.defaultValueResolver = (defaultValueResolver != null) ? defaultValueResolver : (name) -> null; + this.setter = (setter != null) ? setter : systemPropertySetter; } protected Charset getDefaultCharset() { @@ -144,22 +215,6 @@ public final void apply(LogFile logFile) { apply(logFile, resolver); } - protected void apply(LogFile logFile, PropertyResolver resolver) { - setSystemProperty(resolver, EXCEPTION_CONVERSION_WORD, "logging.exception-conversion-word"); - setSystemProperty(PID_KEY, new ApplicationPid().toString()); - setSystemProperty(resolver, CONSOLE_LOG_PATTERN, "logging.pattern.console"); - setSystemProperty(resolver, CONSOLE_LOG_CHARSET, "logging.charset.console", getDefaultCharset().name()); - setSystemProperty(resolver, CONSOLE_LOG_THRESHOLD, "logging.threshold.console"); - setSystemProperty(resolver, LOG_DATEFORMAT_PATTERN, "logging.pattern.dateformat"); - setSystemProperty(resolver, FILE_LOG_PATTERN, "logging.pattern.file"); - setSystemProperty(resolver, FILE_LOG_CHARSET, "logging.charset.file", getDefaultCharset().name()); - setSystemProperty(resolver, FILE_LOG_THRESHOLD, "logging.threshold.file"); - setSystemProperty(resolver, LOG_LEVEL_PATTERN, "logging.pattern.level"); - if (logFile != null) { - logFile.applyToSystemProperties(); - } - } - private PropertyResolver getPropertyResolver() { if (this.environment instanceof ConfigurableEnvironment configurableEnvironment) { PropertySourcesPropertyResolver resolver = new PropertySourcesPropertyResolver( @@ -171,17 +226,85 @@ private PropertyResolver getPropertyResolver() { return this.environment; } + protected void apply(LogFile logFile, PropertyResolver resolver) { + String defaultCharsetName = getDefaultCharset().name(); + setApplicationNameSystemProperty(resolver); + setSystemProperty(LoggingSystemProperty.PID, new ApplicationPid().toString()); + setSystemProperty(LoggingSystemProperty.CONSOLE_CHARSET, resolver, defaultCharsetName); + setSystemProperty(LoggingSystemProperty.FILE_CHARSET, resolver, defaultCharsetName); + setSystemProperty(LoggingSystemProperty.CONSOLE_THRESHOLD, resolver); + setSystemProperty(LoggingSystemProperty.FILE_THRESHOLD, resolver); + setSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD, resolver); + setSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN, resolver); + setSystemProperty(LoggingSystemProperty.FILE_PATTERN, resolver); + setSystemProperty(LoggingSystemProperty.LEVEL_PATTERN, resolver); + setSystemProperty(LoggingSystemProperty.DATEFORMAT_PATTERN, resolver); + setSystemProperty(LoggingSystemProperty.CORRELATION_PATTERN, resolver); + if (logFile != null) { + logFile.applyToSystemProperties(); + } + } + + private void setApplicationNameSystemProperty(PropertyResolver resolver) { + if (resolver.getProperty("logging.include-application-name", Boolean.class, Boolean.TRUE)) { + String applicationName = resolver.getProperty("spring.application.name"); + if (StringUtils.hasText(applicationName)) { + setSystemProperty(LoggingSystemProperty.APPLICATION_NAME.getEnvironmentVariableName(), + "[%s] ".formatted(applicationName)); + } + } + } + + private void setSystemProperty(LoggingSystemProperty property, PropertyResolver resolver) { + setSystemProperty(property, resolver, null); + } + + private void setSystemProperty(LoggingSystemProperty property, PropertyResolver resolver, String defaultValue) { + String value = (property.getApplicationPropertyName() != null) + ? resolver.getProperty(property.getApplicationPropertyName()) : null; + value = (value != null) ? value : this.defaultValueResolver.apply(property.getApplicationPropertyName()); + value = (value != null) ? value : defaultValue; + setSystemProperty(property.getEnvironmentVariableName(), value); + } + + private void setSystemProperty(LoggingSystemProperty property, String value) { + setSystemProperty(property.getEnvironmentVariableName(), value); + } + + /** + * Set a system property. + * @param resolver the resolver used to get the property value + * @param systemPropertyName the system property name + * @param propertyName the application property name + * @deprecated since 3.2.0 for removal in 3.4.0 with no replacement + */ + @Deprecated(since = "3.2.0", forRemoval = true) protected final void setSystemProperty(PropertyResolver resolver, String systemPropertyName, String propertyName) { setSystemProperty(resolver, systemPropertyName, propertyName, null); } + /** + * Set a system property. + * @param resolver the resolver used to get the property value + * @param systemPropertyName the system property name + * @param propertyName the application property name + * @param defaultValue the default value if none can be resolved + * @deprecated since 3.2.0 for removal in 3.4.0 with no replacement + */ + @Deprecated(since = "3.2.0", forRemoval = true) protected final void setSystemProperty(PropertyResolver resolver, String systemPropertyName, String propertyName, String defaultValue) { String value = resolver.getProperty(propertyName); + value = (value != null) ? value : this.defaultValueResolver.apply(systemPropertyName); value = (value != null) ? value : defaultValue; setSystemProperty(systemPropertyName, value); } + /** + * Set a system property. + * @param name the property name + * @param value the value + */ protected final void setSystemProperty(String name, String value) { this.setter.accept(name, value); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java new file mode 100644 index 000000000000..489ebec89feb --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/LoggingSystemProperty.java @@ -0,0 +1,123 @@ +/* + * Copyright 2012-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.boot.logging; + +/** + * Logging system properties that can later be used by log configuration files. + * + * @author Phillip Webb + * @since 3.2.0 + * @see LoggingSystemProperties + */ +public enum LoggingSystemProperty { + + /** + * Logging system property for the application name that should be logged. + */ + APPLICATION_NAME("LOGGED_APPLICATION_NAME"), + + /** + * Logging system property for the process ID. + */ + PID("PID"), + + /** + * Logging system property for the log file. + */ + LOG_FILE("LOG_FILE"), + + /** + * Logging system property for the log path. + */ + LOG_PATH("LOG_PATH"), + + /** + * Logging system property for the console log charset. + */ + CONSOLE_CHARSET("CONSOLE_LOG_CHARSET", "logging.charset.console"), + + /** + * Logging system property for the file log charset. + */ + FILE_CHARSET("FILE_LOG_CHARSET", "logging.charset.file"), + + /** + * Logging system property for the console log. + */ + CONSOLE_THRESHOLD("CONSOLE_LOG_THRESHOLD", "logging.threshold.console"), + + /** + * Logging system property for the file log. + */ + FILE_THRESHOLD("FILE_LOG_THRESHOLD", "logging.threshold.file"), + + /** + * Logging system property for the exception conversion word. + */ + EXCEPTION_CONVERSION_WORD("LOG_EXCEPTION_CONVERSION_WORD", "logging.exception-conversion-word"), + + /** + * Logging system property for the console log pattern. + */ + CONSOLE_PATTERN("CONSOLE_LOG_PATTERN", "logging.pattern.console"), + + /** + * Logging system property for the file log pattern. + */ + FILE_PATTERN("FILE_LOG_PATTERN", "logging.pattern.file"), + + /** + * Logging system property for the log level pattern. + */ + LEVEL_PATTERN("LOG_LEVEL_PATTERN", "logging.pattern.level"), + + /** + * Logging system property for the date-format pattern. + */ + DATEFORMAT_PATTERN("LOG_DATEFORMAT_PATTERN", "logging.pattern.dateformat"), + + /** + * Logging system property for the correlation pattern. + */ + CORRELATION_PATTERN("LOG_CORRELATION_PATTERN", "logging.pattern.correlation"); + + private final String environmentVariableName; + + private final String applicationPropertyName; + + LoggingSystemProperty(String environmentVariableName) { + this(environmentVariableName, null); + } + + LoggingSystemProperty(String environmentVariableName, String applicationPropertyName) { + this.environmentVariableName = environmentVariableName; + this.applicationPropertyName = applicationPropertyName; + } + + /** + * Return the name of environment variable that can be used to access this property. + * @return the environment variable name + */ + public String getEnvironmentVariableName() { + return this.environmentVariableName; + } + + String getApplicationPropertyName() { + return this.applicationPropertyName; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/SimpleFormatter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/SimpleFormatter.java index f92e5eae0edb..fc9dbfbcaf29 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/SimpleFormatter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/java/SimpleFormatter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -22,7 +22,7 @@ import java.util.logging.Formatter; import java.util.logging.LogRecord; -import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; /** * Simple 'Java Logging' {@link Formatter}. @@ -36,19 +36,17 @@ public class SimpleFormatter extends Formatter { private final String format = getOrUseDefault("LOG_FORMAT", DEFAULT_FORMAT); - private final String pid = getOrUseDefault(LoggingSystemProperties.PID_KEY, "????"); - - private final Date date = new Date(); + private final String pid = getOrUseDefault(LoggingSystemProperty.PID.getEnvironmentVariableName(), "????"); @Override - public synchronized String format(LogRecord record) { - this.date.setTime(record.getMillis()); + public String format(LogRecord record) { + Date date = new Date(record.getMillis()); String source = record.getLoggerName(); String message = formatMessage(record); String throwable = getThrowable(record); String thread = getThreadName(); - return String.format(this.format, this.date, source, record.getLoggerName(), - record.getLevel().getLocalizedName(), message, throwable, thread, this.pid); + return String.format(this.format, date, source, record.getLoggerName(), record.getLevel().getLocalizedName(), + message, throwable, thread, this.pid); } private String getThrowable(LogRecord record) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/CorrelationIdConverter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/CorrelationIdConverter.java new file mode 100644 index 000000000000..5dcf9195da48 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/CorrelationIdConverter.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-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.boot.logging.log4j2; + +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.pattern.ConverterKeys; +import org.apache.logging.log4j.core.pattern.LogEventPatternConverter; +import org.apache.logging.log4j.core.pattern.MdcPatternConverter; +import org.apache.logging.log4j.core.pattern.PatternConverter; +import org.apache.logging.log4j.util.PerformanceSensitive; +import org.apache.logging.log4j.util.ReadOnlyStringMap; + +import org.springframework.boot.logging.CorrelationIdFormatter; +import org.springframework.util.ObjectUtils; + +/** + * Log4j2 {@link LogEventPatternConverter} to convert a {@link CorrelationIdFormatter} + * pattern into formatted output using data from the {@link LogEvent#getContextData() + * MDC}. + * + * @author Phillip Webb + * @since 3.2.0 + * @see MdcPatternConverter + */ +@Plugin(name = "CorrelationIdConverter", category = PatternConverter.CATEGORY) +@ConverterKeys("correlationId") +@PerformanceSensitive("allocation") +public final class CorrelationIdConverter extends LogEventPatternConverter { + + private final CorrelationIdFormatter formatter; + + private CorrelationIdConverter(CorrelationIdFormatter formatter) { + super("correlationId{%s}".formatted(formatter), "mdc"); + this.formatter = formatter; + } + + @Override + public void format(LogEvent event, StringBuilder toAppendTo) { + ReadOnlyStringMap contextData = event.getContextData(); + this.formatter.formatTo(contextData::getValue, toAppendTo); + } + + /** + * Factory method to create a new {@link CorrelationIdConverter}. + * @param options options, may be null or first element contains name of property to + * format. + * @return instance of PropertiesPatternConverter. + */ + public static CorrelationIdConverter newInstance(String[] options) { + String pattern = (!ObjectUtils.isEmpty(options)) ? options[0] : null; + return new CorrelationIdConverter(CorrelationIdFormatter.of(pattern)); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java index 1ff7c840ed07..10152f88c398 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystem.java @@ -249,27 +249,28 @@ public void initialize(LoggingInitializationContext initializationContext, Strin @Override protected void loadDefaults(LoggingInitializationContext initializationContext, LogFile logFile) { - if (logFile != null) { - loadConfiguration(getPackagedConfigFile("log4j2-file.xml"), logFile, getOverrides(initializationContext)); - } - else { - loadConfiguration(getPackagedConfigFile("log4j2.xml"), logFile, getOverrides(initializationContext)); - } - } - - private List getOverrides(LoggingInitializationContext initializationContext) { - BindResult> overrides = Binder.get(initializationContext.getEnvironment()) - .bind("logging.log4j2.config.override", Bindable.listOf(String.class)); - return overrides.orElse(Collections.emptyList()); + String location = getPackagedConfigFile((logFile != null) ? "log4j2-file.xml" : "log4j2.xml"); + load(initializationContext, location, logFile); } @Override protected void loadConfiguration(LoggingInitializationContext initializationContext, String location, LogFile logFile) { + load(initializationContext, location, logFile); + } + + private void load(LoggingInitializationContext initializationContext, String location, LogFile logFile) { + List overrides = getOverrides(initializationContext); if (initializationContext != null) { applySystemProperties(initializationContext.getEnvironment(), logFile); } - loadConfiguration(location, logFile, getOverrides(initializationContext)); + loadConfiguration(location, logFile, overrides); + } + + private List getOverrides(LoggingInitializationContext initializationContext) { + BindResult> overrides = Binder.get(initializationContext.getEnvironment()) + .bind("logging.log4j2.config.override", Bindable.listOf(String.class)); + return overrides.orElse(Collections.emptyList()); } /** @@ -492,6 +493,11 @@ private void markAsUninitialized(LoggerContext loggerContext) { loggerContext.setExternalContext(null); } + @Override + protected String getDefaultLogCorrelationPattern() { + return "%correlationId"; + } + /** * Get the Spring {@link Environment} attached to the given {@link LoggerContext} or * {@code null} if no environment is available. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/SpringProfileArbiter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/SpringProfileArbiter.java index 6aa7f5421af3..03e9e2494796 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/SpringProfileArbiter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/log4j2/SpringProfileArbiter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -53,7 +53,7 @@ private SpringProfileArbiter(Environment environment, String[] profiles) { @Override public boolean isCondition() { - return (this.environment != null) ? this.environment.acceptsProfiles(this.profiles) : false; + return (this.environment != null) && this.environment.acceptsProfiles(this.profiles); } @PluginBuilderFactory diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CorrelationIdConverter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CorrelationIdConverter.java new file mode 100644 index 000000000000..87b1e792b6c8 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/CorrelationIdConverter.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-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.boot.logging.logback; + +import java.util.Map; + +import ch.qos.logback.classic.pattern.MDCConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.pattern.DynamicConverter; + +import org.springframework.boot.logging.CorrelationIdFormatter; +import org.springframework.core.env.Environment; + +/** + * Logback {@link DynamicConverter} to convert a {@link CorrelationIdFormatter} pattern + * into formatted output using data from the {@link ILoggingEvent#getMDCPropertyMap() MDC} + * and {@link Environment}. + * + * @author Phillip Webb + * @since 3.2.0 + * @see MDCConverter + */ +public class CorrelationIdConverter extends DynamicConverter { + + private CorrelationIdFormatter formatter; + + @Override + public void start() { + this.formatter = CorrelationIdFormatter.of(getOptionList()); + super.start(); + } + + @Override + public void stop() { + this.formatter = null; + super.stop(); + } + + @Override + public String convert(ILoggingEvent event) { + if (this.formatter == null) { + return ""; + } + Map mdc = event.getMDCPropertyMap(); + return this.formatter.format(mdc::get); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java index 3e7b38003bde..dfce5601214b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/DefaultLogbackConfiguration.java @@ -43,6 +43,7 @@ * @author Vedran Pavic * @author Robert Thornton * @author Scott Frederick + * @author Jonatan Ivanov */ class DefaultLogbackConfiguration { @@ -68,12 +69,14 @@ void apply(LogbackConfigurator config) { private void defaults(LogbackConfigurator config) { config.conversionRule("clr", ColorConverter.class); + config.conversionRule("correlationId", CorrelationIdConverter.class); config.conversionRule("wex", WhitespaceThrowableProxyConverter.class); config.conversionRule("wEx", ExtendedWhitespaceThrowableProxyConverter.class); config.getContext() .putProperty("CONSOLE_LOG_PATTERN", resolve(config, "${CONSOLE_LOG_PATTERN:-" + "%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) " - + "%clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} " + + "%clr(${PID:- }){magenta} %clr(---){faint} %clr(${LOGGED_APPLICATION_NAME:-}[%15.15t]){faint} " + + "%clr(${LOG_CORRELATION_PATTERN:-}){faint}%clr(%-40.40logger{39}){cyan} " + "%clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}")); String defaultCharset = Charset.defaultCharset().name(); config.getContext() @@ -81,7 +84,8 @@ private void defaults(LogbackConfigurator config) { config.getContext().putProperty("CONSOLE_LOG_THRESHOLD", resolve(config, "${CONSOLE_LOG_THRESHOLD:-TRACE}")); config.getContext() .putProperty("FILE_LOG_PATTERN", resolve(config, "${FILE_LOG_PATTERN:-" - + "%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%t] " + + "%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- ${LOGGED_APPLICATION_NAME:-}[%t] " + + "${LOG_CORRELATION_PATTERN:-}" + "%-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}")); config.getContext() .putProperty("FILE_LOG_CHARSET", resolve(config, "${FILE_LOG_CHARSET:-" + defaultCharset + "}")); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java index f9b02130a5ae..009da77db768 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystem.java @@ -111,7 +111,7 @@ public LogbackLoggingSystem(ClassLoader classLoader) { @Override public LoggingSystemProperties getSystemProperties(ConfigurableEnvironment environment) { - return new LogbackLoggingSystemProperties(environment); + return new LogbackLoggingSystemProperties(environment, getDefaultValueResolver(environment), null); } @Override @@ -188,6 +188,7 @@ public void initialize(LoggingInitializationContext initializationContext, Strin if (!initializeFromAotGeneratedArtifactsIfPossible(initializationContext, logFile)) { super.initialize(initializationContext, configLocation, logFile); } + loggerContext.putObject(Environment.class.getName(), initializationContext.getEnvironment()); loggerContext.getTurboFilterList().remove(FILTER); markAsInitialized(loggerContext); if (StringUtils.hasText(System.getProperty(CONFIGURATION_FILE_PROPERTY))) { @@ -226,7 +227,8 @@ protected void loadDefaults(LoggingInitializationContext initializationContext, } Environment environment = initializationContext.getEnvironment(); // Apply system properties directly in case the same JVM runs multiple apps - new LogbackLoggingSystemProperties(environment, context::putProperty).apply(logFile); + new LogbackLoggingSystemProperties(environment, getDefaultValueResolver(environment), context::putProperty) + .apply(logFile); LogbackConfigurator configurator = debug ? new DebugLogbackConfigurator(context) : new LogbackConfigurator(context); new DefaultLogbackConfiguration(logFile).apply(configurator); @@ -410,7 +412,7 @@ private ILoggerFactory getLoggerFactory() { } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while waiting for non-subtitute logger factory", ex); + throw new IllegalStateException("Interrupted while waiting for non-substitute logger factory", ex); } factory = LoggerFactory.getILoggerFactory(); } @@ -443,6 +445,11 @@ private void markAsUninitialized(LoggerContext loggerContext) { loggerContext.removeObject(LoggingSystem.class.getName()); } + @Override + protected String getDefaultLogCorrelationPattern() { + return "%correlationId"; + } + @Override public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) { String key = BeanFactoryInitializationAotContribution.class.getName(); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystemProperties.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystemProperties.java index f9d97937d757..df821a473394 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystemProperties.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackLoggingSystemProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -18,6 +18,7 @@ import java.nio.charset.Charset; import java.util.function.BiConsumer; +import java.util.function.Function; import ch.qos.logback.core.util.FileSize; @@ -35,6 +36,7 @@ * * @author Phillip Webb * @since 2.4.0 + * @see RollingPolicySystemProperty */ public class LogbackLoggingSystemProperties extends LoggingSystemProperties { @@ -44,28 +46,53 @@ public class LogbackLoggingSystemProperties extends LoggingSystemProperties { /** * The name of the System property that contains the rolled-over log file name * pattern. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on + * {@link RollingPolicySystemProperty#FILE_NAME_PATTERN} */ - public static final String ROLLINGPOLICY_FILE_NAME_PATTERN = "LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String ROLLINGPOLICY_FILE_NAME_PATTERN = RollingPolicySystemProperty.FILE_NAME_PATTERN + .getEnvironmentVariableName(); /** * The name of the System property that contains the clean history on start flag. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on + * {@link RollingPolicySystemProperty#CLEAN_HISTORY_ON_START} */ - public static final String ROLLINGPOLICY_CLEAN_HISTORY_ON_START = "LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String ROLLINGPOLICY_CLEAN_HISTORY_ON_START = RollingPolicySystemProperty.CLEAN_HISTORY_ON_START + .getEnvironmentVariableName(); /** * The name of the System property that contains the file log max size. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on + * {@link RollingPolicySystemProperty#MAX_FILE_SIZE} */ - public static final String ROLLINGPOLICY_MAX_FILE_SIZE = "LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String ROLLINGPOLICY_MAX_FILE_SIZE = RollingPolicySystemProperty.MAX_FILE_SIZE + .getEnvironmentVariableName(); /** * The name of the System property that contains the file total size cap. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on + * {@link RollingPolicySystemProperty#TOTAL_SIZE_CAP} */ - public static final String ROLLINGPOLICY_TOTAL_SIZE_CAP = "LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String ROLLINGPOLICY_TOTAL_SIZE_CAP = RollingPolicySystemProperty.TOTAL_SIZE_CAP + .getEnvironmentVariableName(); /** * The name of the System property that contains the file log max history. + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of calling + * {@link RollingPolicySystemProperty#getEnvironmentVariableName()} on + * {@link RollingPolicySystemProperty#MAX_HISTORY} */ - public static final String ROLLINGPOLICY_MAX_HISTORY = "LOGBACK_ROLLINGPOLICY_MAX_HISTORY"; + @Deprecated(since = "3.2.0", forRemoval = true) + public static final String ROLLINGPOLICY_MAX_HISTORY = RollingPolicySystemProperty.MAX_HISTORY + .getEnvironmentVariableName(); public LogbackLoggingSystemProperties(Environment environment) { super(environment); @@ -81,6 +108,19 @@ public LogbackLoggingSystemProperties(Environment environment, BiConsumer defaultValueResolver, + BiConsumer setter) { + super(environment, defaultValueResolver, setter); + } + @Override protected Charset getDefaultCharset() { return Charset.defaultCharset(); @@ -100,32 +140,24 @@ private void applyJBossLoggingProperties() { } private void applyRollingPolicyProperties(PropertyResolver resolver) { - applyRollingPolicy(resolver, ROLLINGPOLICY_FILE_NAME_PATTERN, "logging.logback.rollingpolicy.file-name-pattern", - "logging.pattern.rolling-file-name"); - applyRollingPolicy(resolver, ROLLINGPOLICY_CLEAN_HISTORY_ON_START, - "logging.logback.rollingpolicy.clean-history-on-start", "logging.file.clean-history-on-start"); - applyRollingPolicy(resolver, ROLLINGPOLICY_MAX_FILE_SIZE, "logging.logback.rollingpolicy.max-file-size", - "logging.file.max-size", DataSize.class); - applyRollingPolicy(resolver, ROLLINGPOLICY_TOTAL_SIZE_CAP, "logging.logback.rollingpolicy.total-size-cap", - "logging.file.total-size-cap", DataSize.class); - applyRollingPolicy(resolver, ROLLINGPOLICY_MAX_HISTORY, "logging.logback.rollingpolicy.max-history", - "logging.file.max-history"); + applyRollingPolicy(RollingPolicySystemProperty.FILE_NAME_PATTERN, resolver); + applyRollingPolicy(RollingPolicySystemProperty.CLEAN_HISTORY_ON_START, resolver); + applyRollingPolicy(RollingPolicySystemProperty.MAX_FILE_SIZE, resolver, DataSize.class); + applyRollingPolicy(RollingPolicySystemProperty.TOTAL_SIZE_CAP, resolver, DataSize.class); + applyRollingPolicy(RollingPolicySystemProperty.MAX_HISTORY, resolver); } - private void applyRollingPolicy(PropertyResolver resolver, String systemPropertyName, String propertyName, - String deprecatedPropertyName) { - applyRollingPolicy(resolver, systemPropertyName, propertyName, deprecatedPropertyName, String.class); + private void applyRollingPolicy(RollingPolicySystemProperty property, PropertyResolver resolver) { + applyRollingPolicy(property, resolver, String.class); } - private void applyRollingPolicy(PropertyResolver resolver, String systemPropertyName, String propertyName, - String deprecatedPropertyName, Class type) { - T value = getProperty(resolver, propertyName, type); - if (value == null) { - value = getProperty(resolver, deprecatedPropertyName, type); - } + private void applyRollingPolicy(RollingPolicySystemProperty property, PropertyResolver resolver, + Class type) { + T value = getProperty(resolver, property.getApplicationPropertyName(), type); + value = (value != null) ? value : getProperty(resolver, property.getDeprecatedApplicationPropertyName(), type); if (value != null) { String stringValue = String.valueOf((value instanceof DataSize dataSize) ? dataSize.toBytes() : value); - setSystemProperty(systemPropertyName, stringValue); + setSystemProperty(property.getEnvironmentVariableName(), stringValue); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackRuntimeHints.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackRuntimeHints.java index 532a2adf78d4..44e50d50900d 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackRuntimeHints.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/LogbackRuntimeHints.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -59,7 +59,8 @@ private void registerHintsForBuiltInLogbackConverters(ReflectionHints reflection private void registerHintsForSpringBootConverters(ReflectionHints reflection) { registerForPublicConstructorInvocation(reflection, ColorConverter.class, - ExtendedWhitespaceThrowableProxyConverter.class, WhitespaceThrowableProxyConverter.class); + ExtendedWhitespaceThrowableProxyConverter.class, WhitespaceThrowableProxyConverter.class, + CorrelationIdConverter.class); } private void registerForPublicConstructorInvocation(ReflectionHints reflection, Class... classes) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/RollingPolicySystemProperty.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/RollingPolicySystemProperty.java new file mode 100644 index 000000000000..f75db8f2386f --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/logging/logback/RollingPolicySystemProperty.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-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.boot.logging.logback; + +/** + * Logback rolling policy system properties that can later be used by log configuration + * files. + * + * @author Phillip Webb + * @since 3.2.0 + * @see LogbackLoggingSystemProperties + */ +public enum RollingPolicySystemProperty { + + /** + * Logging system property for the rolled-over log file name pattern. + */ + FILE_NAME_PATTERN("file-name-pattern", "logging.pattern.rolling-file-name"), + + /** + * Logging system property for the clean history on start flag. + */ + CLEAN_HISTORY_ON_START("clean-history-on-start", "logging.file.clean-history-on-start"), + + /** + * Logging system property for the file log max size. + */ + MAX_FILE_SIZE("max-file-size", "logging.file.max-size"), + + /** + * Logging system property for the file total size cap. + */ + TOTAL_SIZE_CAP("total-size-cap", "logging.file.total-size-cap"), + + /** + * Logging system property for the file log max history. + */ + MAX_HISTORY("max-history", "logging.file.max-history"); + + private final String environmentVariableName; + + private final String applicationPropertyName; + + private final String deprecatedApplicationPropertyName; + + RollingPolicySystemProperty(String applicationPropertyName, String deprecatedApplicationPropertyName) { + this.environmentVariableName = "LOGBACK_ROLLINGPOLICY_" + name(); + this.applicationPropertyName = "logging.logback.rollingpolicy." + applicationPropertyName; + this.deprecatedApplicationPropertyName = deprecatedApplicationPropertyName; + } + + /** + * Return the name of environment variable that can be used to access this property. + * @return the environment variable name + */ + public String getEnvironmentVariableName() { + return this.environmentVariableName; + } + + String getApplicationPropertyName() { + return this.applicationPropertyName; + } + + String getDeprecatedApplicationPropertyName() { + return this.deprecatedApplicationPropertyName; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilder.java index 738cdbbb9eb9..d41a39b576f5 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilder.java @@ -17,6 +17,8 @@ package org.springframework.boot.r2dbc; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.function.Consumer; import java.util.function.Function; @@ -43,6 +45,7 @@ * @author Tadaya Tsuyukubo * @author Stephane Nicoll * @author Andy Wilkinson + * @author Moritz Halbritter * @since 2.5.0 */ public final class ConnectionFactoryBuilder { @@ -62,6 +65,8 @@ public final class ConnectionFactoryBuilder { private final Builder optionsBuilder; + private final List decorators = new ArrayList<>(); + private ConnectionFactoryBuilder(Builder optionsBuilder) { this.optionsBuilder = optionsBuilder; } @@ -168,13 +173,41 @@ public ConnectionFactoryBuilder database(String database) { return configure((options) -> options.option(ConnectionFactoryOptions.DATABASE, database)); } + /** + * Add a {@link ConnectionFactoryDecorator decorator}. + * @param decorator the decorator to add + * @return this for method chaining + * @since 3.2.0 + */ + public ConnectionFactoryBuilder decorator(ConnectionFactoryDecorator decorator) { + this.decorators.add(decorator); + return this; + } + + /** + * Add {@link ConnectionFactoryDecorator decorators}. + * @param decorators the decorators to add + * @return this for method chaining + * @since 3.2.0 + */ + public ConnectionFactoryBuilder decorators(Iterable decorators) { + for (ConnectionFactoryDecorator decorator : decorators) { + this.decorators.add(decorator); + } + return this; + } + /** * Build a {@link ConnectionFactory} based on the state of this builder. * @return a connection factory */ public ConnectionFactory build() { ConnectionFactoryOptions options = buildOptions(); - return optionsCapableWrapper.buildAndWrap(options); + ConnectionFactory connectionFactory = optionsCapableWrapper.buildAndWrap(options); + for (ConnectionFactoryDecorator decorator : this.decorators) { + connectionFactory = decorator.decorate(connectionFactory); + } + return connectionFactory; } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryDecorator.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryDecorator.java new file mode 100644 index 000000000000..f4885ec6c632 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/r2dbc/ConnectionFactoryDecorator.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-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.boot.r2dbc; + +import io.r2dbc.spi.ConnectionFactory; + +/** + * Decorator for {@link ConnectionFactory connection factories}. + * + * @author Moritz Halbritter + * @since 3.2.0 + * @see ConnectionFactoryBuilder + */ +@FunctionalInterface +public interface ConnectionFactoryDecorator { + + /** + * Decorates the given {@link ConnectionFactory}. + * @param delegate the connection factory which should be decorated + * @return the decorated connection factory + */ + ConnectionFactory decorate(ConnectionFactory delegate); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessor.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessor.java similarity index 58% rename from spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessor.java rename to spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessor.java index 6268e8b67c93..4b3e1c143fb7 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessor.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -18,31 +18,34 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.boot.system.JavaVersion; import org.springframework.core.Ordered; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.util.ClassUtils; /** - * {@link EnvironmentPostProcessor} to enable the Reactor Debug Agent if available. + * {@link EnvironmentPostProcessor} to enable the Reactor global features as early as + * possible in the startup process. *

- * The debug agent is enabled by default, unless the - * {@code "spring.reactor.debug-agent.enabled"} configuration property is set to false. We - * are using here an {@link EnvironmentPostProcessor} instead of an auto-configuration - * class to enable the agent as soon as possible during the startup process. + * If the "reactor-tools" dependency is available, the debug agent is enabled by default, + * unless the {@code "spring.reactor.debug-agent.enabled"} configuration property is set + * to false. + *

+ * If the {@code "spring.threads.virtual.enabled"} property is enabled and the current JVM + * is 21 or later, then the Reactor System property is set to configure the Bounded + * Elastic Scheduler to use Virtual Threads globally. * * @author Brian Clozel - * @since 2.2.0 + * @since 3.2.0 */ -public class DebugAgentEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { +public class ReactorEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { private static final String REACTOR_DEBUGAGENT_CLASS = "reactor.tools.agent.ReactorDebugAgent"; - private static final String DEBUGAGENT_ENABLED_CONFIG_KEY = "spring.reactor.debug-agent.enabled"; - @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { if (ClassUtils.isPresent(REACTOR_DEBUGAGENT_CLASS, null)) { - Boolean agentEnabled = environment.getProperty(DEBUGAGENT_ENABLED_CONFIG_KEY, Boolean.class); + Boolean agentEnabled = environment.getProperty("spring.reactor.debug-agent.enabled", Boolean.class); if (agentEnabled != Boolean.FALSE) { try { Class debugAgent = Class.forName(REACTOR_DEBUGAGENT_CLASS); @@ -53,6 +56,10 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp } } } + if (environment.getProperty("spring.threads.virtual.enabled", boolean.class, false) + && JavaVersion.getJavaVersion().isEqualOrNewerThan(JavaVersion.TWENTY_ONE)) { + System.setProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads", "true"); + } } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/context/RSocketPortInfoApplicationContextInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/context/RSocketPortInfoApplicationContextInitializer.java index da497d9ab1d6..dd94ba58204a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/context/RSocketPortInfoApplicationContextInitializer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/context/RSocketPortInfoApplicationContextInitializer.java @@ -55,6 +55,8 @@ private static class Listener implements ApplicationListener source = sources.get("server.ports"); + PropertySource source = sources.get(PROPERTY_SOURCE_NAME); if (source == null) { - source = new MapPropertySource("server.ports", new HashMap<>()); + source = new MapPropertySource(PROPERTY_SOURCE_NAME, new HashMap<>()); sources.addFirst(source); } setPortProperty(port, source); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServer.java index cb2df6779f62..6c21b070367a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -62,7 +62,7 @@ public InetSocketAddress address() { @Override public void start() throws RSocketServerException { this.channel = block(this.starter, this.lifecycleTimeout); - logger.info("Netty RSocket started on port(s): " + address().getPort()); + logger.info("Netty RSocket started on port " + address().getPort()); startDaemonAwaitThread(this.channel); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java index bd1d64283515..06840ac2ee40 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,8 @@ import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.web.embedded.netty.SslServerCustomizer; import org.springframework.boot.web.server.Ssl; -import org.springframework.boot.web.server.SslStoreProvider; import org.springframework.boot.web.server.WebServerSslBundle; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.util.Assert; import org.springframework.util.unit.DataSize; @@ -58,7 +57,6 @@ * @author Scott Frederick * @since 2.2.0 */ -@SuppressWarnings("removal") public class NettyRSocketServerFactory implements RSocketServerFactory, ConfigurableRSocketServerFactory { private int port = 9898; @@ -77,8 +75,6 @@ public class NettyRSocketServerFactory implements RSocketServerFactory, Configur private Ssl ssl; - private SslStoreProvider sslStoreProvider; - private SslBundles sslBundles; @Override @@ -106,11 +102,6 @@ public void setSsl(Ssl ssl) { this.ssl = ssl; } - @Override - public void setSslStoreProvider(SslStoreProvider sslStoreProvider) { - this.sslStoreProvider = sslStoreProvider; - } - @Override public void setSslBundles(SslBundles sslBundles) { this.sslBundles = sslBundles; @@ -204,9 +195,8 @@ private ServerTransport createTcpTransport() { return TcpServerTransport.create(tcpServer.bindAddress(this::getListenAddress)); } - @SuppressWarnings("deprecation") private SslBundle getSslBundle() { - return WebServerSslBundle.get(this.ssl, this.sslBundles, this.sslStoreProvider); + return WebServerSslBundle.get(this.ssl, this.sslBundles); } private InetSocketAddress getListenAddress() { @@ -219,12 +209,15 @@ private InetSocketAddress getListenAddress() { private static final class TcpSslServerCustomizer extends org.springframework.boot.web.embedded.netty.SslServerCustomizer { + private final SslBundle sslBundle; + private TcpSslServerCustomizer(Ssl.ClientAuth clientAuth, SslBundle sslBundle) { super(null, clientAuth, sslBundle); + this.sslBundle = sslBundle; } private TcpServer apply(TcpServer server) { - AbstractProtocolSslContextSpec sslContextSpec = createSslContextSpec(); + AbstractProtocolSslContextSpec sslContextSpec = createSslContextSpec(this.sslBundle); return server.secure((spec) -> spec.sslContext(sslContextSpec)); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/server/ConfigurableRSocketServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/server/ConfigurableRSocketServerFactory.java index eb48a9ef475d..671d4bea200d 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/server/ConfigurableRSocketServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/server/ConfigurableRSocketServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 org.springframework.boot.ssl.SslBundles; import org.springframework.boot.web.server.Ssl; -import org.springframework.boot.web.server.SslStoreProvider; import org.springframework.util.unit.DataSize; /** @@ -65,16 +64,6 @@ public interface ConfigurableRSocketServerFactory { */ void setSsl(Ssl ssl); - /** - * Sets a provider that will be used to obtain SSL stores. - * @param sslStoreProvider the SSL store provider - * @deprecated since 3.1.0 for removal in 3.3.0 in favor of - * {@link #setSslBundles(SslBundles)} - */ - @SuppressWarnings("removal") - @Deprecated(since = "3.1.0", forRemoval = true) - void setSslStoreProvider(SslStoreProvider sslStoreProvider); - /** * Sets an SSL bundle that can be used to get SSL configuration. * @param sslBundles the SSL bundles diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java index 59264b353ff2..88ea38ca5614 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java index fa79265755c2..8c999e5ccf4f 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java @@ -16,20 +16,31 @@ package org.springframework.boot.ssl; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; import org.springframework.util.Assert; /** * Default {@link SslBundleRegistry} implementation. * * @author Scott Frederick + * @author Moritz Halbritter + * @author Phillip Webb * @since 3.1.0 */ public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { - private final Map bundles = new ConcurrentHashMap<>(); + private static final Log logger = LogFactory.getLog(DefaultSslBundleRegistry.class); + + private final Map registeredBundles = new ConcurrentHashMap<>(); public DefaultSslBundleRegistry() { } @@ -42,18 +53,67 @@ public DefaultSslBundleRegistry(String name, SslBundle bundle) { public void registerBundle(String name, SslBundle bundle) { Assert.notNull(name, "Name must not be null"); Assert.notNull(bundle, "Bundle must not be null"); - SslBundle previous = this.bundles.putIfAbsent(name, bundle); + RegisteredSslBundle previous = this.registeredBundles.putIfAbsent(name, new RegisteredSslBundle(name, bundle)); Assert.state(previous == null, () -> "Cannot replace existing SSL bundle '%s'".formatted(name)); } + @Override + public void updateBundle(String name, SslBundle updatedBundle) { + getRegistered(name).update(updatedBundle); + } + @Override public SslBundle getBundle(String name) { + return getRegistered(name).getBundle(); + } + + @Override + public void addBundleUpdateHandler(String name, Consumer updateHandler) throws NoSuchSslBundleException { + getRegistered(name).addUpdateHandler(updateHandler); + } + + private RegisteredSslBundle getRegistered(String name) throws NoSuchSslBundleException { Assert.notNull(name, "Name must not be null"); - SslBundle bundle = this.bundles.get(name); - if (bundle == null) { + RegisteredSslBundle registered = this.registeredBundles.get(name); + if (registered == null) { throw new NoSuchSslBundleException(name, "SSL bundle name '%s' cannot be found".formatted(name)); } - return bundle; + return registered; + } + + private static class RegisteredSslBundle { + + private final String name; + + private final List> updateHandlers = new CopyOnWriteArrayList<>(); + + private volatile SslBundle bundle; + + RegisteredSslBundle(String name, SslBundle bundle) { + this.name = name; + this.bundle = bundle; + } + + void update(SslBundle updatedBundle) { + Assert.notNull(updatedBundle, "UpdatedBundle must not be null"); + this.bundle = updatedBundle; + if (this.updateHandlers.isEmpty()) { + logger.warn(LogMessage.format( + "SSL bundle '%s' has been updated but may be in use by a technology that doesn't support SSL reloading", + this.name)); + } + this.updateHandlers.forEach((handler) -> handler.accept(updatedBundle)); + } + + SslBundle getBundle() { + return this.bundle; + } + + void addUpdateHandler(Consumer updateHandler) { + Assert.notNull(updateHandler, "UpdateHandler must not be null"); + this.updateHandlers.add(updateHandler); + } + } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java index 990a481066be..e1c0a4c64179 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java @@ -20,6 +20,7 @@ * Interface that can be used to register an {@link SslBundle} for a given name. * * @author Scott Frederick + * @author Moritz Halbritter * @since 3.1.0 */ public interface SslBundleRegistry { @@ -31,4 +32,13 @@ public interface SslBundleRegistry { */ void registerBundle(String name, SslBundle bundle); + /** + * Updates an {@link SslBundle}. + * @param name the bundle name + * @param updatedBundle the updated bundle + * @throws NoSuchSslBundleException if the bundle cannot be found + * @since 3.2.0 + */ + void updateBundle(String name, SslBundle updatedBundle) throws NoSuchSslBundleException; + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java index ed8a0ea9cda4..21afc4346a61 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java @@ -16,20 +16,32 @@ package org.springframework.boot.ssl; +import java.util.function.Consumer; + /** * A managed set of {@link SslBundle} instances that can be retrieved by name. * * @author Scott Frederick + * @author Moritz Halbritter * @since 3.1.0 */ public interface SslBundles { /** * Return an {@link SslBundle} with the provided name. - * @param bundleName the bundle name + * @param name the bundle name * @return the bundle * @throws NoSuchSslBundleException if a bundle with the provided name does not exist */ - SslBundle getBundle(String bundleName) throws NoSuchSslBundleException; + SslBundle getBundle(String name) throws NoSuchSslBundleException; + + /** + * Add a handler that will be called each time the named bundle is updated. + * @param name the bundle name + * @param updateHandler the handler that should be called + * @throws NoSuchSslBundleException if a bundle with the provided name does not exist + * @since 3.2.0 + */ + void addBundleUpdateHandler(String name, Consumer updateHandler) throws NoSuchSslBundleException; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/LoadedPemSslStore.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/LoadedPemSslStore.java new file mode 100644 index 000000000000..5edacd360e61 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/LoadedPemSslStore.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-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.boot.ssl.pem; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.function.Supplier; + +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.function.SingletonSupplier; +import org.springframework.util.function.ThrowingSupplier; + +/** + * {@link PemSslStore} loaded from {@link PemSslStoreDetails}. + * + * @author Phillip Webb + * @see PemSslStore#load(PemSslStoreDetails) + */ +final class LoadedPemSslStore implements PemSslStore { + + private final PemSslStoreDetails details; + + private final Supplier> certificatesSupplier; + + private final Supplier privateKeySupplier; + + LoadedPemSslStore(PemSslStoreDetails details) { + Assert.notNull(details, "Details must not be null"); + this.details = details; + this.certificatesSupplier = supplier(() -> loadCertificates(details)); + this.privateKeySupplier = supplier(() -> loadPrivateKey(details)); + } + + private static Supplier supplier(ThrowingSupplier supplier) { + return SingletonSupplier.of(supplier.throwing(LoadedPemSslStore::asUncheckedIOException)); + } + + private static UncheckedIOException asUncheckedIOException(String message, Exception cause) { + return new UncheckedIOException(message, (IOException) cause); + } + + private static List loadCertificates(PemSslStoreDetails details) throws IOException { + PemContent pemContent = PemContent.load(details.certificates()); + if (pemContent == null) { + return null; + } + List certificates = pemContent.getCertificates(); + Assert.state(!CollectionUtils.isEmpty(certificates), "Loaded certificates are empty"); + return certificates; + } + + private static PrivateKey loadPrivateKey(PemSslStoreDetails details) throws IOException { + PemContent pemContent = PemContent.load(details.privateKey()); + return (pemContent != null) ? pemContent.getPrivateKey(details.privateKeyPassword()) : null; + } + + @Override + public String type() { + return this.details.type(); + } + + @Override + public String alias() { + return this.details.alias(); + } + + @Override + public String password() { + return this.details.password(); + } + + @Override + public List certificates() { + return this.certificatesSupplier.get(); + } + + @Override + public PrivateKey privateKey() { + return this.privateKeySupplier.get(); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java index 327dcc94a1ff..8e07b0740bc3 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java @@ -27,6 +27,9 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + /** * Parser for X.509 certificates in PEM format. * @@ -48,17 +51,18 @@ private PemCertificateParser() { /** * Parse certificates from the specified string. - * @param certificates the certificates to parse + * @param text the text to parse * @return the parsed certificates */ - static X509Certificate[] parse(String certificates) { - if (certificates == null) { + static List parse(String text) { + if (text == null) { return null; } CertificateFactory factory = getCertificateFactory(); List certs = new ArrayList<>(); - readCertificates(certificates, factory, certs::add); - return (!certs.isEmpty()) ? certs.toArray(X509Certificate[]::new) : null; + readCertificates(text, factory, certs::add); + Assert.state(!CollectionUtils.isEmpty(certs), "Missing certificates or unrecognized format"); + return List.copyOf(certs); } private static CertificateFactory getCertificateFactory() { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java index 317828575064..d3013bcb6f3a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java @@ -17,48 +17,155 @@ package org.springframework.boot.ssl.pem; import java.io.IOException; -import java.io.InputStreamReader; -import java.io.Reader; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Objects; import java.util.regex.Pattern; -import org.springframework.util.FileCopyUtils; +import org.springframework.util.Assert; import org.springframework.util.ResourceUtils; +import org.springframework.util.StreamUtils; /** - * Utility to load PEM content. + * PEM encoded content that can provide {@link X509Certificate certificates} and + * {@link PrivateKey private keys}. * * @author Scott Frederick * @author Phillip Webb + * @since 3.2.0 */ -final class PemContent { +public final class PemContent { private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); private static final Pattern PEM_FOOTER = Pattern.compile("-+END\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); - private PemContent() { + private final String text; + + private PemContent(String text) { + this.text = text; + } + + /** + * Parse and return all {@link X509Certificate certificates} from the PEM content. + * Most PEM files either contain a single certificate or a certificate chain. + * @return the certificates + * @throws IllegalStateException if no certificates could be loaded + */ + public List getCertificates() { + return PemCertificateParser.parse(this.text); + } + + /** + * Parse and return the {@link PrivateKey private keys} from the PEM content. + * @return the private keys + * @throws IllegalStateException if no private key could be loaded + */ + public PrivateKey getPrivateKey() { + return getPrivateKey(null); + } + + /** + * Parse and return the {@link PrivateKey private keys} from the PEM content or + * {@code null} if there is no private key. + * @param password the password to decrypt the private keys or {@code null} + * @return the private keys + */ + public PrivateKey getPrivateKey(String password) { + return PemPrivateKeyParser.parse(this.text, password); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(this.text, ((PemContent) obj).text); + } + + @Override + public int hashCode() { + return Objects.hash(this.text); } - static String load(String content) { - if (content == null || isPemContent(content)) { - return content; + @Override + public String toString() { + return this.text; + } + + /** + * Load {@link PemContent} from the given content (either the PEM content itself or a + * reference to the resource to load). + * @param content the content to load + * @return a new {@link PemContent} instance + * @throws IOException on IO error + */ + static PemContent load(String content) throws IOException { + if (content == null) { + return null; + } + if (isPresentInText(content)) { + return new PemContent(content); } try { - URL url = ResourceUtils.getURL(content); - try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { - return FileCopyUtils.copyToString(reader); - } + return load(ResourceUtils.getURL(content)); } - catch (IOException ex) { - throw new IllegalStateException( - "Error reading certificate or key from file '" + content + "':" + ex.getMessage(), ex); + catch (IOException | UncheckedIOException ex) { + throw new IOException("Error reading certificate or key from file '%s'".formatted(content), ex); } } - private static boolean isPemContent(String content) { - return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find(); + /** + * Load {@link PemContent} from the given {@link Path}. + * @param path a path to load the content from + * @return the loaded PEM content + * @throws IOException on IO error + */ + public static PemContent load(Path path) throws IOException { + Assert.notNull(path, "Path must not be null"); + try (InputStream in = Files.newInputStream(path, StandardOpenOption.READ)) { + return load(in); + } + } + + private static PemContent load(URL url) throws IOException { + Assert.notNull(url, "Url must not be null"); + try (InputStream in = url.openStream()) { + return load(in); + } + } + + private static PemContent load(InputStream in) throws IOException { + return of(StreamUtils.copyToString(in, StandardCharsets.UTF_8)); + } + + /** + * Return a new {@link PemContent} instance containing the given text. + * @param text the text containing PEM encoded content + * @return a new {@link PemContent} instance + */ + public static PemContent of(String text) { + return (text != null) ? new PemContent(text) : null; + } + + /** + * Return if PEM content is present in the given text. + * @param text the text to check + * @return if the text includes PEM encoded content. + */ + public static boolean isPresentInText(String text) { + return text != null && PEM_HEADER.matcher(text).find() && PEM_FOOTER.matcher(text).find(); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java index 0f67ff854c42..72e65d7c282b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java @@ -139,7 +139,7 @@ private static EncodedOid getEcParameters(DerElement parameters) { } Assert.state(parameters.isType(ValueType.ENCODED), "Key spec should contain encoded parameters"); DerElement contents = DerElement.of(parameters.getContents()); - Assert.state(contents.isType(ValueType.PRIMITIVE, TagType.OBJECT_IDENTIFIER), + Assert.state(contents != null && contents.isType(ValueType.PRIMITIVE, TagType.OBJECT_IDENTIFIER), "Key spec parameters should contain object identifier"); return EncodedOid.of(contents); } @@ -184,36 +184,36 @@ private static PKCS8EncodedKeySpec createKeySpecForPkcs8Encrypted(byte[] bytes, /** * Parse a private key from the specified string. - * @param key the private key to parse + * @param text the text to parse * @return the parsed private key */ - static PrivateKey parse(String key) { - return parse(key, null); + static PrivateKey parse(String text) { + return parse(text, null); } /** * Parse a private key from the specified string, using the provided password for * decryption if necessary. - * @param key the private key to parse + * @param text the text to parse * @param password the password used to decrypt an encrypted private key * @return the parsed private key */ - static PrivateKey parse(String key, String password) { - if (key == null) { + static PrivateKey parse(String text, String password) { + if (text == null) { return null; } try { for (PemParser pemParser : PEM_PARSERS) { - PrivateKey privateKey = pemParser.parse(key, password); + PrivateKey privateKey = pemParser.parse(text, password); if (privateKey != null) { return privateKey; } } - throw new IllegalStateException("Unrecognized private key format"); } catch (Exception ex) { throw new IllegalStateException("Error loading private key file: " + ex.getMessage(), ex); } + throw new IllegalStateException("Missing private key or unrecognized format"); } /** @@ -289,10 +289,6 @@ void octetString(byte[] bytes) throws IOException { codeLengthBytes(0x04, bytes); } - void sequence(int... elements) throws IOException { - sequence(bytes(elements)); - } - void sequence(byte[] bytes) throws IOException { codeLengthBytes(0x30, bytes); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStore.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStore.java new file mode 100644 index 000000000000..d58fc9a71bee --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStore.java @@ -0,0 +1,170 @@ +/* + * Copyright 2012-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.boot.ssl.pem; + +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * An individual trust or key store that has been loaded from PEM content. + * + * @author Phillip Webb + * @since 3.2.0 + * @see PemSslStoreDetails + * @see PemContent + */ +public interface PemSslStore { + + /** + * The key store type, for example {@code JKS} or {@code PKCS11}. A {@code null} value + * will use {@link KeyStore#getDefaultType()}). + * @return the key store type + */ + String type(); + + /** + * The alias used when setting entries in the {@link KeyStore}. + * @return the alias + */ + String alias(); + + /** + * The password used when + * {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[]) + * setting key entries} in the {@link KeyStore}. + * @return the password + */ + String password(); + + /** + * The certificates for this store. When a {@link #privateKey() private key} is + * present the returned value is treated as a certificate chain, otherwise it is + * treated a list of certificates that should all be registered. + * @return the X509 certificates + */ + List certificates(); + + /** + * The private key for this store or {@code null}. + * @return the private key + */ + PrivateKey privateKey(); + + /** + * Return a new {@link PemSslStore} instance with a new alias. + * @param alias the new alias + * @return a new {@link PemSslStore} instance + */ + default PemSslStore withAlias(String alias) { + return of(type(), alias, password(), certificates(), privateKey()); + } + + /** + * Return a new {@link PemSslStore} instance with a new password. + * @param password the new password + * @return a new {@link PemSslStore} instance + */ + default PemSslStore withPassword(String password) { + return of(type(), alias(), password, certificates(), privateKey()); + } + + /** + * Return a {@link PemSslStore} instance loaded using the given + * {@link PemSslStoreDetails}. + * @param details the PEM store details + * @return a loaded {@link PemSslStore} or {@code null}. + */ + static PemSslStore load(PemSslStoreDetails details) { + if (details == null || details.isEmpty()) { + return null; + } + return new LoadedPemSslStore(details); + } + + /** + * Factory method that can be used to create a new {@link PemSslStore} with the given + * values. + * @param type the key store type + * @param certificates the certificates for this store + * @param privateKey the private key + * @return a new {@link PemSslStore} instance + */ + static PemSslStore of(String type, List certificates, PrivateKey privateKey) { + return of(type, null, null, certificates, privateKey); + } + + /** + * Factory method that can be used to create a new {@link PemSslStore} with the given + * values. + * @param certificates the certificates for this store + * @param privateKey the private key + * @return a new {@link PemSslStore} instance + */ + static PemSslStore of(List certificates, PrivateKey privateKey) { + return of(null, null, null, certificates, privateKey); + } + + /** + * Factory method that can be used to create a new {@link PemSslStore} with the given + * values. + * @param type the key store type + * @param alias the alias used when setting entries in the {@link KeyStore} + * @param password the password used + * {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[]) + * setting key entries} in the {@link KeyStore} + * @param certificates the certificates for this store + * @param privateKey the private key + * @return a new {@link PemSslStore} instance + */ + static PemSslStore of(String type, String alias, String password, List certificates, + PrivateKey privateKey) { + Assert.notEmpty(certificates, "Certificates must not be empty"); + return new PemSslStore() { + + @Override + public String type() { + return type; + } + + @Override + public String alias() { + return alias; + } + + @Override + public String password() { + return password; + } + + @Override + public List certificates() { + return certificates; + } + + @Override + public PrivateKey privateKey() { + return privateKey; + } + + }; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java index da7117701107..f8f5eda84ef5 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java @@ -16,10 +16,14 @@ package org.springframework.boot.ssl.pem; +import java.io.IOException; import java.security.KeyStore; import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.List; import org.springframework.boot.ssl.SslStoreBundle; import org.springframework.core.style.ToStringCreator; @@ -36,7 +40,7 @@ */ public class PemSslStoreBundle implements SslStoreBundle { - private static final String DEFAULT_KEY_ALIAS = "ssl"; + private static final String DEFAULT_ALIAS = "ssl"; private final KeyStore keyStore; @@ -55,12 +59,30 @@ public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails * Create a new {@link PemSslStoreBundle} instance. * @param keyStoreDetails the key store details * @param trustStoreDetails the trust store details - * @param keyAlias the key alias to use or {@code null} to use a default alias + * @param alias the alias to use or {@code null} to use a default alias + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link PemSslStoreDetails#alias()} in the {@code keyStoreDetails} and + * {@code trustStoreDetails} */ - public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails, - String keyAlias) { - this.keyStore = createKeyStore("key", keyStoreDetails, keyAlias); - this.trustStore = createKeyStore("trust", trustStoreDetails, keyAlias); + @Deprecated(since = "3.2.0", forRemoval = true) + public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails, String alias) { + this.keyStore = createKeyStore("key", PemSslStore.load(keyStoreDetails), alias); + this.trustStore = createKeyStore("trust", PemSslStore.load(trustStoreDetails), alias); + } + + /** + * Create a new {@link PemSslStoreBundle} instance. + * @param pemKeyStore the PEM key store + * @param pemTrustStore the PEM trust store + * @since 3.2.0 + */ + public PemSslStoreBundle(PemSslStore pemKeyStore, PemSslStore pemTrustStore) { + this(pemKeyStore, pemTrustStore, null); + } + + private PemSslStoreBundle(PemSslStore pemKeyStore, PemSslStore pemTrustStore, String alias) { + this.keyStore = createKeyStore("key", pemKeyStore, alias); + this.trustStore = createKeyStore("trust", pemTrustStore, alias); } @Override @@ -78,20 +100,23 @@ public KeyStore getTrustStore() { return this.trustStore; } - private KeyStore createKeyStore(String name, PemSslStoreDetails details, String alias) { - if (details == null || details.isEmpty()) { + private static KeyStore createKeyStore(String name, PemSslStore pemSslStore, String alias) { + if (pemSslStore == null) { return null; } try { - Assert.notNull(details.certificate(), "Certificate content must not be null"); - String type = (!StringUtils.hasText(details.type())) ? KeyStore.getDefaultType() : details.type(); - KeyStore store = KeyStore.getInstance(type); - store.load(null); - String certificateContent = PemContent.load(details.certificate()); - String privateKeyContent = PemContent.load(details.privateKey()); - X509Certificate[] certificates = PemCertificateParser.parse(certificateContent); - PrivateKey privateKey = PemPrivateKeyParser.parse(privateKeyContent, details.privateKeyPassword()); - addCertificates(store, certificates, privateKey, (alias != null) ? alias : DEFAULT_KEY_ALIAS); + Assert.notEmpty(pemSslStore.certificates(), "Certificates must not be empty"); + alias = (pemSslStore.alias() != null) ? pemSslStore.alias() : alias; + alias = (alias != null) ? alias : DEFAULT_ALIAS; + KeyStore store = createKeyStore(pemSslStore.type()); + List certificates = pemSslStore.certificates(); + PrivateKey privateKey = pemSslStore.privateKey(); + if (privateKey != null) { + addPrivateKey(store, privateKey, alias, pemSslStore.password(), certificates); + } + else { + addCertificates(store, certificates, alias); + } return store; } catch (Exception ex) { @@ -99,15 +124,25 @@ private KeyStore createKeyStore(String name, PemSslStoreDetails details, String } } - private void addCertificates(KeyStore keyStore, X509Certificate[] certificates, PrivateKey privateKey, String alias) + private static KeyStore createKeyStore(String type) + throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { + KeyStore store = KeyStore.getInstance(StringUtils.hasText(type) ? type : KeyStore.getDefaultType()); + store.load(null); + return store; + } + + private static void addPrivateKey(KeyStore keyStore, PrivateKey privateKey, String alias, String keyPassword, + List certificateChain) throws KeyStoreException { + keyStore.setKeyEntry(alias, privateKey, (keyPassword != null) ? keyPassword.toCharArray() : null, + certificateChain.toArray(X509Certificate[]::new)); + } + + private static void addCertificates(KeyStore keyStore, List certificates, String alias) throws KeyStoreException { - if (privateKey != null) { - keyStore.setKeyEntry(alias, privateKey, null, certificates); - } - else { - for (int index = 0; index < certificates.length; index++) { - keyStore.setCertificateEntry(alias + "-" + index, certificates[index]); - } + for (int index = 0; index < certificates.size(); index++) { + String entryAlias = alias + ((certificates.size() == 1) ? "" : "-" + index); + X509Certificate certificate = certificates.get(index); + keyStore.setCertificateEntry(entryAlias, certificate); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java index 81d68eb69594..2f7dfff29c13 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java @@ -18,7 +18,6 @@ import java.security.KeyStore; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; /** @@ -26,41 +25,124 @@ * * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A * {@code null} value will use {@link KeyStore#getDefaultType()}). - * @param certificate the certificate content (either the PEM content itself or something - * that can be loaded by {@link ResourceUtils#getURL}) - * @param privateKey the private key content (either the PEM content itself or something - * that can be loaded by {@link ResourceUtils#getURL}) + * @param alias the alias used when setting entries in the {@link KeyStore} + * @param password the password used + * {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[]) + * setting key entries} in the {@link KeyStore} + * @param certificates the certificates content (either the PEM content itself or or a + * reference to the resource to load). When a {@link #privateKey() private key} is present + * this value is treated as a certificate chain, otherwise it is treated a list of + * certificates that should all be registered. + * @param privateKey the private key content (either the PEM content itself or a reference + * to the resource to load) * @param privateKeyPassword a password used to decrypt an encrypted private key * @author Scott Frederick * @author Phillip Webb * @since 3.1.0 + * @see PemSslStore#load(PemSslStoreDetails) */ -public record PemSslStoreDetails(String type, String certificate, String privateKey, String privateKeyPassword) { +public record PemSslStoreDetails(String type, String alias, String password, String certificates, String privateKey, + String privateKeyPassword) { + /** + * Create a new {@link PemSslStoreDetails} instance. + * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A + * {@code null} value will use {@link KeyStore#getDefaultType()}). + * @param alias the alias used when setting entries in the {@link KeyStore} + * @param password the password used + * {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[]) + * setting key entries} in the {@link KeyStore} + * @param certificates the certificate content (either the PEM content itself or a + * reference to the resource to load) + * @param privateKey the private key content (either the PEM content itself or a + * reference to the resource to load) + * @param privateKeyPassword a password used to decrypt an encrypted private key + * @since 3.2.0 + */ + public PemSslStoreDetails { + } + + /** + * Create a new {@link PemSslStoreDetails} instance. + * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A + * {@code null} value will use {@link KeyStore#getDefaultType()}). + * @param certificate the certificate content (either the PEM content itself or a + * reference to the resource to load) + * @param privateKey the private key content (either the PEM content itself or a + * reference to the resource to load) + * @param privateKeyPassword a password used to decrypt an encrypted private key + */ + public PemSslStoreDetails(String type, String certificate, String privateKey, String privateKeyPassword) { + this(type, null, null, certificate, privateKey, privateKeyPassword); + } + + /** + * Create a new {@link PemSslStoreDetails} instance. + * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A + * {@code null} value will use {@link KeyStore#getDefaultType()}). + * @param certificate the certificate content (either the PEM content itself or a + * reference to the resource to load) + * @param privateKey the private key content (either the PEM content itself or a + * reference to the resource to load) + */ public PemSslStoreDetails(String type, String certificate, String privateKey) { this(type, certificate, privateKey, null); } + /** + * Return the certificate content. + * @return the certificate content + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of {@link #certificates()} + */ + @Deprecated(since = "3.2.0", forRemoval = true) + public String certificate() { + return certificates(); + } + + /** + * Return a new {@link PemSslStoreDetails} instance with a new alias. + * @param alias the new alias + * @return a new {@link PemSslStoreDetails} instance + * @since 3.2.0 + */ + public PemSslStoreDetails withAlias(String alias) { + return new PemSslStoreDetails(this.type, alias, this.password, this.certificates, this.privateKey, + this.privateKeyPassword); + } + + /** + * Return a new {@link PemSslStoreDetails} instance with a new password. + * @param password the new password + * @return a new {@link PemSslStoreDetails} instance + * @since 3.2.0 + */ + public PemSslStoreDetails withPassword(String password) { + return new PemSslStoreDetails(this.type, this.alias, password, this.certificates, this.privateKey, + this.privateKeyPassword); + } + /** * Return a new {@link PemSslStoreDetails} instance with a new private key. * @param privateKey the new private key * @return a new {@link PemSslStoreDetails} instance */ public PemSslStoreDetails withPrivateKey(String privateKey) { - return new PemSslStoreDetails(this.type, this.certificate, privateKey, this.privateKeyPassword); + return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificates, privateKey, + this.privateKeyPassword); } /** * Return a new {@link PemSslStoreDetails} instance with a new private key password. - * @param password the new private key password + * @param privateKeyPassword the new private key password * @return a new {@link PemSslStoreDetails} instance */ - public PemSslStoreDetails withPrivateKeyPassword(String password) { - return new PemSslStoreDetails(this.type, this.certificate, this.privateKey, password); + public PemSslStoreDetails withPrivateKeyPassword(String privateKeyPassword) { + return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificates, this.privateKey, + privateKeyPassword); } boolean isEmpty() { - return isEmpty(this.type) && isEmpty(this.certificate) && isEmpty(this.privateKey); + return isEmpty(this.type) && isEmpty(this.certificates) && isEmpty(this.privateKey); } private boolean isEmpty(String value) { @@ -69,12 +151,27 @@ private boolean isEmpty(String value) { /** * Factory method to create a new {@link PemSslStoreDetails} instance for the given - * certificate. - * @param certificate the certificate + * certificate. Note: This method doesn't actually check if the provided value + * only contains a single certificate. It is functionally equivalent to + * {@link #forCertificates(String)}. + * @param certificate the certificate content (either the PEM content itself or a + * reference to the resource to load) * @return a new {@link PemSslStoreDetails} instance. */ public static PemSslStoreDetails forCertificate(String certificate) { - return new PemSslStoreDetails(null, certificate, null); + return forCertificates(certificate); + } + + /** + * Factory method to create a new {@link PemSslStoreDetails} instance for the given + * certificates. + * @param certificates the certificates content (either the PEM content itself or a + * reference to the resource to load) + * @return a new {@link PemSslStoreDetails} instance. + * @since 3.2.0 + */ + public static PemSslStoreDetails forCertificates(String certificates) { + return new PemSslStoreDetails(null, certificates, null); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/ApplicationTemp.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/ApplicationTemp.java index 9c413a0ab702..2ab9f745f574 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/ApplicationTemp.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/system/ApplicationTemp.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -28,6 +28,8 @@ import java.security.MessageDigest; import java.util.EnumSet; import java.util.HexFormat; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -49,6 +51,8 @@ public class ApplicationTemp { private final Class sourceClass; + private final Lock pathLock = new ReentrantLock(); + private volatile Path path; /** @@ -90,9 +94,15 @@ public File getDir(String subDir) { private Path getPath() { if (this.path == null) { - synchronized (this) { - String hash = HexFormat.of().withUpperCase().formatHex(generateHash(this.sourceClass)); - this.path = createDirectory(getTempDirectory().resolve(hash)); + this.pathLock.lock(); + try { + if (this.path == null) { + String hash = HexFormat.of().withUpperCase().formatHex(generateHash(this.sourceClass)); + this.path = createDirectory(getTempDirectory().resolve(hash)); + } + } + finally { + this.pathLock.unlock(); } } return this.path; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilder.java new file mode 100644 index 000000000000..c6e70bcf7e55 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilder.java @@ -0,0 +1,235 @@ +/* + * Copyright 2012-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.boot.task; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.beans.BeanUtils; +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskDecorator; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Builder that can be used to configure and create a {@link SimpleAsyncTaskExecutor}. + * Provides convenience methods to set common {@link SimpleAsyncTaskExecutor} settings and + * register {@link #taskDecorator(TaskDecorator)}). For advanced configuration, consider + * using {@link SimpleAsyncTaskExecutorCustomizer}. + *

+ * In a typical auto-configured Spring Boot application this builder is available as a + * bean and can be injected whenever a {@link SimpleAsyncTaskExecutor} is needed. + * + * @author Stephane Nicoll + * @author Filip Hrisafov + * @author Moritz Halbritter + * @since 3.2.0 + */ +public class SimpleAsyncTaskExecutorBuilder { + + private final Boolean virtualThreads; + + private final String threadNamePrefix; + + private final Integer concurrencyLimit; + + private final TaskDecorator taskDecorator; + + private final Set customizers; + + private final Duration taskTerminationTimeout; + + public SimpleAsyncTaskExecutorBuilder() { + this(null, null, null, null, null, null); + } + + private SimpleAsyncTaskExecutorBuilder(Boolean virtualThreads, String threadNamePrefix, Integer concurrencyLimit, + TaskDecorator taskDecorator, Set customizers, + Duration taskTerminationTimeout) { + this.virtualThreads = virtualThreads; + this.threadNamePrefix = threadNamePrefix; + this.concurrencyLimit = concurrencyLimit; + this.taskDecorator = taskDecorator; + this.customizers = customizers; + this.taskTerminationTimeout = taskTerminationTimeout; + } + + /** + * Set the prefix to use for the names of newly created threads. + * @param threadNamePrefix the thread name prefix to set + * @return a new builder instance + */ + public SimpleAsyncTaskExecutorBuilder threadNamePrefix(String threadNamePrefix) { + return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, threadNamePrefix, this.concurrencyLimit, + this.taskDecorator, this.customizers, this.taskTerminationTimeout); + } + + /** + * Set whether to use virtual threads. + * @param virtualThreads whether to use virtual threads + * @return a new builder instance + */ + public SimpleAsyncTaskExecutorBuilder virtualThreads(Boolean virtualThreads) { + return new SimpleAsyncTaskExecutorBuilder(virtualThreads, this.threadNamePrefix, this.concurrencyLimit, + this.taskDecorator, this.customizers, this.taskTerminationTimeout); + } + + /** + * Set the concurrency limit. + * @param concurrencyLimit the concurrency limit + * @return a new builder instance + */ + public SimpleAsyncTaskExecutorBuilder concurrencyLimit(Integer concurrencyLimit) { + return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, concurrencyLimit, + this.taskDecorator, this.customizers, this.taskTerminationTimeout); + } + + /** + * Set the {@link TaskDecorator} to use or {@code null} to not use any. + * @param taskDecorator the task decorator to use + * @return a new builder instance + */ + public SimpleAsyncTaskExecutorBuilder taskDecorator(TaskDecorator taskDecorator) { + return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, this.concurrencyLimit, + taskDecorator, this.customizers, this.taskTerminationTimeout); + } + + /** + * Set the task termination timeout. + * @param taskTerminationTimeout the task termination timeout + * @return a new builder instance + * @since 3.2.1 + */ + public SimpleAsyncTaskExecutorBuilder taskTerminationTimeout(Duration taskTerminationTimeout) { + return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, this.concurrencyLimit, + this.taskDecorator, this.customizers, taskTerminationTimeout); + } + + /** + * Set the {@link SimpleAsyncTaskExecutorCustomizer customizers} that should be + * applied to the {@link SimpleAsyncTaskExecutor}. Customizers are applied in the + * order that they were added after builder configuration has been applied. Setting + * this value will replace any previously configured customizers. + * @param customizers the customizers to set + * @return a new builder instance + * @see #additionalCustomizers(SimpleAsyncTaskExecutorCustomizer...) + */ + public SimpleAsyncTaskExecutorBuilder customizers(SimpleAsyncTaskExecutorCustomizer... customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return customizers(Arrays.asList(customizers)); + } + + /** + * Set the {@link SimpleAsyncTaskExecutorCustomizer customizers} that should be + * applied to the {@link SimpleAsyncTaskExecutor}. Customizers are applied in the + * order that they were added after builder configuration has been applied. Setting + * this value will replace any previously configured customizers. + * @param customizers the customizers to set + * @return a new builder instance + * @see #additionalCustomizers(Iterable) + */ + public SimpleAsyncTaskExecutorBuilder customizers( + Iterable customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, this.concurrencyLimit, + this.taskDecorator, append(null, customizers), this.taskTerminationTimeout); + } + + /** + * Add {@link SimpleAsyncTaskExecutorCustomizer customizers} that should be applied to + * the {@link SimpleAsyncTaskExecutor}. Customizers are applied in the order that they + * were added after builder configuration has been applied. + * @param customizers the customizers to add + * @return a new builder instance + * @see #customizers(SimpleAsyncTaskExecutorCustomizer...) + */ + public SimpleAsyncTaskExecutorBuilder additionalCustomizers(SimpleAsyncTaskExecutorCustomizer... customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return additionalCustomizers(Arrays.asList(customizers)); + } + + /** + * Add {@link SimpleAsyncTaskExecutorCustomizer customizers} that should be applied to + * the {@link SimpleAsyncTaskExecutor}. Customizers are applied in the order that they + * were added after builder configuration has been applied. + * @param customizers the customizers to add + * @return a new builder instance + * @see #customizers(Iterable) + */ + public SimpleAsyncTaskExecutorBuilder additionalCustomizers( + Iterable customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return new SimpleAsyncTaskExecutorBuilder(this.virtualThreads, this.threadNamePrefix, this.concurrencyLimit, + this.taskDecorator, append(this.customizers, customizers), this.taskTerminationTimeout); + } + + /** + * Build a new {@link SimpleAsyncTaskExecutor} instance and configure it using this + * builder. + * @return a configured {@link SimpleAsyncTaskExecutor} instance. + * @see #build(Class) + * @see #configure(SimpleAsyncTaskExecutor) + */ + public SimpleAsyncTaskExecutor build() { + return configure(new SimpleAsyncTaskExecutor()); + } + + /** + * Build a new {@link SimpleAsyncTaskExecutor} instance of the specified type and + * configure it using this builder. + * @param the type of task executor + * @param taskExecutorClass the template type to create + * @return a configured {@link SimpleAsyncTaskExecutor} instance. + * @see #build() + * @see #configure(SimpleAsyncTaskExecutor) + */ + public T build(Class taskExecutorClass) { + return configure(BeanUtils.instantiateClass(taskExecutorClass)); + } + + /** + * Configure the provided {@link SimpleAsyncTaskExecutor} instance using this builder. + * @param the type of task executor + * @param taskExecutor the {@link SimpleAsyncTaskExecutor} to configure + * @return the task executor instance + * @see #build() + * @see #build(Class) + */ + public T configure(T taskExecutor) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.virtualThreads).to(taskExecutor::setVirtualThreads); + map.from(this.threadNamePrefix).whenHasText().to(taskExecutor::setThreadNamePrefix); + map.from(this.concurrencyLimit).to(taskExecutor::setConcurrencyLimit); + map.from(this.taskDecorator).to(taskExecutor::setTaskDecorator); + map.from(this.taskTerminationTimeout).as(Duration::toMillis).to(taskExecutor::setTaskTerminationTimeout); + if (!CollectionUtils.isEmpty(this.customizers)) { + this.customizers.forEach((customizer) -> customizer.customize(taskExecutor)); + } + return taskExecutor; + } + + private Set append(Set set, Iterable additions) { + Set result = new LinkedHashSet<>((set != null) ? set : Collections.emptySet()); + additions.forEach(result::add); + return Collections.unmodifiableSet(result); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorCustomizer.java new file mode 100644 index 000000000000..0f4218ecb4bd --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskExecutorCustomizer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-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.boot.task; + +import org.springframework.core.task.SimpleAsyncTaskExecutor; + +/** + * Callback interface that can be used to customize a {@link SimpleAsyncTaskExecutor}. + * + * @author Stephane Nicoll + * @author Moritz Halbritter + * @since 3.2.0 + * @see SimpleAsyncTaskExecutorBuilder + */ +@FunctionalInterface +public interface SimpleAsyncTaskExecutorCustomizer { + + /** + * Callback to customize a {@link SimpleAsyncTaskExecutor} instance. + * @param taskExecutor the task executor to customize + */ + void customize(SimpleAsyncTaskExecutor taskExecutor); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilder.java new file mode 100644 index 000000000000..4e2f4069bd8c --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilder.java @@ -0,0 +1,202 @@ +/* + * Copyright 2012-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.boot.task; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Builder that can be used to configure and create a {@link SimpleAsyncTaskScheduler}. + * Provides convenience methods to set common {@link SimpleAsyncTaskScheduler} settings. + * For advanced configuration, consider using {@link SimpleAsyncTaskSchedulerCustomizer}. + *

+ * In a typical auto-configured Spring Boot application this builder is available as a + * bean and can be injected whenever a {@link SimpleAsyncTaskScheduler} is needed. + * + * @author Stephane Nicoll + * @author Moritz Halbritter + * @since 3.2.0 + */ +public class SimpleAsyncTaskSchedulerBuilder { + + private final String threadNamePrefix; + + private final Integer concurrencyLimit; + + private final Boolean virtualThreads; + + private final Set customizers; + + private final Duration taskTerminationTimeout; + + public SimpleAsyncTaskSchedulerBuilder() { + this(null, null, null, null, null); + } + + private SimpleAsyncTaskSchedulerBuilder(String threadNamePrefix, Integer concurrencyLimit, Boolean virtualThreads, + Set taskSchedulerCustomizers, Duration taskTerminationTimeout) { + this.threadNamePrefix = threadNamePrefix; + this.concurrencyLimit = concurrencyLimit; + this.virtualThreads = virtualThreads; + this.customizers = taskSchedulerCustomizers; + this.taskTerminationTimeout = taskTerminationTimeout; + } + + /** + * Set the prefix to use for the names of newly created threads. + * @param threadNamePrefix the thread name prefix to set + * @return a new builder instance + */ + public SimpleAsyncTaskSchedulerBuilder threadNamePrefix(String threadNamePrefix) { + return new SimpleAsyncTaskSchedulerBuilder(threadNamePrefix, this.concurrencyLimit, this.virtualThreads, + this.customizers, this.taskTerminationTimeout); + } + + /** + * Set the concurrency limit. + * @param concurrencyLimit the concurrency limit + * @return a new builder instance + */ + public SimpleAsyncTaskSchedulerBuilder concurrencyLimit(Integer concurrencyLimit) { + return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, concurrencyLimit, this.virtualThreads, + this.customizers, this.taskTerminationTimeout); + } + + /** + * Set whether to use virtual threads. + * @param virtualThreads whether to use virtual threads + * @return a new builder instance + */ + public SimpleAsyncTaskSchedulerBuilder virtualThreads(Boolean virtualThreads) { + return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, this.concurrencyLimit, virtualThreads, + this.customizers, this.taskTerminationTimeout); + } + + /** + * Set the task termination timeout. + * @param taskTerminationTimeout the task termination timeout + * @return a new builder instance + * @since 3.2.1 + */ + public SimpleAsyncTaskSchedulerBuilder taskTerminationTimeout(Duration taskTerminationTimeout) { + return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, this.concurrencyLimit, this.virtualThreads, + this.customizers, taskTerminationTimeout); + } + + /** + * Set the {@link SimpleAsyncTaskSchedulerCustomizer customizers} that should be + * applied to the {@link SimpleAsyncTaskScheduler}. Customizers are applied in the + * order that they were added after builder configuration has been applied. Setting + * this value will replace any previously configured customizers. + * @param customizers the customizers to set + * @return a new builder instance + * @see #additionalCustomizers(SimpleAsyncTaskSchedulerCustomizer...) + */ + public SimpleAsyncTaskSchedulerBuilder customizers(SimpleAsyncTaskSchedulerCustomizer... customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return customizers(Arrays.asList(customizers)); + } + + /** + * Set the {@link SimpleAsyncTaskSchedulerCustomizer customizers} that should be + * applied to the {@link SimpleAsyncTaskScheduler}. Customizers are applied in the + * order that they were added after builder configuration has been applied. Setting + * this value will replace any previously configured customizers. + * @param customizers the customizers to set + * @return a new builder instance + * @see #additionalCustomizers(Iterable) + */ + public SimpleAsyncTaskSchedulerBuilder customizers( + Iterable customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, this.concurrencyLimit, this.virtualThreads, + append(null, customizers), this.taskTerminationTimeout); + } + + /** + * Add {@link SimpleAsyncTaskSchedulerCustomizer customizers} that should be applied + * to the {@link SimpleAsyncTaskScheduler}. Customizers are applied in the order that + * they were added after builder configuration has been applied. + * @param customizers the customizers to add + * @return a new builder instance + * @see #customizers(SimpleAsyncTaskSchedulerCustomizer...) + */ + public SimpleAsyncTaskSchedulerBuilder additionalCustomizers(SimpleAsyncTaskSchedulerCustomizer... customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return additionalCustomizers(Arrays.asList(customizers)); + } + + /** + * Add {@link SimpleAsyncTaskSchedulerCustomizer customizers} that should be applied + * to the {@link SimpleAsyncTaskScheduler}. Customizers are applied in the order that + * they were added after builder configuration has been applied. + * @param customizers the customizers to add + * @return a new builder instance + * @see #customizers(Iterable) + */ + public SimpleAsyncTaskSchedulerBuilder additionalCustomizers( + Iterable customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return new SimpleAsyncTaskSchedulerBuilder(this.threadNamePrefix, this.concurrencyLimit, this.virtualThreads, + append(this.customizers, customizers), this.taskTerminationTimeout); + } + + /** + * Build a new {@link SimpleAsyncTaskScheduler} instance and configure it using this + * builder. + * @return a configured {@link SimpleAsyncTaskScheduler} instance. + * @see #configure(SimpleAsyncTaskScheduler) + */ + public SimpleAsyncTaskScheduler build() { + return configure(new SimpleAsyncTaskScheduler()); + } + + /** + * Configure the provided {@link SimpleAsyncTaskScheduler} instance using this + * builder. + * @param the type of task scheduler + * @param taskScheduler the {@link SimpleAsyncTaskScheduler} to configure + * @return the task scheduler instance + * @see #build() + */ + public T configure(T taskScheduler) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.threadNamePrefix).to(taskScheduler::setThreadNamePrefix); + map.from(this.concurrencyLimit).to(taskScheduler::setConcurrencyLimit); + map.from(this.virtualThreads).to(taskScheduler::setVirtualThreads); + map.from(this.taskTerminationTimeout).as(Duration::toMillis).to(taskScheduler::setTaskTerminationTimeout); + if (!CollectionUtils.isEmpty(this.customizers)) { + this.customizers.forEach((customizer) -> customizer.customize(taskScheduler)); + } + return taskScheduler; + } + + private Set append(Set set, Iterable additions) { + Set result = new LinkedHashSet<>((set != null) ? set : Collections.emptySet()); + additions.forEach(result::add); + return Collections.unmodifiableSet(result); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerCustomizer.java new file mode 100644 index 000000000000..e66c627327d4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerCustomizer.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-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.boot.task; + +import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler; + +/** + * Callback interface that can be used to customize a {@link SimpleAsyncTaskScheduler}. + * + * @author Moritz Halbritter + * @since 3.2.0 + */ +@FunctionalInterface +public interface SimpleAsyncTaskSchedulerCustomizer { + + /** + * Callback to customize a {@link SimpleAsyncTaskScheduler} instance. + * @param taskScheduler the task scheduler to customize + */ + void customize(SimpleAsyncTaskScheduler taskScheduler); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorBuilder.java index 34b45a83176b..304b9ce9320c 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -42,7 +42,11 @@ * @author Stephane Nicoll * @author Filip Hrisafov * @since 2.1.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link ThreadPoolTaskExecutorBuilder} */ +@Deprecated(since = "3.2.0", forRemoval = true) +@SuppressWarnings("removal") public class TaskExecutorBuilder { private final Integer queueCapacity; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorCustomizer.java index 4ceed9047b02..0ff969caaba7 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorCustomizer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskExecutorCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -24,8 +24,11 @@ * @author Stephane Nicoll * @since 2.1.0 * @see TaskExecutorBuilder + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link ThreadPoolTaskExecutorCustomizer} */ @FunctionalInterface +@Deprecated(since = "3.2.0", forRemoval = true) public interface TaskExecutorCustomizer { /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerBuilder.java index 9ec2c3e6aeef..65c385d0c5a3 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -38,7 +38,11 @@ * * @author Stephane Nicoll * @since 2.1.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link ThreadPoolTaskSchedulerBuilder} */ +@Deprecated(since = "3.2.0", forRemoval = true) +@SuppressWarnings("removal") public class TaskSchedulerBuilder { private final Integer poolSize; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerCustomizer.java index 7c5252c68669..8acf391a42a8 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerCustomizer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/TaskSchedulerCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -23,8 +23,11 @@ * * @author Stephane Nicoll * @since 2.1.0 + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link ThreadPoolTaskSchedulerCustomizer} */ @FunctionalInterface +@Deprecated(since = "3.2.0", forRemoval = true) public interface TaskSchedulerCustomizer { /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilder.java new file mode 100644 index 000000000000..fb90c8ad5898 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilder.java @@ -0,0 +1,347 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.task; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.beans.BeanUtils; +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Builder that can be used to configure and create a {@link ThreadPoolTaskExecutor}. + * Provides convenience methods to set common {@link ThreadPoolTaskExecutor} settings and + * register {@link #taskDecorator(TaskDecorator)}). For advanced configuration, consider + * using {@link ThreadPoolTaskExecutorCustomizer}. + *

+ * In a typical auto-configured Spring Boot application this builder is available as a + * bean and can be injected whenever a {@link ThreadPoolTaskExecutor} is needed. + * + * @author Stephane Nicoll + * @author Filip Hrisafov + * @author Yanming Zhou + * @since 3.2.0 + */ +public class ThreadPoolTaskExecutorBuilder { + + private final Integer queueCapacity; + + private final Integer corePoolSize; + + private final Integer maxPoolSize; + + private final Boolean allowCoreThreadTimeOut; + + private final Duration keepAlive; + + private final Boolean acceptTasksAfterContextClose; + + private final Boolean awaitTermination; + + private final Duration awaitTerminationPeriod; + + private final String threadNamePrefix; + + private final TaskDecorator taskDecorator; + + private final Set customizers; + + public ThreadPoolTaskExecutorBuilder() { + this.queueCapacity = null; + this.corePoolSize = null; + this.maxPoolSize = null; + this.allowCoreThreadTimeOut = null; + this.keepAlive = null; + this.acceptTasksAfterContextClose = null; + this.awaitTermination = null; + this.awaitTerminationPeriod = null; + this.threadNamePrefix = null; + this.taskDecorator = null; + this.customizers = null; + } + + private ThreadPoolTaskExecutorBuilder(Integer queueCapacity, Integer corePoolSize, Integer maxPoolSize, + Boolean allowCoreThreadTimeOut, Duration keepAlive, Boolean acceptTasksAfterContextClose, + Boolean awaitTermination, Duration awaitTerminationPeriod, String threadNamePrefix, + TaskDecorator taskDecorator, Set customizers) { + this.queueCapacity = queueCapacity; + this.corePoolSize = corePoolSize; + this.maxPoolSize = maxPoolSize; + this.allowCoreThreadTimeOut = allowCoreThreadTimeOut; + this.keepAlive = keepAlive; + this.acceptTasksAfterContextClose = acceptTasksAfterContextClose; + this.awaitTermination = awaitTermination; + this.awaitTerminationPeriod = awaitTerminationPeriod; + this.threadNamePrefix = threadNamePrefix; + this.taskDecorator = taskDecorator; + this.customizers = customizers; + } + + /** + * Set the capacity of the queue. An unbounded capacity does not increase the pool and + * therefore ignores {@link #maxPoolSize(int) maxPoolSize}. + * @param queueCapacity the queue capacity to set + * @return a new builder instance + */ + public ThreadPoolTaskExecutorBuilder queueCapacity(int queueCapacity) { + return new ThreadPoolTaskExecutorBuilder(queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set the core number of threads. Effectively that maximum number of threads as long + * as the queue is not full. + *

+ * Core threads can grow and shrink if {@link #allowCoreThreadTimeOut(boolean)} is + * enabled. + * @param corePoolSize the core pool size to set + * @return a new builder instance + */ + public ThreadPoolTaskExecutorBuilder corePoolSize(int corePoolSize) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set the maximum allowed number of threads. When the {@link #queueCapacity(int) + * queue} is full, the pool can expand up to that size to accommodate the load. + *

+ * If the {@link #queueCapacity(int) queue capacity} is unbounded, this setting is + * ignored. + * @param maxPoolSize the max pool size to set + * @return a new builder instance + */ + public ThreadPoolTaskExecutorBuilder maxPoolSize(int maxPoolSize) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set whether core threads are allowed to time out. When enabled, this enables + * dynamic growing and shrinking of the pool. + * @param allowCoreThreadTimeOut if core threads are allowed to time out + * @return a new builder instance + */ + public ThreadPoolTaskExecutorBuilder allowCoreThreadTimeOut(boolean allowCoreThreadTimeOut) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set the time limit for which threads may remain idle before being terminated. + * @param keepAlive the keep alive to set + * @return a new builder instance + */ + public ThreadPoolTaskExecutorBuilder keepAlive(Duration keepAlive) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set whether to accept further tasks after the application context close phase has + * begun. + * @param acceptTasksAfterContextClose whether to accept further tasks after the + * application context close phase has begun + * @return a new builder instance + * @since 3.3.0 + */ + public ThreadPoolTaskExecutorBuilder acceptTasksAfterContextClose(boolean acceptTasksAfterContextClose) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set whether the executor should wait for scheduled tasks to complete on shutdown, + * not interrupting running tasks and executing all tasks in the queue. + * @param awaitTermination whether the executor needs to wait for the tasks to + * complete on shutdown + * @return a new builder instance + * @see #awaitTerminationPeriod(Duration) + */ + public ThreadPoolTaskExecutorBuilder awaitTermination(boolean awaitTermination) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set the maximum time the executor is supposed to block on shutdown. When set, the + * executor blocks on shutdown in order to wait for remaining tasks to complete their + * execution before the rest of the container continues to shut down. This is + * particularly useful if your remaining tasks are likely to need access to other + * resources that are also managed by the container. + * @param awaitTerminationPeriod the await termination period to set + * @return a new builder instance + */ + public ThreadPoolTaskExecutorBuilder awaitTerminationPeriod(Duration awaitTerminationPeriod) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set the prefix to use for the names of newly created threads. + * @param threadNamePrefix the thread name prefix to set + * @return a new builder instance + */ + public ThreadPoolTaskExecutorBuilder threadNamePrefix(String threadNamePrefix) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, threadNamePrefix, this.taskDecorator, this.customizers); + } + + /** + * Set the {@link TaskDecorator} to use or {@code null} to not use any. + * @param taskDecorator the task decorator to use + * @return a new builder instance + */ + public ThreadPoolTaskExecutorBuilder taskDecorator(TaskDecorator taskDecorator) { + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, taskDecorator, this.customizers); + } + + /** + * Set the {@link ThreadPoolTaskExecutorCustomizer ThreadPoolTaskExecutorCustomizers} + * that should be applied to the {@link ThreadPoolTaskExecutor}. Customizers are + * applied in the order that they were added after builder configuration has been + * applied. Setting this value will replace any previously configured customizers. + * @param customizers the customizers to set + * @return a new builder instance + * @see #additionalCustomizers(ThreadPoolTaskExecutorCustomizer...) + */ + public ThreadPoolTaskExecutorBuilder customizers(ThreadPoolTaskExecutorCustomizer... customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return customizers(Arrays.asList(customizers)); + } + + /** + * Set the {@link ThreadPoolTaskExecutorCustomizer ThreadPoolTaskExecutorCustomizers} + * that should be applied to the {@link ThreadPoolTaskExecutor}. Customizers are + * applied in the order that they were added after builder configuration has been + * applied. Setting this value will replace any previously configured customizers. + * @param customizers the customizers to set + * @return a new builder instance + * @see #additionalCustomizers(ThreadPoolTaskExecutorCustomizer...) + */ + public ThreadPoolTaskExecutorBuilder customizers(Iterable customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, append(null, customizers)); + } + + /** + * Add {@link ThreadPoolTaskExecutorCustomizer ThreadPoolTaskExecutorCustomizers} that + * should be applied to the {@link ThreadPoolTaskExecutor}. Customizers are applied in + * the order that they were added after builder configuration has been applied. + * @param customizers the customizers to add + * @return a new builder instance + * @see #customizers(ThreadPoolTaskExecutorCustomizer...) + */ + public ThreadPoolTaskExecutorBuilder additionalCustomizers(ThreadPoolTaskExecutorCustomizer... customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return additionalCustomizers(Arrays.asList(customizers)); + } + + /** + * Add {@link ThreadPoolTaskExecutorCustomizer ThreadPoolTaskExecutorCustomizers} that + * should be applied to the {@link ThreadPoolTaskExecutor}. Customizers are applied in + * the order that they were added after builder configuration has been applied. + * @param customizers the customizers to add + * @return a new builder instance + * @see #customizers(ThreadPoolTaskExecutorCustomizer...) + */ + public ThreadPoolTaskExecutorBuilder additionalCustomizers( + Iterable customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return new ThreadPoolTaskExecutorBuilder(this.queueCapacity, this.corePoolSize, this.maxPoolSize, + this.allowCoreThreadTimeOut, this.keepAlive, this.acceptTasksAfterContextClose, this.awaitTermination, + this.awaitTerminationPeriod, this.threadNamePrefix, this.taskDecorator, + append(this.customizers, customizers)); + } + + /** + * Build a new {@link ThreadPoolTaskExecutor} instance and configure it using this + * builder. + * @return a configured {@link ThreadPoolTaskExecutor} instance. + * @see #build(Class) + * @see #configure(ThreadPoolTaskExecutor) + */ + public ThreadPoolTaskExecutor build() { + return configure(new ThreadPoolTaskExecutor()); + } + + /** + * Build a new {@link ThreadPoolTaskExecutor} instance of the specified type and + * configure it using this builder. + * @param the type of task executor + * @param taskExecutorClass the template type to create + * @return a configured {@link ThreadPoolTaskExecutor} instance. + * @see #build() + * @see #configure(ThreadPoolTaskExecutor) + */ + public T build(Class taskExecutorClass) { + return configure(BeanUtils.instantiateClass(taskExecutorClass)); + } + + /** + * Configure the provided {@link ThreadPoolTaskExecutor} instance using this builder. + * @param the type of task executor + * @param taskExecutor the {@link ThreadPoolTaskExecutor} to configure + * @return the task executor instance + * @see #build() + * @see #build(Class) + */ + public T configure(T taskExecutor) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.queueCapacity).to(taskExecutor::setQueueCapacity); + map.from(this.corePoolSize).to(taskExecutor::setCorePoolSize); + map.from(this.maxPoolSize).to(taskExecutor::setMaxPoolSize); + map.from(this.keepAlive).asInt(Duration::getSeconds).to(taskExecutor::setKeepAliveSeconds); + map.from(this.allowCoreThreadTimeOut).to(taskExecutor::setAllowCoreThreadTimeOut); + map.from(this.acceptTasksAfterContextClose).to(taskExecutor::setAcceptTasksAfterContextClose); + map.from(this.awaitTermination).to(taskExecutor::setWaitForTasksToCompleteOnShutdown); + map.from(this.awaitTerminationPeriod).as(Duration::toMillis).to(taskExecutor::setAwaitTerminationMillis); + map.from(this.threadNamePrefix).whenHasText().to(taskExecutor::setThreadNamePrefix); + map.from(this.taskDecorator).to(taskExecutor::setTaskDecorator); + if (!CollectionUtils.isEmpty(this.customizers)) { + this.customizers.forEach((customizer) -> customizer.customize(taskExecutor)); + } + return taskExecutor; + } + + private Set append(Set set, Iterable additions) { + Set result = new LinkedHashSet<>((set != null) ? set : Collections.emptySet()); + additions.forEach(result::add); + return Collections.unmodifiableSet(result); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorCustomizer.java new file mode 100644 index 000000000000..c81c5bfe7985 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskExecutorCustomizer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-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.boot.task; + +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Callback interface that can be used to customize a {@link ThreadPoolTaskExecutor}. + * + * @author Stephane Nicoll + * @since 3.2.0 + * @see ThreadPoolTaskExecutorBuilder + */ +@FunctionalInterface +public interface ThreadPoolTaskExecutorCustomizer { + + /** + * Callback to customize a {@link ThreadPoolTaskExecutor} instance. + * @param taskExecutor the task executor to customize + */ + void customize(ThreadPoolTaskExecutor taskExecutor); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilder.java new file mode 100644 index 000000000000..a36e48308ee4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilder.java @@ -0,0 +1,214 @@ +/* + * Copyright 2012-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.boot.task; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Builder that can be used to configure and create a {@link ThreadPoolTaskScheduler}. + * Provides convenience methods to set common {@link ThreadPoolTaskScheduler} settings. + * For advanced configuration, consider using {@link ThreadPoolTaskSchedulerCustomizer}. + *

+ * In a typical auto-configured Spring Boot application this builder is available as a + * bean and can be injected whenever a {@link ThreadPoolTaskScheduler} is needed. + * + * @author Stephane Nicoll + * @since 3.2.0 + */ +public class ThreadPoolTaskSchedulerBuilder { + + private final Integer poolSize; + + private final Boolean awaitTermination; + + private final Duration awaitTerminationPeriod; + + private final String threadNamePrefix; + + private final Set customizers; + + public ThreadPoolTaskSchedulerBuilder() { + this.poolSize = null; + this.awaitTermination = null; + this.awaitTerminationPeriod = null; + this.threadNamePrefix = null; + this.customizers = null; + } + + public ThreadPoolTaskSchedulerBuilder(Integer poolSize, Boolean awaitTermination, Duration awaitTerminationPeriod, + String threadNamePrefix, Set taskSchedulerCustomizers) { + this.poolSize = poolSize; + this.awaitTermination = awaitTermination; + this.awaitTerminationPeriod = awaitTerminationPeriod; + this.threadNamePrefix = threadNamePrefix; + this.customizers = taskSchedulerCustomizers; + } + + /** + * Set the maximum allowed number of threads. + * @param poolSize the pool size to set + * @return a new builder instance + */ + public ThreadPoolTaskSchedulerBuilder poolSize(int poolSize) { + return new ThreadPoolTaskSchedulerBuilder(poolSize, this.awaitTermination, this.awaitTerminationPeriod, + this.threadNamePrefix, this.customizers); + } + + /** + * Set whether the executor should wait for scheduled tasks to complete on shutdown, + * not interrupting running tasks and executing all tasks in the queue. + * @param awaitTermination whether the executor needs to wait for the tasks to + * complete on shutdown + * @return a new builder instance + * @see #awaitTerminationPeriod(Duration) + */ + public ThreadPoolTaskSchedulerBuilder awaitTermination(boolean awaitTermination) { + return new ThreadPoolTaskSchedulerBuilder(this.poolSize, awaitTermination, this.awaitTerminationPeriod, + this.threadNamePrefix, this.customizers); + } + + /** + * Set the maximum time the executor is supposed to block on shutdown. When set, the + * executor blocks on shutdown in order to wait for remaining tasks to complete their + * execution before the rest of the container continues to shut down. This is + * particularly useful if your remaining tasks are likely to need access to other + * resources that are also managed by the container. + * @param awaitTerminationPeriod the await termination period to set + * @return a new builder instance + */ + public ThreadPoolTaskSchedulerBuilder awaitTerminationPeriod(Duration awaitTerminationPeriod) { + return new ThreadPoolTaskSchedulerBuilder(this.poolSize, this.awaitTermination, awaitTerminationPeriod, + this.threadNamePrefix, this.customizers); + } + + /** + * Set the prefix to use for the names of newly created threads. + * @param threadNamePrefix the thread name prefix to set + * @return a new builder instance + */ + public ThreadPoolTaskSchedulerBuilder threadNamePrefix(String threadNamePrefix) { + return new ThreadPoolTaskSchedulerBuilder(this.poolSize, this.awaitTermination, this.awaitTerminationPeriod, + threadNamePrefix, this.customizers); + } + + /** + * Set the {@link ThreadPoolTaskSchedulerCustomizer + * threadPoolTaskSchedulerCustomizers} that should be applied to the + * {@link ThreadPoolTaskScheduler}. Customizers are applied in the order that they + * were added after builder configuration has been applied. Setting this value will + * replace any previously configured customizers. + * @param customizers the customizers to set + * @return a new builder instance + * @see #additionalCustomizers(ThreadPoolTaskSchedulerCustomizer...) + */ + public ThreadPoolTaskSchedulerBuilder customizers(ThreadPoolTaskSchedulerCustomizer... customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return customizers(Arrays.asList(customizers)); + } + + /** + * Set the {@link ThreadPoolTaskSchedulerCustomizer + * threadPoolTaskSchedulerCustomizers} that should be applied to the + * {@link ThreadPoolTaskScheduler}. Customizers are applied in the order that they + * were added after builder configuration has been applied. Setting this value will + * replace any previously configured customizers. + * @param customizers the customizers to set + * @return a new builder instance + * @see #additionalCustomizers(ThreadPoolTaskSchedulerCustomizer...) + */ + public ThreadPoolTaskSchedulerBuilder customizers( + Iterable customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return new ThreadPoolTaskSchedulerBuilder(this.poolSize, this.awaitTermination, this.awaitTerminationPeriod, + this.threadNamePrefix, append(null, customizers)); + } + + /** + * Add {@link ThreadPoolTaskSchedulerCustomizer threadPoolTaskSchedulerCustomizers} + * that should be applied to the {@link ThreadPoolTaskScheduler}. Customizers are + * applied in the order that they were added after builder configuration has been + * applied. + * @param customizers the customizers to add + * @return a new builder instance + * @see #customizers(ThreadPoolTaskSchedulerCustomizer...) + */ + public ThreadPoolTaskSchedulerBuilder additionalCustomizers(ThreadPoolTaskSchedulerCustomizer... customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return additionalCustomizers(Arrays.asList(customizers)); + } + + /** + * Add {@link ThreadPoolTaskSchedulerCustomizer threadPoolTaskSchedulerCustomizers} + * that should be applied to the {@link ThreadPoolTaskScheduler}. Customizers are + * applied in the order that they were added after builder configuration has been + * applied. + * @param customizers the customizers to add + * @return a new builder instance + * @see #customizers(ThreadPoolTaskSchedulerCustomizer...) + */ + public ThreadPoolTaskSchedulerBuilder additionalCustomizers( + Iterable customizers) { + Assert.notNull(customizers, "Customizers must not be null"); + return new ThreadPoolTaskSchedulerBuilder(this.poolSize, this.awaitTermination, this.awaitTerminationPeriod, + this.threadNamePrefix, append(this.customizers, customizers)); + } + + /** + * Build a new {@link ThreadPoolTaskScheduler} instance and configure it using this + * builder. + * @return a configured {@link ThreadPoolTaskScheduler} instance. + * @see #configure(ThreadPoolTaskScheduler) + */ + public ThreadPoolTaskScheduler build() { + return configure(new ThreadPoolTaskScheduler()); + } + + /** + * Configure the provided {@link ThreadPoolTaskScheduler} instance using this builder. + * @param the type of task scheduler + * @param taskScheduler the {@link ThreadPoolTaskScheduler} to configure + * @return the task scheduler instance + * @see #build() + */ + public T configure(T taskScheduler) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.poolSize).to(taskScheduler::setPoolSize); + map.from(this.awaitTermination).to(taskScheduler::setWaitForTasksToCompleteOnShutdown); + map.from(this.awaitTerminationPeriod).asInt(Duration::getSeconds).to(taskScheduler::setAwaitTerminationSeconds); + map.from(this.threadNamePrefix).to(taskScheduler::setThreadNamePrefix); + if (!CollectionUtils.isEmpty(this.customizers)) { + this.customizers.forEach((customizer) -> customizer.customize(taskScheduler)); + } + return taskScheduler; + } + + private Set append(Set set, Iterable additions) { + Set result = new LinkedHashSet<>((set != null) ? set : Collections.emptySet()); + additions.forEach(result::add); + return Collections.unmodifiableSet(result); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerCustomizer.java new file mode 100644 index 000000000000..0e7cc44458e2 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/task/ThreadPoolTaskSchedulerCustomizer.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-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.boot.task; + +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * Callback interface that can be used to customize a {@link ThreadPoolTaskScheduler}. + * + * @author Stephane Nicoll + * @since 3.2.0 + */ +@FunctionalInterface +public interface ThreadPoolTaskSchedulerCustomizer { + + /** + * Callback to customize a {@link ThreadPoolTaskScheduler} instance. + * @param taskScheduler the task scheduler to customize + */ + void customize(ThreadPoolTaskScheduler taskScheduler); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java index af23e1111559..05eb183cc679 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/util/Instantiator.java @@ -17,7 +17,6 @@ package org.springframework.boot.util; import java.lang.reflect.Constructor; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -28,7 +27,6 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; -import java.util.stream.Collectors; import java.util.stream.Stream; import org.springframework.core.annotation.AnnotationAwareOrderComparator; @@ -139,9 +137,7 @@ public List instantiateTypes(Collection> types) { } private List instantiate(Stream typeSuppliers) { - List instances = typeSuppliers.map(this::instantiate).collect(Collectors.toCollection(ArrayList::new)); - AnnotationAwareOrderComparator.sort(instances); - return Collections.unmodifiableList(instances); + return typeSuppliers.map(this::instantiate).sorted(AnnotationAwareOrderComparator.INSTANCE).toList(); } private T instantiate(TypeSupplier typeSupplier) { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactories.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactories.java index cd49c0fadf29..9fc047382ff3 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactories.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactories.java @@ -26,6 +26,7 @@ import java.util.function.Supplier; import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; @@ -38,6 +39,9 @@ import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; import org.apache.hc.core5.http.io.SocketConfig; +import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.ssl.SslBundle; @@ -45,6 +49,8 @@ import org.springframework.http.client.AbstractClientHttpRequestFactoryWrapper; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.util.Assert; @@ -70,6 +76,10 @@ public final class ClientHttpRequestFactories { private static final boolean OKHTTP_CLIENT_PRESENT = ClassUtils.isPresent(OKHTTP_CLIENT_CLASS, null); + static final String JETTY_CLIENT_CLASS = "org.eclipse.jetty.client.HttpClient"; + + private static final boolean JETTY_CLIENT_PRESENT = ClassUtils.isPresent(JETTY_CLIENT_CLASS, null); + private ClientHttpRequestFactories() { } @@ -79,17 +89,22 @@ private ClientHttpRequestFactories() { * dependencies {@link ClassUtils#isPresent are available} is returned: *

    *
  1. {@link HttpComponentsClientHttpRequestFactory}
  2. - *
  3. {@link OkHttp3ClientHttpRequestFactory}
  4. + *
  5. {@link JettyClientHttpRequestFactory}
  6. + *
  7. {@link OkHttp3ClientHttpRequestFactory} (deprecated)
  8. *
  9. {@link SimpleClientHttpRequestFactory}
  10. *
* @param settings the settings to apply * @return a new {@link ClientHttpRequestFactory} */ + @SuppressWarnings("removal") public static ClientHttpRequestFactory get(ClientHttpRequestFactorySettings settings) { Assert.notNull(settings, "Settings must not be null"); if (APACHE_HTTP_CLIENT_PRESENT) { return HttpComponents.get(settings); } + if (JETTY_CLIENT_PRESENT) { + return Jetty.get(settings); + } if (OKHTTP_CLIENT_PRESENT) { return OkHttp.get(settings); } @@ -103,7 +118,9 @@ public static ClientHttpRequestFactory get(ClientHttpRequestFactorySettings sett * use of reflection: *
    *
  • {@link HttpComponentsClientHttpRequestFactory}
  • - *
  • {@link OkHttp3ClientHttpRequestFactory}
  • + *
  • {@link JdkClientHttpRequestFactory}
  • + *
  • {@link JettyClientHttpRequestFactory}
  • + *
  • {@link OkHttp3ClientHttpRequestFactory} (deprecated)
  • *
  • {@link SimpleClientHttpRequestFactory}
  • *
* A {@code requestFactoryType} of {@link ClientHttpRequestFactory} is equivalent to @@ -113,7 +130,7 @@ public static ClientHttpRequestFactory get(ClientHttpRequestFactorySettings sett * @param settings the settings to apply * @return a new {@link ClientHttpRequestFactory} instance */ - @SuppressWarnings("unchecked") + @SuppressWarnings({ "unchecked", "removal" }) public static T get(Class requestFactoryType, ClientHttpRequestFactorySettings settings) { Assert.notNull(settings, "Settings must not be null"); @@ -123,12 +140,18 @@ public static T get(Class requestFactory if (requestFactoryType == HttpComponentsClientHttpRequestFactory.class) { return (T) HttpComponents.get(settings); } - if (requestFactoryType == OkHttp3ClientHttpRequestFactory.class) { - return (T) OkHttp.get(settings); + if (requestFactoryType == JettyClientHttpRequestFactory.class) { + return (T) Jetty.get(settings); + } + if (requestFactoryType == JdkClientHttpRequestFactory.class) { + return (T) Jdk.get(settings); } if (requestFactoryType == SimpleClientHttpRequestFactory.class) { return (T) Simple.get(settings); } + if (requestFactoryType == OkHttp3ClientHttpRequestFactory.class) { + return (T) OkHttp.get(settings); + } return get(() -> createRequestFactory(requestFactoryType), settings); } @@ -166,7 +189,6 @@ static HttpComponentsClientHttpRequestFactory get(ClientHttpRequestFactorySettin settings.sslBundle()); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout); - map.from(settings::bufferRequestBody).to(requestFactory::setBufferRequestBody); return requestFactory; } @@ -200,11 +222,11 @@ private static HttpClient createHttpClient(Duration readTimeout, SslBundle sslBu /** * Support for {@link OkHttp3ClientHttpRequestFactory}. */ + @Deprecated(since = "3.2.0", forRemoval = true) + @SuppressWarnings("removal") static class OkHttp { static OkHttp3ClientHttpRequestFactory get(ClientHttpRequestFactorySettings settings) { - Assert.state(settings.bufferRequestBody() == null, - () -> "OkHttp3ClientHttpRequestFactory does not support request body buffering"); OkHttp3ClientHttpRequestFactory requestFactory = createRequestFactory(settings.sslBundle()); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout); @@ -229,6 +251,61 @@ private static OkHttp3ClientHttpRequestFactory createRequestFactory(SslBundle ss } + /** + * Support for {@link JettyClientHttpRequestFactory}. + */ + static class Jetty { + + static JettyClientHttpRequestFactory get(ClientHttpRequestFactorySettings settings) { + JettyClientHttpRequestFactory requestFactory = createRequestFactory(settings.sslBundle()); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout); + map.from(settings::readTimeout).asInt(Duration::toMillis).to(requestFactory::setReadTimeout); + return requestFactory; + } + + private static JettyClientHttpRequestFactory createRequestFactory(SslBundle sslBundle) { + if (sslBundle != null) { + SSLContext sslContext = sslBundle.createSslContext(); + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + sslContextFactory.setSslContext(sslContext); + ClientConnector connector = new ClientConnector(); + connector.setSslContextFactory(sslContextFactory); + org.eclipse.jetty.client.HttpClient httpClient = new org.eclipse.jetty.client.HttpClient( + new HttpClientTransportDynamic(connector)); + return new JettyClientHttpRequestFactory(httpClient); + } + return new JettyClientHttpRequestFactory(); + } + + } + + /** + * Support for {@link JdkClientHttpRequestFactory}. + */ + static class Jdk { + + static JdkClientHttpRequestFactory get(ClientHttpRequestFactorySettings settings) { + java.net.http.HttpClient httpClient = createHttpClient(settings.connectTimeout(), settings.sslBundle()); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(settings::readTimeout).to(requestFactory::setReadTimeout); + return requestFactory; + } + + private static java.net.http.HttpClient createHttpClient(Duration connectTimeout, SslBundle sslBundle) { + java.net.http.HttpClient.Builder builder = java.net.http.HttpClient.newBuilder(); + if (connectTimeout != null) { + builder.connectTimeout(connectTimeout); + } + if (sslBundle != null) { + builder.sslContext(sslBundle.createSslContext()); + } + return builder.build(); + } + + } + /** * Support for {@link SimpleClientHttpRequestFactory}. */ @@ -243,7 +320,6 @@ static SimpleClientHttpRequestFactory get(ClientHttpRequestFactorySettings setti PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(settings::readTimeout).asInt(Duration::toMillis).to(requestFactory::setReadTimeout); map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout); - map.from(settings::bufferRequestBody).to(requestFactory::setBufferRequestBody); return requestFactory; } @@ -291,8 +367,6 @@ private static void configure(ClientHttpRequestFactory requestFactory, PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(settings::connectTimeout).to((connectTimeout) -> setConnectTimeout(unwrapped, connectTimeout)); map.from(settings::readTimeout).to((readTimeout) -> setReadTimeout(unwrapped, readTimeout)); - map.from(settings::bufferRequestBody) - .to((bufferRequestBody) -> setBufferRequestBody(unwrapped, bufferRequestBody)); } private static ClientHttpRequestFactory unwrapRequestFactoryIfNecessary( @@ -322,11 +396,6 @@ private static void setReadTimeout(ClientHttpRequestFactory factory, Duration re invoke(factory, method, timeout); } - private static void setBufferRequestBody(ClientHttpRequestFactory factory, boolean bufferRequestBody) { - Method method = findMethod(factory, "setBufferRequestBody", boolean.class); - invoke(factory, method, bufferRequestBody); - } - private static Method findMethod(ClientHttpRequestFactory requestFactory, String methodName, Class... parameters) { Method method = ReflectionUtils.findMethod(requestFactory.getClass(), methodName, parameters); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHints.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHints.java index c47ef109a64c..45a3744a4b82 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHints.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHints.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -28,6 +28,7 @@ import org.springframework.http.client.AbstractClientHttpRequestFactoryWrapper; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.util.Assert; @@ -55,21 +56,36 @@ private void registerHints(ReflectionHints hints, ClassLoader classLoader) { typeHint.onReachableType(TypeReference.of(ClientHttpRequestFactories.APACHE_HTTP_CLIENT_CLASS)); registerReflectionHints(hints, HttpComponentsClientHttpRequestFactory.class); }); - hints.registerTypeIfPresent(classLoader, ClientHttpRequestFactories.OKHTTP_CLIENT_CLASS, (typeHint) -> { - typeHint.onReachableType(TypeReference.of(ClientHttpRequestFactories.OKHTTP_CLIENT_CLASS)); - registerReflectionHints(hints, OkHttp3ClientHttpRequestFactory.class); + hints.registerTypeIfPresent(classLoader, ClientHttpRequestFactories.JETTY_CLIENT_CLASS, (typeHint) -> { + typeHint.onReachableType(TypeReference.of(ClientHttpRequestFactories.JETTY_CLIENT_CLASS)); + registerReflectionHints(hints, JettyClientHttpRequestFactory.class, long.class); }); hints.registerType(SimpleClientHttpRequestFactory.class, (typeHint) -> { typeHint.onReachableType(HttpURLConnection.class); registerReflectionHints(hints, SimpleClientHttpRequestFactory.class); }); + registerOkHttpHints(hints, classLoader); + } + + @SuppressWarnings("removal") + @Deprecated(since = "3.2.0", forRemoval = true) + private void registerOkHttpHints(ReflectionHints hints, ClassLoader classLoader) { + hints.registerTypeIfPresent(classLoader, ClientHttpRequestFactories.OKHTTP_CLIENT_CLASS, (typeHint) -> { + typeHint.onReachableType(TypeReference.of(ClientHttpRequestFactories.OKHTTP_CLIENT_CLASS)); + registerReflectionHints(hints, OkHttp3ClientHttpRequestFactory.class); + }); + } private void registerReflectionHints(ReflectionHints hints, Class requestFactoryType) { + registerReflectionHints(hints, requestFactoryType, int.class); + } + + private void registerReflectionHints(ReflectionHints hints, + Class requestFactoryType, Class readTimeoutType) { registerMethod(hints, requestFactoryType, "setConnectTimeout", int.class); - registerMethod(hints, requestFactoryType, "setReadTimeout", int.class); - registerMethod(hints, requestFactoryType, "setBufferRequestBody", boolean.class); + registerMethod(hints, requestFactoryType, "setReadTimeout", readTimeoutType); } private void registerMethod(ReflectionHints hints, Class requestFactoryType, diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettings.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettings.java index 22deb5a4a16b..204acffb933d 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettings.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettings.java @@ -26,7 +26,6 @@ * * @param connectTimeout the connect timeout * @param readTimeout the read timeout - * @param bufferRequestBody if request body buffering is used * @param sslBundle the SSL bundle providing SSL configuration * @author Andy Wilkinson * @author Phillip Webb @@ -34,8 +33,7 @@ * @since 3.0.0 * @see ClientHttpRequestFactories */ -public record ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, Boolean bufferRequestBody, - SslBundle sslBundle) { +public record ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, SslBundle sslBundle) { /** * Use defaults for the {@link ClientHttpRequestFactory} which can differ depending on @@ -48,15 +46,29 @@ public record ClientHttpRequestFactorySettings(Duration connectTimeout, Duration * Create a new {@link ClientHttpRequestFactorySettings} instance. * @param connectTimeout the connection timeout * @param readTimeout the read timeout - * @param bufferRequestBody the bugger request body - * @param sslBundle the ssl bundle - * @since 3.1.0 + * @param bufferRequestBody if request body buffering is used + * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been + * removed in Spring Framework 6.1 */ - public ClientHttpRequestFactorySettings { + @Deprecated(since = "3.2.0", forRemoval = true) + public ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, Boolean bufferRequestBody) { + this(connectTimeout, readTimeout, (SslBundle) null); } - public ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, Boolean bufferRequestBody) { - this(connectTimeout, readTimeout, bufferRequestBody, null); + /** + * Create a new {@link ClientHttpRequestFactorySettings} instance. + * @param connectTimeout the connection timeout + * @param readTimeout the read timeout + * @param bufferRequestBody if request body buffering is used + * @param sslBundle the ssl bundle + * @since 3.1.0 + * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been + * removed in Spring Framework 6.1 + */ + @Deprecated(since = "3.2.0", forRemoval = true) + public ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTimeout, Boolean bufferRequestBody, + SslBundle sslBundle) { + this(connectTimeout, readTimeout, sslBundle); } /** @@ -66,8 +78,7 @@ public ClientHttpRequestFactorySettings(Duration connectTimeout, Duration readTi * @return a new {@link ClientHttpRequestFactorySettings} instance */ public ClientHttpRequestFactorySettings withConnectTimeout(Duration connectTimeout) { - return new ClientHttpRequestFactorySettings(connectTimeout, this.readTimeout, this.bufferRequestBody, - this.sslBundle); + return new ClientHttpRequestFactorySettings(connectTimeout, this.readTimeout, this.sslBundle); } /** @@ -78,19 +89,19 @@ public ClientHttpRequestFactorySettings withConnectTimeout(Duration connectTimeo */ public ClientHttpRequestFactorySettings withReadTimeout(Duration readTimeout) { - return new ClientHttpRequestFactorySettings(this.connectTimeout, readTimeout, this.bufferRequestBody, - this.sslBundle); + return new ClientHttpRequestFactorySettings(this.connectTimeout, readTimeout, this.sslBundle); } /** - * Return a new {@link ClientHttpRequestFactorySettings} instance with an updated - * buffer request body setting. + * Has no effect as support for buffering has been removed in Spring Framework 6.1. * @param bufferRequestBody the new buffer request body setting * @return a new {@link ClientHttpRequestFactorySettings} instance + * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been + * removed in Spring Framework 6.1 */ + @Deprecated(since = "3.2.0", forRemoval = true) public ClientHttpRequestFactorySettings withBufferRequestBody(Boolean bufferRequestBody) { - return new ClientHttpRequestFactorySettings(this.connectTimeout, this.readTimeout, bufferRequestBody, - this.sslBundle); + return this; } /** @@ -101,8 +112,18 @@ public ClientHttpRequestFactorySettings withBufferRequestBody(Boolean bufferRequ * @since 3.1.0 */ public ClientHttpRequestFactorySettings withSslBundle(SslBundle sslBundle) { - return new ClientHttpRequestFactorySettings(this.connectTimeout, this.readTimeout, this.bufferRequestBody, - sslBundle); + return new ClientHttpRequestFactorySettings(this.connectTimeout, this.readTimeout, sslBundle); + } + + /** + * Returns whether request body buffering is used. + * @return whether request body buffering is used + * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been + * removed in Spring Framework 6.1 + */ + @Deprecated(since = "3.2.0", forRemoval = true) + public Boolean bufferRequestBody() { + return null; } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySupplier.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySupplier.java deleted file mode 100644 index 7fce4e31cf0f..000000000000 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/ClientHttpRequestFactorySupplier.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2012-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.boot.web.client; - -import java.util.function.Supplier; - -import org.springframework.http.client.ClientHttpRequestFactory; - -/** - * A supplier for {@link ClientHttpRequestFactory} that detects the preferred candidate - * based on the available implementations on the classpath. - * - * @author Stephane Nicoll - * @author Moritz Halbritter - * @since 2.1.0 - * @deprecated since 3.0.0 for removal in 3.2.0 in favor of - * {@link ClientHttpRequestFactories} - */ -@Deprecated(since = "3.0.0", forRemoval = true) -public class ClientHttpRequestFactorySupplier implements Supplier { - - @Override - public ClientHttpRequestFactory get() { - return ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS); - } - -} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestClientCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestClientCustomizer.java new file mode 100644 index 000000000000..e19b9f5a02c7 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestClientCustomizer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-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.boot.web.client; + +import org.springframework.web.client.RestClient; + +/** + * Callback interface that can be used to customize a + * {@link org.springframework.web.client.RestClient.Builder RestClient.Builder}. + * + * @author Arjen Poutsma + * @since 3.2.0 + */ +@FunctionalInterface +public interface RestClientCustomizer { + + /** + * Callback to customize a {@link org.springframework.web.client.RestClient.Builder + * RestClient.Builder} instance. + * @param restClientBuilder the client builder to customize + */ + void customize(RestClient.Builder restClientBuilder); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestTemplateBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestTemplateBuilder.java index 67b4138324fb..a10a9ce10022 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestTemplateBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/client/RestTemplateBuilder.java @@ -439,19 +439,18 @@ public RestTemplateBuilder setReadTimeout(Duration readTimeout) { } /** - * Sets if the underlying {@link ClientHttpRequestFactory} should buffer the - * {@linkplain ClientHttpRequest#getBody() request body} internally. + * Has no effect as support for buffering has been removed in Spring Framework 6.1. * @param bufferRequestBody value of the bufferRequestBody parameter * @return a new builder instance. * @since 2.2.0 + * @deprecated since 3.2.0 for removal in 3.4.0 as support for buffering has been + * removed in Spring Framework 6.1 * @see SimpleClientHttpRequestFactory#setBufferRequestBody(boolean) * @see HttpComponentsClientHttpRequestFactory#setBufferRequestBody(boolean) */ + @Deprecated(since = "3.2.0", forRemoval = true) public RestTemplateBuilder setBufferRequestBody(boolean bufferRequestBody) { - return new RestTemplateBuilder(this.requestFactorySettings.withBufferRequestBody(bufferRequestBody), - this.detectRequestFactory, this.rootUri, this.messageConverters, this.interceptors, this.requestFactory, - this.uriTemplateHandler, this.errorHandler, this.basicAuthentication, this.defaultHeaders, - this.customizers, this.requestCustomizers); + return this; } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/context/ServerPortInfoApplicationContextInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/context/ServerPortInfoApplicationContextInitializer.java index 7e3fafe9f410..2ec73ab57a12 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/context/ServerPortInfoApplicationContextInitializer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/context/ServerPortInfoApplicationContextInitializer.java @@ -52,6 +52,8 @@ public class ServerPortInfoApplicationContextInitializer implements ApplicationContextInitializer, ApplicationListener { + private static final String PROPERTY_SOURCE_NAME = "server.ports"; + @Override public void initialize(ConfigurableApplicationContext applicationContext) { applicationContext.addApplicationListener(this); @@ -80,9 +82,9 @@ private void setPortProperty(ApplicationContext context, String propertyName, in @SuppressWarnings("unchecked") private void setPortProperty(ConfigurableEnvironment environment, String propertyName, int port) { MutablePropertySources sources = environment.getPropertySources(); - PropertySource source = sources.get("server.ports"); + PropertySource source = sources.get(PROPERTY_SOURCE_NAME); if (source == null) { - source = new MapPropertySource("server.ports", new HashMap<>()); + source = new MapPropertySource(PROPERTY_SOURCE_NAME, new HashMap<>()); sources.addFirst(source); } ((Map) source.getSource()).put(propertyName, port); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ConfigurableJettyWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ConfigurableJettyWebServerFactory.java index 5f8a927b403a..d92502ac9abb 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ConfigurableJettyWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ConfigurableJettyWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -25,6 +25,7 @@ * {@link ConfigurableWebServerFactory} for Jetty-specific features. * * @author Brian Clozel + * @author Moritz Halbritter * @since 2.0.0 * @see JettyServletWebServerFactory * @see JettyReactiveWebServerFactory @@ -63,4 +64,11 @@ public interface ConfigurableJettyWebServerFactory extends ConfigurableWebServer */ void addServerCustomizers(JettyServerCustomizer... customizers); + /** + * Sets the maximum number of concurrent connections. + * @param maxConnections the maximum number of concurrent connections + * @since 3.2.0 + */ + void setMaxConnections(int maxConnections); + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/GracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/GracefulShutdown.java index dc2580d25ea8..c0a27c20f14b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/GracefulShutdown.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/GracefulShutdown.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,7 @@ private void awaitShutdown(GracefulShutdownCallback callback) { while (this.shuttingDown && this.activeRequests.get() > 0) { sleep(100); } + System.out.println(this.activeRequests.get()); this.shuttingDown = false; long activeRequests = this.activeRequests.get(); if (activeRequests == 0) { @@ -106,7 +107,8 @@ private void awaitShutdown(GracefulShutdownCallback callback) { callback.shutdownComplete(GracefulShutdownResult.IDLE); } else { - logger.info(LogMessage.format("Graceful shutdown aborted with %d request(s) still active", activeRequests)); + logger.info(LogMessage.format("Graceful shutdown aborted with %d request%s still active", activeRequests, + (activeRequests == 1) ? "" : "s")); callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JasperInitializer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JasperInitializer.java index db5185247bd2..5af413adf02e 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JasperInitializer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JasperInitializer.java @@ -24,8 +24,8 @@ import java.net.URLStreamHandlerFactory; import jakarta.servlet.ServletContainerInitializer; +import org.eclipse.jetty.ee10.webapp.WebAppContext; import org.eclipse.jetty.util.component.AbstractLifeCycle; -import org.eclipse.jetty.webapp.WebAppContext; import org.springframework.util.ClassUtils; @@ -63,7 +63,6 @@ private ServletContainerInitializer newInitializer() { } @Override - @SuppressWarnings("deprecation") protected void doStart() throws Exception { if (this.initializer == null) { return; @@ -84,11 +83,11 @@ protected void doStart() throws Exception { try { Thread.currentThread().setContextClassLoader(this.context.getClassLoader()); try { - setExtendedListenerTypes(true); + this.context.getContext().setExtendedListenerTypes(true); this.initializer.onStartup(null, this.context.getServletContext()); } finally { - setExtendedListenerTypes(false); + this.context.getContext().setExtendedListenerTypes(false); } } finally { @@ -96,15 +95,6 @@ protected void doStart() throws Exception { } } - private void setExtendedListenerTypes(boolean extended) { - try { - this.context.getServletContext().setExtendedListenerTypes(extended); - } - catch (NoSuchMethodError ex) { - // Not available on Jetty 8 - } - } - /** * {@link URLStreamHandlerFactory} to support {@literal war} protocol. */ diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedErrorHandler.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedErrorHandler.java index 5871fb668acf..924a53242139 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedErrorHandler.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedErrorHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,17 +16,8 @@ package org.springframework.boot.web.embedded.jetty; -import java.io.IOException; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.jetty.ee10.servlet.ErrorPageErrorHandler; import org.eclipse.jetty.http.HttpMethod; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.servlet.ErrorPageErrorHandler; /** * Variation of Jetty's {@link ErrorPageErrorHandler} that supports all {@link HttpMethod @@ -40,20 +31,9 @@ */ class JettyEmbeddedErrorHandler extends ErrorPageErrorHandler { - private static final Set HANDLED_HTTP_METHODS = new HashSet<>(Arrays.asList("GET", "POST", "HEAD")); - @Override public boolean errorPageForMethod(String method) { return true; } - @Override - public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) - throws IOException, ServletException { - if (!HANDLED_HTTP_METHODS.contains(baseRequest.getMethod())) { - baseRequest.setMethod("GET"); - } - super.handle(target, baseRequest, request, response); - } - } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedWebAppContext.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedWebAppContext.java index 233105263857..d583483a3ddb 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedWebAppContext.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyEmbeddedWebAppContext.java @@ -16,8 +16,9 @@ package org.springframework.boot.web.embedded.jetty; -import org.eclipse.jetty.servlet.ServletHandler; -import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.ee10.servlet.ServletHandler; +import org.eclipse.jetty.ee10.webapp.ClassMatcher; +import org.eclipse.jetty.ee10.webapp.WebAppContext; /** * Jetty {@link WebAppContext} used by {@link JettyWebServer} to support deferred @@ -27,6 +28,11 @@ */ class JettyEmbeddedWebAppContext extends WebAppContext { + JettyEmbeddedWebAppContext() { + setServerClassMatcher(new ClassMatcher("org.springframework.boot.loader.")); + // setTempDirectory(WebInfConfiguration.getCanonicalNameForWebAppTmpDir(this)); + } + @Override protected ServletHandler newServletHandler() { return new JettyEmbeddedServletHandler(); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyHandlerWrappers.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyHandlerWrappers.java index 35a5286b81cb..bb61f81c6b79 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyHandlerWrappers.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyHandlerWrappers.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -16,15 +16,13 @@ package org.springframework.boot.web.embedded.jetty; -import java.io.IOException; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.HttpFields.Mutable; import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.handler.HandlerWrapper; +import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.handler.gzip.GzipHandler; +import org.eclipse.jetty.util.Callback; import org.springframework.boot.web.server.Compression; @@ -38,7 +36,7 @@ final class JettyHandlerWrappers { private JettyHandlerWrappers() { } - static HandlerWrapper createGzipHandlerWrapper(Compression compression) { + static Handler.Wrapper createGzipHandlerWrapper(Compression compression) { GzipHandler handler = new GzipHandler(); handler.setMinGzipSize((int) compression.getMinResponseSize().toBytes()); handler.setIncludedMimeTypes(compression.getMimeTypes()); @@ -48,14 +46,14 @@ static HandlerWrapper createGzipHandlerWrapper(Compression compression) { return handler; } - static HandlerWrapper createServerHeaderHandlerWrapper(String header) { + static Handler.Wrapper createServerHeaderHandlerWrapper(String header) { return new ServerHeaderHandler(header); } /** - * {@link HandlerWrapper} to add a custom {@code server} header. + * {@link Handler.Wrapper} to add a custom {@code server} header. */ - private static class ServerHeaderHandler extends HandlerWrapper { + private static class ServerHeaderHandler extends Handler.Wrapper { private static final String SERVER_HEADER = "server"; @@ -66,12 +64,12 @@ private static class ServerHeaderHandler extends HandlerWrapper { } @Override - public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) - throws IOException, ServletException { - if (!response.getHeaderNames().contains(SERVER_HEADER)) { - response.setHeader(SERVER_HEADER, this.value); + public boolean handle(Request request, Response response, Callback callback) throws Exception { + Mutable headers = response.getHeaders(); + if (!headers.contains(SERVER_HEADER)) { + headers.add(SERVER_HEADER, this.value); } - super.handle(target, baseRequest, request, response); + return super.handle(request, response, callback); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java index 3e8049f4d739..aad002cfcd37 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,18 +26,18 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory; import org.eclipse.jetty.server.AbstractConnector; import org.eclipse.jetty.server.ConnectionFactory; +import org.eclipse.jetty.server.ConnectionLimit; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.HandlerWrapper; import org.eclipse.jetty.server.handler.StatisticsHandler; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.util.thread.ThreadPool; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; @@ -55,6 +55,7 @@ * {@link ReactiveWebServerFactory} that can be used to create {@link JettyWebServer}s. * * @author Brian Clozel + * @author Moritz Halbritter * @since 2.0.0 */ public class JettyReactiveWebServerFactory extends AbstractReactiveWebServerFactory @@ -80,6 +81,8 @@ public class JettyReactiveWebServerFactory extends AbstractReactiveWebServerFact private ThreadPool threadPool; + private int maxConnections = -1; + /** * Create a new {@link JettyServletWebServerFactory} instance. */ @@ -118,6 +121,11 @@ public void addServerCustomizers(JettyServerCustomizer... customizers) { this.jettyServerCustomizers.addAll(Arrays.asList(customizers)); } + @Override + public void setMaxConnections(int maxConnections) { + this.maxConnections = maxConnections; + } + /** * Sets {@link JettyServerCustomizer}s that will be applied to the {@link Server} * before it is started. Calling this method will replace any existing customizers. @@ -176,10 +184,13 @@ protected Server createJettyServer(JettyHttpHandlerAdapter servlet) { server.setStopTimeout(0); ServletHolder servletHolder = new ServletHolder(servlet); servletHolder.setAsyncSupported(true); - ServletContextHandler contextHandler = new ServletContextHandler(server, "/", false, false); + ServletContextHandler contextHandler = new ServletContextHandler("/", false, false); contextHandler.addServlet(servletHolder, "/"); server.setHandler(addHandlerWrappers(contextHandler)); JettyReactiveWebServerFactory.logger.info("Server initialized with port: " + port); + if (this.maxConnections > -1) { + server.addBean(new ConnectionLimit(this.maxConnections, server)); + } if (Ssl.isEnabled(getSsl())) { customizeSsl(server, address); } @@ -194,6 +205,7 @@ protected Server createJettyServer(JettyHttpHandlerAdapter servlet) { statisticsHandler.setHandler(server.getHandler()); server.setHandler(statisticsHandler); } + server.setAttribute(org.springframework.boot.web.server.WebServerFactory.class.getName(), getClass()); return server; } @@ -231,7 +243,7 @@ private Handler addHandlerWrappers(Handler handler) { return handler; } - private Handler applyWrapper(Handler handler, HandlerWrapper wrapper) { + private Handler applyWrapper(Handler handler, Handler.Wrapper wrapper) { wrapper.setHandler(handler); return wrapper; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java index e060a372f07c..f7c34851b61a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,55 +20,69 @@ import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; -import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.nio.channels.ReadableByteChannel; +import java.nio.file.Path; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.EventListener; +import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Set; +import java.util.Spliterator; +import java.util.UUID; +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.HttpServletResponseWrapper; +import org.eclipse.jetty.ee10.servlet.ErrorHandler; +import org.eclipse.jetty.ee10.servlet.ErrorPageErrorHandler; +import org.eclipse.jetty.ee10.servlet.ListenerHolder; +import org.eclipse.jetty.ee10.servlet.ServletHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.ee10.servlet.ServletMapping; +import org.eclipse.jetty.ee10.servlet.SessionHandler; +import org.eclipse.jetty.ee10.servlet.Source; +import org.eclipse.jetty.ee10.webapp.AbstractConfiguration; +import org.eclipse.jetty.ee10.webapp.Configuration; +import org.eclipse.jetty.ee10.webapp.WebAppContext; +import org.eclipse.jetty.ee10.webapp.WebInfConfiguration; +import org.eclipse.jetty.http.CookieCompliance; import org.eclipse.jetty.http.HttpCookie; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpFields.Mutable; +import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.MimeTypes.Wrapper; +import org.eclipse.jetty.http.SetCookieParser; import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory; import org.eclipse.jetty.server.AbstractConnector; import org.eclipse.jetty.server.ConnectionFactory; +import org.eclipse.jetty.server.ConnectionLimit; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.HttpCookieUtils; import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.ErrorHandler; -import org.eclipse.jetty.server.handler.HandlerWrapper; import org.eclipse.jetty.server.handler.StatisticsHandler; -import org.eclipse.jetty.server.session.DefaultSessionCache; -import org.eclipse.jetty.server.session.FileSessionDataStore; -import org.eclipse.jetty.server.session.SessionHandler; -import org.eclipse.jetty.servlet.ErrorPageErrorHandler; -import org.eclipse.jetty.servlet.ListenerHolder; -import org.eclipse.jetty.servlet.ServletHandler; -import org.eclipse.jetty.servlet.ServletHolder; -import org.eclipse.jetty.servlet.ServletMapping; -import org.eclipse.jetty.servlet.Source; -import org.eclipse.jetty.util.resource.JarResource; +import org.eclipse.jetty.session.DefaultSessionCache; +import org.eclipse.jetty.session.FileSessionDataStore; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.resource.CombinedResource; import org.eclipse.jetty.util.resource.Resource; -import org.eclipse.jetty.util.resource.ResourceCollection; +import org.eclipse.jetty.util.resource.ResourceFactory; +import org.eclipse.jetty.util.resource.URLResourceFactory; import org.eclipse.jetty.util.thread.ThreadPool; -import org.eclipse.jetty.webapp.AbstractConfiguration; -import org.eclipse.jetty.webapp.Configuration; -import org.eclipse.jetty.webapp.WebAppContext; import org.springframework.boot.web.server.Cookie.SameSite; import org.springframework.boot.web.server.ErrorPage; @@ -101,6 +115,8 @@ * @author Eddú Meléndez * @author Venil Noronha * @author Henri Kerola + * @author Moritz Halbritter + * @author Onur Kagan Ozcan * @since 2.0.0 * @see #setPort(int) * @see #setConfigurations(Collection) @@ -129,6 +145,8 @@ public class JettyServletWebServerFactory extends AbstractServletWebServerFactor private ThreadPool threadPool; + private int maxConnections = -1; + /** * Create a new {@link JettyServletWebServerFactory} instance. */ @@ -157,12 +175,17 @@ public JettyServletWebServerFactory(String contextPath, int port) { @Override public WebServer getWebServer(ServletContextInitializer... initializers) { JettyEmbeddedWebAppContext context = new JettyEmbeddedWebAppContext(); + context.getContext().getServletContext().setExtendedListenerTypes(true); int port = Math.max(getPort(), 0); InetSocketAddress address = new InetSocketAddress(getAddress(), port); Server server = createServer(address); + context.setServer(server); configureWebAppContext(context, initializers); server.setHandler(addHandlerWrappers(context)); this.logger.info("Server initialized with port: " + port); + if (this.maxConnections > -1) { + server.addBean(new ConnectionLimit(this.maxConnections, server.getConnectors())); + } if (Ssl.isEnabled(getSsl())) { customizeSsl(server, address); } @@ -184,6 +207,10 @@ private Server createServer(InetSocketAddress address) { Server server = new Server(getThreadPool()); server.setConnectors(new Connector[] { createConnector(address, server) }); server.setStopTimeout(0); + MimeTypes.Mutable mimeTypes = server.getMimeTypes(); + for (MimeMappings.Mapping mapping : getMimeMappings()) { + mimeTypes.addMimeMapping(mapping.getExtension(), mapping.getMimeType()); + } return server; } @@ -215,7 +242,7 @@ private Handler addHandlerWrappers(Handler handler) { return handler; } - private Handler applyWrapper(Handler handler, HandlerWrapper wrapper) { + private Handler applyWrapper(Handler handler, Handler.Wrapper wrapper) { wrapper.setHandler(handler); return wrapper; } @@ -232,7 +259,6 @@ private void customizeSsl(Server server, InetSocketAddress address) { protected final void configureWebAppContext(WebAppContext context, ServletContextInitializer... initializers) { Assert.notNull(context, "Context must not be null"); context.clearAliasChecks(); - context.setTempDirectory(getTempDirectory()); if (this.resourceLoader != null) { context.setClassLoader(this.resourceLoader.getClassLoader()); } @@ -253,6 +279,7 @@ protected final void configureWebAppContext(WebAppContext context, ServletContex context.setConfigurations(configurations); context.setThrowUnavailableOnStartupException(true); configureSession(context); + context.setTempDirectory(getTempDirectory(context)); postProcessWebAppContext(context); } @@ -282,40 +309,49 @@ private void addLocaleMappings(WebAppContext context) { .forEach((locale, charset) -> context.addLocaleEncoding(locale.toString(), charset.toString())); } - private File getTempDirectory() { + private File getTempDirectory(WebAppContext context) { String temp = System.getProperty("java.io.tmpdir"); - return (temp != null) ? new File(temp) : null; + return (temp != null) + ? new File(temp, WebInfConfiguration.getCanonicalNameForWebAppTmpDir(context) + UUID.randomUUID()) + : null; } private void configureDocumentRoot(WebAppContext handler) { File root = getValidDocumentRoot(); File docBase = (root != null) ? root : createTempDir("jetty-docbase"); try { + ResourceFactory resourceFactory = handler.getResourceFactory(); List resources = new ArrayList<>(); - Resource rootResource = (docBase.isDirectory() ? Resource.newResource(docBase.getCanonicalFile()) - : JarResource.newJarResource(Resource.newResource(docBase))); - resources.add((root != null) ? new LoaderHidingResource(rootResource) : rootResource); + Resource rootResource = (docBase.isDirectory() + ? resourceFactory.newResource(docBase.getCanonicalFile().toURI()) + : resourceFactory.newJarFileResource(docBase.toURI())); + resources.add((root != null) ? new LoaderHidingResource(rootResource, rootResource) : rootResource); + URLResourceFactory urlResourceFactory = new URLResourceFactory(); for (URL resourceJarUrl : getUrlsOfJarsWithMetaInfResources()) { - Resource resource = createResource(resourceJarUrl); - if (resource.exists() && resource.isDirectory()) { + Resource resource = createResource(resourceJarUrl, resourceFactory, urlResourceFactory); + if (resource != null) { resources.add(resource); } } - handler.setBaseResource(new ResourceCollection(resources.toArray(new Resource[0]))); + handler.setBaseResource(ResourceFactory.combine(resources)); } catch (Exception ex) { throw new IllegalStateException(ex); } } - private Resource createResource(URL url) throws Exception { + private Resource createResource(URL url, ResourceFactory resourceFactory, URLResourceFactory urlResourceFactory) + throws Exception { if ("file".equals(url.getProtocol())) { File file = new File(url.toURI()); if (file.isFile()) { - return Resource.newResource("jar:" + url + "!/META-INF/resources"); + return resourceFactory.newResource("jar:" + url + "!/META-INF/resources/"); + } + if (file.isDirectory()) { + return resourceFactory.newResource(url).resolve("META-INF/resources/"); } } - return Resource.newResource(url + "META-INF/resources"); + return urlResourceFactory.newResource(url + "META-INF/resources/"); } /** @@ -326,7 +362,7 @@ protected final void addDefaultServlet(WebAppContext context) { Assert.notNull(context, "Context must not be null"); ServletHolder holder = new ServletHolder(); holder.setName("default"); - holder.setClassName("org.eclipse.jetty.servlet.DefaultServlet"); + holder.setClassName("org.eclipse.jetty.ee10.servlet.DefaultServlet"); holder.setInitParameter("dirAllowed", "false"); holder.setInitOrder(1); context.getServletHandler().addServletWithMapping(holder, "/"); @@ -375,7 +411,7 @@ protected Configuration[] getWebAppContextConfigurations(WebAppContext webAppCon * @return a configuration object for adding error pages */ private Configuration getErrorPageConfiguration() { - return new AbstractConfiguration() { + return new AbstractConfiguration(new AbstractConfiguration.Builder()) { @Override public void configure(WebAppContext context) throws Exception { @@ -392,11 +428,12 @@ public void configure(WebAppContext context) throws Exception { * @return a configuration object for adding mime type mappings */ private Configuration getMimeTypeConfiguration() { - return new AbstractConfiguration() { + return new AbstractConfiguration(new AbstractConfiguration.Builder()) { @Override public void configure(WebAppContext context) throws Exception { - MimeTypes mimeTypes = context.getMimeTypes(); + MimeTypes.Wrapper mimeTypes = (Wrapper) context.getMimeTypes(); + mimeTypes.setWrapped(new MimeTypes(null)); for (MimeMappings.Mapping mapping : getMimeMappings()) { mimeTypes.addMimeMapping(mapping.getExtension(), mapping.getMimeType()); } @@ -458,6 +495,11 @@ public void setSelectors(int selectors) { this.selectors = selectors; } + @Override + public void setMaxConnections(int maxConnections) { + this.maxConnections = maxConnections; + } + /** * Sets {@link JettyServerCustomizer}s that will be applied to the {@link Server} * before it is started. Calling this method will replace any existing customizers. @@ -546,28 +588,48 @@ private void addJettyErrorPages(ErrorHandler errorHandler, Collection private static final class LoaderHidingResource extends Resource { + private static final String LOADER_RESOURCE_PATH_PREFIX = "/org/springframework/boot/"; + + private final Resource base; + private final Resource delegate; - private LoaderHidingResource(Resource delegate) { + private LoaderHidingResource(Resource base, Resource delegate) { + this.base = base; this.delegate = delegate; } @Override - public Resource addPath(String path) throws IOException { - if (path.startsWith("/org/springframework/boot")) { - return null; + public void forEach(Consumer action) { + this.delegate.forEach(action); + } + + @Override + public Path getPath() { + return this.delegate.getPath(); + } + + @Override + public boolean isContainedIn(Resource r) { + return this.delegate.isContainedIn(r); + } + + @Override + public Iterator iterator() { + if (this.delegate instanceof CombinedResource) { + return list().iterator(); } - return this.delegate.addPath(path); + return List.of(this).iterator(); } @Override - public boolean isContainedIn(Resource resource) throws MalformedURLException { - return this.delegate.isContainedIn(resource); + public boolean equals(Object obj) { + return this.delegate.equals(obj); } @Override - public void close() { - this.delegate.close(); + public int hashCode() { + return this.delegate.hashCode(); } @Override @@ -575,13 +637,23 @@ public boolean exists() { return this.delegate.exists(); } + @Override + public Spliterator spliterator() { + return this.delegate.spliterator(); + } + @Override public boolean isDirectory() { return this.delegate.isDirectory(); } @Override - public long lastModified() { + public boolean isReadable() { + return this.delegate.isReadable(); + } + + @Override + public Instant lastModified() { return this.delegate.lastModified(); } @@ -596,38 +668,67 @@ public URI getURI() { } @Override - public File getFile() throws IOException { - return this.delegate.getFile(); + public String getName() { + return this.delegate.getName(); } @Override - public String getName() { - return this.delegate.getName(); + public String getFileName() { + return this.delegate.getFileName(); } @Override - public InputStream getInputStream() throws IOException { - return this.delegate.getInputStream(); + public InputStream newInputStream() throws IOException { + return this.delegate.newInputStream(); } @Override - public ReadableByteChannel getReadableByteChannel() throws IOException { - return this.delegate.getReadableByteChannel(); + public ReadableByteChannel newReadableByteChannel() throws IOException { + return this.delegate.newReadableByteChannel(); } @Override - public boolean delete() throws SecurityException { - return this.delegate.delete(); + public List list() { + return this.delegate.list().stream().filter(this::nonLoaderResource).toList(); + } + + private boolean nonLoaderResource(Resource resource) { + Path prefix = this.base.getPath().resolve(Path.of("org", "springframework", "boot")); + return !resource.getPath().startsWith(prefix); } @Override - public boolean renameTo(Resource dest) throws SecurityException { - return this.delegate.renameTo(dest); + public Resource resolve(String subUriPath) { + if (subUriPath.startsWith(LOADER_RESOURCE_PATH_PREFIX)) { + return null; + } + Resource resolved = this.delegate.resolve(subUriPath); + return (resolved != null) ? new LoaderHidingResource(this.base, resolved) : null; } @Override - public String[] list() { - return this.delegate.list(); + public boolean isAlias() { + return this.delegate.isAlias(); + } + + @Override + public URI getRealURI() { + return this.delegate.getRealURI(); + } + + @Override + public void copyTo(Path destination) throws IOException { + this.delegate.copyTo(destination); + } + + @Override + public Collection getAllResources() { + return this.delegate.getAllResources().stream().filter(this::nonLoaderResource).toList(); + } + + @Override + public String toString() { + return this.delegate.toString(); } } @@ -640,6 +741,7 @@ private static class WebListenersConfiguration extends AbstractConfiguration { private final Set classNames; WebListenersConfiguration(Set webListenerClassNames) { + super(new AbstractConfiguration.Builder()); this.classNames = webListenerClassNames; } @@ -669,10 +771,12 @@ private Class loadClass(WebAppContext context, String c } /** - * {@link HandlerWrapper} to apply {@link CookieSameSiteSupplier supplied} + * {@link Handler.Wrapper} to apply {@link CookieSameSiteSupplier supplied} * {@link SameSite} cookie values. */ - private static class SuppliedSameSiteCookieHandlerWrapper extends HandlerWrapper { + private static class SuppliedSameSiteCookieHandlerWrapper extends Handler.Wrapper { + + private static final SetCookieParser setCookieParser = SetCookieParser.newInstance(); private final List suppliers; @@ -681,46 +785,75 @@ private static class SuppliedSameSiteCookieHandlerWrapper extends HandlerWrapper } @Override - public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) - throws IOException, ServletException { - HttpServletResponse wrappedResponse = new ResponseWrapper(response); - super.handle(target, baseRequest, request, wrappedResponse); + public boolean handle(Request request, Response response, Callback callback) throws Exception { + SuppliedSameSiteCookieResponse wrappedResponse = new SuppliedSameSiteCookieResponse(request, response); + return super.handle(request, wrappedResponse, callback); + } + + private class SuppliedSameSiteCookieResponse extends Response.Wrapper { + + private HttpFields.Mutable wrappedHeaders; + + SuppliedSameSiteCookieResponse(Request request, Response wrapped) { + super(request, wrapped); + this.wrappedHeaders = new SuppliedSameSiteCookieHeaders( + request.getConnectionMetaData().getHttpConfiguration().getResponseCookieCompliance(), + wrapped.getHeaders()); + } + + @Override + public Mutable getHeaders() { + return this.wrappedHeaders; + } + } - class ResponseWrapper extends HttpServletResponseWrapper { + private class SuppliedSameSiteCookieHeaders extends HttpFields.Mutable.Wrapper { - ResponseWrapper(HttpServletResponse response) { - super(response); + private final CookieCompliance compliance; + + SuppliedSameSiteCookieHeaders(CookieCompliance compliance, HttpFields.Mutable fields) { + super(fields); + this.compliance = compliance; } @Override - @SuppressWarnings("removal") - public void addCookie(Cookie cookie) { - SameSite sameSite = getSameSite(cookie); - if (sameSite != null) { - String comment = HttpCookie.getCommentWithoutAttributes(cookie.getComment()); - String sameSiteComment = getSameSiteComment(sameSite); - cookie.setComment((comment != null) ? comment + sameSiteComment : sameSiteComment); + public HttpField onAddField(HttpField field) { + return (field.getHeader() != HttpHeader.SET_COOKIE) ? field : onAddSetCookieField(field); + } + + private HttpField onAddSetCookieField(HttpField field) { + HttpCookie cookie = setCookieParser.parse(field.getValue()); + SameSite sameSite = (cookie != null) ? getSameSite(cookie) : null; + if (sameSite == null) { + return field; } - super.addCookie(cookie); + HttpCookie updatedCookie = buildCookieWithUpdatedSameSite(cookie, sameSite); + return new HttpCookieUtils.SetCookieHttpField(updatedCookie, this.compliance); + } + + private HttpCookie buildCookieWithUpdatedSameSite(HttpCookie cookie, SameSite sameSite) { + return HttpCookie.build(cookie) + .sameSite(org.eclipse.jetty.http.HttpCookie.SameSite.from(sameSite.name())) + .build(); } - private String getSameSiteComment(SameSite sameSite) { - return switch (sameSite) { - case NONE -> HttpCookie.SAME_SITE_NONE_COMMENT; - case LAX -> HttpCookie.SAME_SITE_LAX_COMMENT; - case STRICT -> HttpCookie.SAME_SITE_STRICT_COMMENT; - }; + private SameSite getSameSite(HttpCookie cookie) { + return getSameSite(asServletCookie(cookie)); } private SameSite getSameSite(Cookie cookie) { - for (CookieSameSiteSupplier supplier : SuppliedSameSiteCookieHandlerWrapper.this.suppliers) { - SameSite sameSite = supplier.getSameSite(cookie); - if (sameSite != null) { - return sameSite; - } - } - return null; + return SuppliedSameSiteCookieHandlerWrapper.this.suppliers.stream() + .map((supplier) -> supplier.getSameSite(cookie)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private Cookie asServletCookie(HttpCookie cookie) { + Cookie servletCookie = new Cookie(cookie.getName(), cookie.getValue()); + cookie.getAttributes().forEach(servletCookie::setAttribute); + return servletCookie; } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java index 95ae2e3c2b67..acbf9f94e450 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyWebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.boot.web.embedded.jetty; import java.io.IOException; -import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -29,8 +28,6 @@ import org.eclipse.jetty.server.NetworkConnector; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.handler.ContextHandler; -import org.eclipse.jetty.server.handler.HandlerCollection; -import org.eclipse.jetty.server.handler.HandlerWrapper; import org.eclipse.jetty.server.handler.StatisticsHandler; import org.springframework.boot.web.server.GracefulShutdownCallback; @@ -38,6 +35,7 @@ import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; +import org.springframework.boot.web.server.WebServerFactory; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -106,7 +104,7 @@ private StatisticsHandler findStatisticsHandler(Handler handler) { if (handler instanceof StatisticsHandler statisticsHandler) { return statisticsHandler; } - if (handler instanceof HandlerWrapper handlerWrapper) { + if (handler instanceof Handler.Wrapper handlerWrapper) { return findStatisticsHandler(handlerWrapper.getHandler()); } return null; @@ -168,8 +166,7 @@ public void start() throws WebServerException { } } this.started = true; - logger.info("Jetty started on port(s) " + getActualPortsDescription() + " with context path '" - + getContextPath() + "'"); + logger.info(getStartedLogMessage()); } catch (WebServerException ex) { stopSilently(); @@ -182,15 +179,27 @@ public void start() throws WebServerException { } } + String getStartedLogMessage() { + String contextPath = getContextPath(); + return "Jetty started on " + getActualPortsDescription() + + ((contextPath != null) ? " with context path '" + contextPath + "'" : ""); + } + private String getActualPortsDescription() { - StringBuilder ports = new StringBuilder(); - for (Connector connector : this.server.getConnectors()) { - if (ports.length() != 0) { - ports.append(", "); + StringBuilder description = new StringBuilder("port"); + Connector[] connectors = this.server.getConnectors(); + if (connectors.length != 1) { + description.append("s"); + } + description.append(" "); + for (int i = 0; i < connectors.length; i++) { + if (i != 0) { + description.append(", "); } - ports.append(getLocalPort(connector)).append(getProtocols(connector)); + Connector connector = connectors[i]; + description.append(getLocalPort(connector)).append(getProtocols(connector)); } - return ports.toString(); + return description.toString(); } private String getProtocols(Connector connector) { @@ -199,7 +208,11 @@ private String getProtocols(Connector connector) { } private String getContextPath() { - return Arrays.stream(this.server.getHandlers()) + if (JettyReactiveWebServerFactory.class.equals(this.server.getAttribute(WebServerFactory.class.getName()))) { + return null; + } + return this.server.getHandlers() + .stream() .map(this::findContextHandler) .filter(Objects::nonNull) .map(ContextHandler::getContextPath) @@ -207,7 +220,7 @@ private String getContextPath() { } private ContextHandler findContextHandler(Handler handler) { - while (handler instanceof HandlerWrapper handlerWrapper) { + while (handler instanceof Handler.Wrapper handlerWrapper) { if (handler instanceof ContextHandler contextHandler) { return contextHandler; } @@ -216,17 +229,21 @@ private ContextHandler findContextHandler(Handler handler) { return null; } - private void handleDeferredInitialize(Handler... handlers) throws Exception { + private void handleDeferredInitialize(List handlers) throws Exception { for (Handler handler : handlers) { - if (handler instanceof JettyEmbeddedWebAppContext jettyEmbeddedWebAppContext) { - jettyEmbeddedWebAppContext.deferredInitialize(); - } - else if (handler instanceof HandlerWrapper handlerWrapper) { - handleDeferredInitialize(handlerWrapper.getHandler()); - } - else if (handler instanceof HandlerCollection handlerCollection) { - handleDeferredInitialize(handlerCollection.getHandlers()); - } + handleDeferredInitialize(handler); + } + } + + private void handleDeferredInitialize(Handler handler) throws Exception { + if (handler instanceof JettyEmbeddedWebAppContext jettyEmbeddedWebAppContext) { + jettyEmbeddedWebAppContext.deferredInitialize(); + } + else if (handler instanceof Handler.Wrapper handlerWrapper) { + handleDeferredInitialize(handlerWrapper.getHandler()); + } + else if (handler instanceof Handler.Collection handlerCollection) { + handleDeferredInitialize(handlerCollection.getHandlers()); } } @@ -238,7 +255,9 @@ public void stop() { this.gracefulShutdown.abort(); } try { - this.server.stop(); + for (Connector connector : this.server.getConnectors()) { + connector.stop(); + } } catch (InterruptedException ex) { Thread.currentThread().interrupt(); @@ -249,19 +268,31 @@ public void stop() { } } + @Override + public void destroy() { + synchronized (this.monitor) { + try { + this.server.stop(); + } + catch (Exception ex) { + throw new WebServerException("Unable to destroy embedded Jetty server", ex); + } + } + } + @Override public int getPort() { Connector[] connectors = this.server.getConnectors(); for (Connector connector : connectors) { - Integer localPort = getLocalPort(connector); - if (localPort != null && localPort > 0) { + int localPort = getLocalPort(connector); + if (localPort > 0) { return localPort; } } return -1; } - private Integer getLocalPort(Connector connector) { + private int getLocalPort(Connector connector) { if (connector instanceof NetworkConnector networkConnector) { return networkConnector.getLocalPort(); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ServletContextInitializerConfiguration.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ServletContextInitializerConfiguration.java index 2b3ecbafa56f..98a86fb5ba4b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ServletContextInitializerConfiguration.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/ServletContextInitializerConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -17,9 +17,9 @@ package org.springframework.boot.web.embedded.jetty; import jakarta.servlet.ServletException; -import org.eclipse.jetty.webapp.AbstractConfiguration; -import org.eclipse.jetty.webapp.Configuration; -import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.ee10.webapp.AbstractConfiguration; +import org.eclipse.jetty.ee10.webapp.Configuration; +import org.eclipse.jetty.ee10.webapp.WebAppContext; import org.springframework.boot.web.servlet.ServletContextInitializer; import org.springframework.util.Assert; @@ -41,6 +41,7 @@ public class ServletContextInitializerConfiguration extends AbstractConfiguratio * @since 1.2.1 */ public ServletContextInitializerConfiguration(ServletContextInitializer... initializers) { + super(new AbstractConfiguration.Builder()); Assert.notNull(initializers, "Initializers must not be null"); this.initializers = initializers; } @@ -59,22 +60,13 @@ public void configure(WebAppContext context) throws Exception { private void callInitializers(WebAppContext context) throws ServletException { try { - setExtendedListenerTypes(context, true); + context.getContext().setExtendedListenerTypes(true); for (ServletContextInitializer initializer : this.initializers) { initializer.onStartup(context.getServletContext()); } } finally { - setExtendedListenerTypes(context, false); - } - } - - private void setExtendedListenerTypes(WebAppContext context, boolean extended) { - try { - context.getServletContext().setExtendedListenerTypes(extended); - } - catch (NoSuchMethodError ex) { - // Not available on Jetty 8 + context.getContext().setExtendedListenerTypes(false); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/SslServerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/SslServerCustomizer.java index cffefa6f0985..f215f9d4419a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/SslServerCustomizer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/SslServerCustomizer.java @@ -96,7 +96,7 @@ private ServerConnector createServerConnector(Server server, SslContextFactory.S Assert.state(isJettyAlpnPresent(), () -> "An 'org.eclipse.jetty:jetty-alpn-*-server' dependency is required for HTTP/2 support."); Assert.state(isJettyHttp2Present(), - () -> "The 'org.eclipse.jetty.http2:http2-server' dependency is required for HTTP/2 support."); + () -> "The 'org.eclipse.jetty.http2:jetty-http2-server' dependency is required for HTTP/2 support."); return createHttp2ServerConnector(config, sslContextFactory, server); } @@ -111,19 +111,7 @@ private ServerConnector createHttp11ServerConnector(HttpConfiguration config, private SslConnectionFactory createSslConnectionFactory(SslContextFactory.Server sslContextFactory, String protocol) { - try { - return new SslConnectionFactory(sslContextFactory, protocol); - } - catch (NoSuchMethodError ex) { - // Jetty 10 - try { - return SslConnectionFactory.class.getConstructor(SslContextFactory.Server.class, String.class) - .newInstance(sslContextFactory, protocol); - } - catch (Exception ex2) { - throw new RuntimeException(ex2); - } - } + return new SslConnectionFactory(sslContextFactory, protocol); } private boolean isJettyAlpnPresent() { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java index f351da622fd4..ee8c014ec3d9 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java @@ -27,22 +27,23 @@ import reactor.netty.http.HttpProtocol; import reactor.netty.http.server.HttpServer; -import reactor.netty.resources.LoopResources; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.ReactiveWebServerFactory; import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.WebServer; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * {@link ReactiveWebServerFactory} that can be used to create {@link NettyWebServer}s. * * @author Brian Clozel + * @author Moritz Halbritter * @since 2.0.0 */ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFactory { @@ -78,7 +79,7 @@ public WebServer getWebServer(HttpHandler httpHandler) { NettyWebServer createNettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout, Shutdown shutdown) { - return new NettyWebServer(httpServer, handlerAdapter, lifecycleTimeout, shutdown); + return new NettyWebServer(httpServer, handlerAdapter, lifecycleTimeout, shutdown, this.resourceFactory); } /** @@ -158,15 +159,7 @@ public Shutdown getShutdown() { } private HttpServer createHttpServer() { - HttpServer server = HttpServer.create(); - if (this.resourceFactory != null) { - LoopResources resources = this.resourceFactory.getLoopResources(); - Assert.notNull(resources, "No LoopResources: is ReactorResourceFactory not initialized yet?"); - server = server.runOn(resources).bindAddress(this::getListenAddress); - } - else { - server = server.bindAddress(this::getListenAddress); - } + HttpServer server = HttpServer.create().bindAddress(this::getListenAddress); if (Ssl.isEnabled(getSsl())) { server = customizeSslConfiguration(server); } @@ -179,7 +172,12 @@ private HttpServer createHttpServer() { } private HttpServer customizeSslConfiguration(HttpServer httpServer) { - return new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle()).apply(httpServer); + SslServerCustomizer customizer = new SslServerCustomizer(getHttp2(), getSsl().getClientAuth(), getSslBundle()); + String bundleName = getSsl().getBundle(); + if (StringUtils.hasText(bundleName)) { + getSslBundles().addBundleUpdateHandler(bundleName, customizer::updateSslBundle); + } + return customizer.apply(httpServer); } private HttpProtocol[] listProtocols() { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java index c441b8837d0d..12948e48ac32 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyWebServer.java @@ -35,6 +35,7 @@ import reactor.netty.http.server.HttpServerRequest; import reactor.netty.http.server.HttpServerResponse; import reactor.netty.http.server.HttpServerRoutes; +import reactor.netty.resources.LoopResources; import org.springframework.boot.web.server.GracefulShutdownCallback; import org.springframework.boot.web.server.GracefulShutdownResult; @@ -42,6 +43,7 @@ import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.util.Assert; @@ -74,12 +76,39 @@ public class NettyWebServer implements WebServer { private final GracefulShutdown gracefulShutdown; + private final ReactorResourceFactory resourceFactory; + private List routeProviders = Collections.emptyList(); private volatile DisposableServer disposableServer; + /** + * Creates a new {@code NettyWebServer} instance. + * @param httpServer the HTTP server + * @param handlerAdapter the handler adapter + * @param lifecycleTimeout the lifecycle timeout, may be {@code null} + * @param shutdown the shutdown, may be {@code null} + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link #NettyWebServer(HttpServer, ReactorHttpHandlerAdapter, Duration, Shutdown, ReactorResourceFactory)} + */ + @Deprecated(since = "3.2.0", forRemoval = true) public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout, Shutdown shutdown) { + this(httpServer, handlerAdapter, lifecycleTimeout, shutdown, null); + } + + /** + * Creates a new {@code NettyWebServer} instance. + * @param httpServer the HTTP server + * @param handlerAdapter the handler adapter + * @param lifecycleTimeout the lifecycle timeout, may be {@code null} + * @param shutdown the shutdown, may be {@code null} + * @param resourceFactory the factory for the server's {@link LoopResources loop + * resources}, may be {@code null} + * @since 3.2.0 + */ + public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout, + Shutdown shutdown, ReactorResourceFactory resourceFactory) { Assert.notNull(httpServer, "HttpServer must not be null"); Assert.notNull(handlerAdapter, "HandlerAdapter must not be null"); this.lifecycleTimeout = lifecycleTimeout; @@ -87,6 +116,7 @@ public NettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAd this.httpServer = httpServer.channelGroup(new DefaultChannelGroup(new DefaultEventExecutor())); this.gracefulShutdown = (shutdown == Shutdown.GRACEFUL) ? new GracefulShutdown(() -> this.disposableServer) : null; + this.resourceFactory = resourceFactory; } public void setRouteProviders(List routeProviders) { @@ -108,7 +138,7 @@ public void start() throws WebServerException { throw new WebServerException("Unable to start Netty", ex); } if (this.disposableServer != null) { - logger.info("Netty started" + getStartedOnMessage(this.disposableServer)); + logger.info(getStartedOnMessage(this.disposableServer)); } startDaemonAwaitThread(this.disposableServer); } @@ -116,15 +146,20 @@ public void start() throws WebServerException { private String getStartedOnMessage(DisposableServer server) { StringBuilder message = new StringBuilder(); - tryAppend(message, "port %s", server::port); + tryAppend(message, "port %s", () -> server.port() + + ((this.httpServer.configuration().sslProvider() != null) ? " (https)" : " (http)")); tryAppend(message, "path %s", server::path); - return (message.length() > 0) ? " on " + message : ""; + return (!message.isEmpty()) ? "Netty started on " + message : "Netty started"; + } + + protected String getStartedLogMessage() { + return getStartedOnMessage(this.disposableServer); } private void tryAppend(StringBuilder message, String format, Supplier supplier) { try { Object value = supplier.get(); - message.append((message.length() != 0) ? " " : ""); + message.append((!message.isEmpty()) ? " " : ""); message.append(String.format(format, value)); } catch (UnsupportedOperationException ex) { @@ -140,6 +175,11 @@ DisposableServer startHttpServer() { else { server = server.route(this::applyRouteProviders); } + if (this.resourceFactory != null) { + LoopResources resources = this.resourceFactory.getLoopResources(); + Assert.notNull(resources, "No LoopResources: is ReactorResourceFactory not initialized yet?"); + server = server.runOn(resources); + } if (this.lifecycleTimeout != null) { return server.bindNow(this.lifecycleTimeout); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java index 5480c4d0c876..204868e06088 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/SslServerCustomizer.java @@ -17,10 +17,14 @@ package org.springframework.boot.web.embedded.netty; import io.netty.handler.ssl.ClientAuth; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import reactor.netty.http.Http11SslContextSpec; import reactor.netty.http.Http2SslContextSpec; import reactor.netty.http.server.HttpServer; import reactor.netty.tcp.AbstractProtocolSslContextSpec; +import reactor.netty.tcp.SslProvider; +import reactor.netty.tcp.SslProvider.SslContextSpec; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslOptions; @@ -36,41 +40,77 @@ * @author Chris Bono * @author Cyril Dangerville * @author Scott Frederick + * @author Moritz Halbritter + * @author Phillip Webb * @since 2.0.0 */ public class SslServerCustomizer implements NettyServerCustomizer { + private static final Log logger = LogFactory.getLog(SslServerCustomizer.class); + private final Http2 http2; - private final Ssl.ClientAuth clientAuth; + private final ClientAuth clientAuth; + + private volatile SslProvider sslProvider; - private final SslBundle sslBundle; + private volatile SslBundle sslBundle; public SslServerCustomizer(Http2 http2, Ssl.ClientAuth clientAuth, SslBundle sslBundle) { this.http2 = http2; - this.clientAuth = clientAuth; + this.clientAuth = Ssl.ClientAuth.map(clientAuth, ClientAuth.NONE, ClientAuth.OPTIONAL, ClientAuth.REQUIRE); this.sslBundle = sslBundle; + this.sslProvider = createSslProvider(sslBundle); } @Override public HttpServer apply(HttpServer server) { - AbstractProtocolSslContextSpec sslContextSpec = createSslContextSpec(); - return server.secure((spec) -> spec.sslContext(sslContextSpec)); + return server.secure(this::applySecurity); + } + + private void applySecurity(SslContextSpec spec) { + spec.sslContext(this.sslProvider.getSslContext()) + .setSniAsyncMappings((domainName, promise) -> promise.setSuccess(this.sslProvider)); } + void updateSslBundle(SslBundle sslBundle) { + logger.debug("SSL Bundle has been updated, reloading SSL configuration"); + this.sslBundle = sslBundle; + this.sslProvider = createSslProvider(sslBundle); + } + + private SslProvider createSslProvider(SslBundle sslBundle) { + return SslProvider.builder().sslContext(createSslContextSpec(sslBundle)).build(); + } + + /** + * Factory method used to create an {@link AbstractProtocolSslContextSpec}. + * @return the {@link AbstractProtocolSslContextSpec} to use + * @deprecated since 3.2.0 for removal in 3.4.0 in favor of + * {@link #createSslContextSpec(SslBundle)} + */ + @Deprecated(since = "3.2", forRemoval = true) protected AbstractProtocolSslContextSpec createSslContextSpec() { + return createSslContextSpec(this.sslBundle); + } + + /** + * Create an {@link AbstractProtocolSslContextSpec} for a given {@link SslBundle}. + * @param sslBundle the {@link SslBundle} to use + * @return an {@link AbstractProtocolSslContextSpec} instance + * @since 3.2.0 + */ + protected final AbstractProtocolSslContextSpec createSslContextSpec(SslBundle sslBundle) { AbstractProtocolSslContextSpec sslContextSpec = (this.http2 != null && this.http2.isEnabled()) - ? Http2SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory()) - : Http11SslContextSpec.forServer(this.sslBundle.getManagers().getKeyManagerFactory()); - sslContextSpec.configure((builder) -> { - builder.trustManager(this.sslBundle.getManagers().getTrustManagerFactory()); - SslOptions options = this.sslBundle.getOptions(); + ? Http2SslContextSpec.forServer(sslBundle.getManagers().getKeyManagerFactory()) + : Http11SslContextSpec.forServer(sslBundle.getManagers().getKeyManagerFactory()); + return sslContextSpec.configure((builder) -> { + builder.trustManager(sslBundle.getManagers().getTrustManagerFactory()); + SslOptions options = sslBundle.getOptions(); builder.protocols(options.getEnabledProtocols()); builder.ciphers(SslOptions.asSet(options.getCiphers())); - builder.clientAuth(org.springframework.boot.web.server.Ssl.ClientAuth.map(this.clientAuth, ClientAuth.NONE, - ClientAuth.OPTIONAL, ClientAuth.REQUIRE)); + builder.clientAuth(this.clientAuth); }); - return sslContextSpec; } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/GracefulShutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/GracefulShutdown.java index c921cf5c94aa..3215a0de8609 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/GracefulShutdown.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/GracefulShutdown.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -60,14 +60,14 @@ private void doShutdown(GracefulShutdownCallback callback) { try { for (Container host : this.tomcat.getEngine().findChildren()) { for (Container context : host.findChildren()) { - while (isActive(context)) { - if (this.aborted) { - logger.info("Graceful shutdown aborted with one or more requests still active"); - callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE); - return; - } + while (!this.aborted && isActive(context)) { Thread.sleep(50); } + if (this.aborted) { + logger.info("Graceful shutdown aborted with one or more requests still active"); + callback.shutdownComplete(GracefulShutdownResult.REQUESTS_ACTIVE); + return; + } } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/NestedJarResourceSet.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/NestedJarResourceSet.java new file mode 100644 index 000000000000..47a9f5b5711e --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/NestedJarResourceSet.java @@ -0,0 +1,161 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.web.embedded.tomcat; + +import java.io.IOException; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.util.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +import org.apache.catalina.LifecycleException; +import org.apache.catalina.WebResource; +import org.apache.catalina.WebResourceRoot; +import org.apache.catalina.WebResourceSet; +import org.apache.catalina.webresources.AbstractSingleArchiveResourceSet; +import org.apache.catalina.webresources.JarResource; + +import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; + +/** + * A {@link WebResourceSet} for a resource in a nested JAR. + * + * @author Phillip Webb + */ +class NestedJarResourceSet extends AbstractSingleArchiveResourceSet { + + private static final Name MULTI_RELEASE = new Name("Multi-Release"); + + private final URL url; + + private JarFile archive = null; + + private long archiveUseCount = 0; + + private boolean useCaches; + + private volatile Boolean multiRelease; + + NestedJarResourceSet(URL url, WebResourceRoot root, String webAppMount, String internalPath) + throws IllegalArgumentException { + this.url = url; + setRoot(root); + setWebAppMount(webAppMount); + setInternalPath(internalPath); + setStaticOnly(true); + if (getRoot().getState().isAvailable()) { + try { + start(); + } + catch (LifecycleException ex) { + throw new IllegalStateException(ex); + } + } + } + + @Override + protected WebResource createArchiveResource(JarEntry jarEntry, String webAppPath, Manifest manifest) { + return new JarResource(this, webAppPath, getBaseUrlString(), jarEntry); + } + + @Override + protected void initInternal() throws LifecycleException { + try { + JarURLConnection connection = connect(); + try { + setManifest(connection.getManifest()); + setBaseUrl(connection.getJarFileURL()); + } + finally { + if (!connection.getUseCaches()) { + connection.getJarFile().close(); + } + } + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + protected JarFile openJarFile() throws IOException { + synchronized (this.archiveLock) { + if (this.archive == null) { + JarURLConnection connection = connect(); + this.useCaches = connection.getUseCaches(); + this.archive = connection.getJarFile(); + } + this.archiveUseCount++; + return this.archive; + } + } + + @Override + protected void closeJarFile() { + synchronized (this.archiveLock) { + this.archiveUseCount--; + } + } + + @Override + protected boolean isMultiRelease() { + if (this.multiRelease == null) { + synchronized (this.archiveLock) { + if (this.multiRelease == null) { + // JarFile.isMultiRelease() is final so we must go to the manifest + Manifest manifest = getManifest(); + Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null; + this.multiRelease = (attributes != null) && attributes.containsKey(MULTI_RELEASE); + } + } + } + return this.multiRelease.booleanValue(); + } + + @Override + public void gc() { + synchronized (this.archiveLock) { + if (this.archive != null && this.archiveUseCount == 0) { + try { + if (!this.useCaches) { + this.archive.close(); + } + } + catch (IOException ex) { + // Ignore + } + this.archive = null; + this.archiveEntries = null; + } + } + } + + private JarURLConnection connect() throws IOException { + URLConnection connection = this.url.openConnection(); + ResourceUtils.useCachesIfNecessary(connection); + Assert.state(connection instanceof JarURLConnection, + () -> "URL '%s' did not return a JAR connection".formatted(this.url)); + connection.connect(); + return (JarURLConnection) connection; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java index 516c61db0084..75601111c1df 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizer.java @@ -17,6 +17,7 @@ package org.springframework.boot.web.embedded.tomcat; import org.apache.catalina.connector.Connector; +import org.apache.commons.logging.Log; import org.apache.coyote.ProtocolHandler; import org.apache.coyote.http11.AbstractHttp11JsseProtocol; import org.apache.coyote.http11.Http11NioProtocol; @@ -33,48 +34,62 @@ import org.springframework.util.StringUtils; /** - * {@link TomcatConnectorCustomizer} that configures SSL support on the given connector. + * Utility that configures SSL support on the given connector. * * @author Brian Clozel * @author Andy Wilkinson * @author Scott Frederick * @author Cyril Dangerville + * @author Moritz Halbritter */ -class SslConnectorCustomizer implements TomcatConnectorCustomizer { +class SslConnectorCustomizer { + + private final Log logger; private final ClientAuth clientAuth; - private final SslBundle sslBundle; + private final Connector connector; - SslConnectorCustomizer(ClientAuth clientAuth, SslBundle sslBundle) { + SslConnectorCustomizer(Log logger, Connector connector, ClientAuth clientAuth) { + this.logger = logger; this.clientAuth = clientAuth; - this.sslBundle = sslBundle; + this.connector = connector; + } + + void update(SslBundle updatedSslBundle) { + this.logger.debug("SSL Bundle has been updated, reloading SSL configuration"); + customize(updatedSslBundle); } - @Override - public void customize(Connector connector) { - ProtocolHandler handler = connector.getProtocolHandler(); + void customize(SslBundle sslBundle) { + ProtocolHandler handler = this.connector.getProtocolHandler(); Assert.state(handler instanceof AbstractHttp11JsseProtocol, "To use SSL, the connector's protocol handler must be an AbstractHttp11JsseProtocol subclass"); - configureSsl((AbstractHttp11JsseProtocol) handler); - connector.setScheme("https"); - connector.setSecure(true); + configureSsl(sslBundle, (AbstractHttp11JsseProtocol) handler); + this.connector.setScheme("https"); + this.connector.setSecure(true); } /** * Configure Tomcat's {@link AbstractHttp11JsseProtocol} for SSL. + * @param sslBundle the SSL bundle * @param protocol the protocol */ - void configureSsl(AbstractHttp11JsseProtocol protocol) { - SslBundleKey key = this.sslBundle.getKey(); - SslStoreBundle stores = this.sslBundle.getStores(); - SslOptions options = this.sslBundle.getOptions(); + private void configureSsl(SslBundle sslBundle, AbstractHttp11JsseProtocol protocol) { protocol.setSSLEnabled(true); SSLHostConfig sslHostConfig = new SSLHostConfig(); sslHostConfig.setHostName(protocol.getDefaultSSLHostConfigName()); - sslHostConfig.setSslProtocol(this.sslBundle.getProtocol()); - protocol.addSslHostConfig(sslHostConfig); configureSslClientAuth(sslHostConfig); + applySslBundle(sslBundle, protocol, sslHostConfig); + protocol.addSslHostConfig(sslHostConfig, true); + } + + private void applySslBundle(SslBundle sslBundle, AbstractHttp11JsseProtocol protocol, + SSLHostConfig sslHostConfig) { + SslBundleKey key = sslBundle.getKey(); + SslStoreBundle stores = sslBundle.getStores(); + SslOptions options = sslBundle.getOptions(); + sslHostConfig.setSslProtocol(sslBundle.getProtocol()); SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(sslHostConfig, Type.UNDEFINED); String keystorePassword = (stores.getKeyStorePassword() != null) ? stores.getKeyStorePassword() : ""; certificate.setCertificateKeystorePassword(keystorePassword); @@ -89,17 +104,14 @@ void configureSsl(AbstractHttp11JsseProtocol protocol) { String ciphers = StringUtils.arrayToCommaDelimitedString(options.getCiphers()); sslHostConfig.setCiphers(ciphers); } - configureEnabledProtocols(protocol); - configureSslStoreProvider(protocol, sslHostConfig, certificate); + configureSslStoreProvider(protocol, sslHostConfig, certificate, stores); + configureEnabledProtocols(sslHostConfig, options); } - private void configureEnabledProtocols(AbstractHttp11JsseProtocol protocol) { - SslOptions options = this.sslBundle.getOptions(); + private void configureEnabledProtocols(SSLHostConfig sslHostConfig, SslOptions options) { if (options.getEnabledProtocols() != null) { String enabledProtocols = StringUtils.arrayToDelimitedString(options.getEnabledProtocols(), "+"); - for (SSLHostConfig sslHostConfig : protocol.findSslHostConfigs()) { - sslHostConfig.setProtocols(enabledProtocols); - } + sslHostConfig.setProtocols(enabledProtocols); } } @@ -107,12 +119,11 @@ private void configureSslClientAuth(SSLHostConfig config) { config.setCertificateVerification(ClientAuth.map(this.clientAuth, "none", "optional", "required")); } - protected void configureSslStoreProvider(AbstractHttp11JsseProtocol protocol, SSLHostConfig sslHostConfig, - SSLHostConfigCertificate certificate) { + private void configureSslStoreProvider(AbstractHttp11JsseProtocol protocol, SSLHostConfig sslHostConfig, + SSLHostConfigCertificate certificate, SslStoreBundle stores) { Assert.isInstanceOf(Http11NioProtocol.class, protocol, "SslStoreProvider can only be used with Http11NioProtocol"); try { - SslStoreBundle stores = this.sslBundle.getStores(); if (stores.getKeyStore() != null) { certificate.setCertificateKeystore(stores.getKeyStore()); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java index 40dbd18cb06e..228135bfc0bf 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import org.apache.catalina.Context; import org.apache.catalina.Engine; +import org.apache.catalina.Executor; import org.apache.catalina.Host; import org.apache.catalina.LifecycleListener; import org.apache.catalina.Valve; @@ -35,6 +36,8 @@ import org.apache.catalina.core.AprLifecycleListener; import org.apache.catalina.loader.WebappLoader; import org.apache.catalina.startup.Tomcat; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.coyote.AbstractProtocol; import org.apache.coyote.ProtocolHandler; import org.apache.coyote.http2.Http2Protocol; @@ -57,11 +60,14 @@ * * @author Brian Clozel * @author HaiTao Zhang + * @author Moritz Halbritter * @since 2.0.0 */ public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFactory implements ConfigurableTomcatWebServerFactory { + private static final Log logger = LogFactory.getLog(TomcatReactiveWebServerFactory.class); + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; /** @@ -130,16 +136,24 @@ public WebServer getWebServer(HttpHandler httpHandler) { tomcat.getService().addConnector(connector); customizeConnector(connector); tomcat.setConnector(connector); + registerConnectorExecutor(tomcat, connector); tomcat.getHost().setAutoDeploy(false); configureEngine(tomcat.getEngine()); for (Connector additionalConnector : this.additionalTomcatConnectors) { tomcat.getService().addConnector(additionalConnector); + registerConnectorExecutor(tomcat, additionalConnector); } TomcatHttpHandlerAdapter servlet = new TomcatHttpHandlerAdapter(httpHandler); prepareContext(tomcat.getHost(), servlet); return getTomcatWebServer(tomcat); } + private void registerConnectorExecutor(Tomcat tomcat, Connector connector) { + if (connector.getProtocolHandler().getExecutor() instanceof Executor executor) { + tomcat.getService().addExecutor(executor); + } + } + private void configureEngine(Engine engine) { engine.setBackgroundProcessorDelay(this.backgroundProcessorDelay); for (Valve valve : this.engineValves) { @@ -195,8 +209,6 @@ protected void customizeConnector(Connector connector) { if (getUriEncoding() != null) { connector.setURIEncoding(getUriEncoding().name()); } - // Don't bind to the socket prematurely if ApplicationContext is slow to start - connector.setProperty("bindOnInit", "false"); if (getHttp2() != null && getHttp2().isEnabled()) { connector.addUpgradeProtocol(new Http2Protocol()); } @@ -224,7 +236,12 @@ private void customizeProtocol(AbstractProtocol protocol) { } private void customizeSsl(Connector connector) { - new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(logger, connector, getSsl().getClientAuth()); + customizer.customize(getSslBundle()); + String sslBundleName = getSsl().getBundle(); + if (StringUtils.hasText(sslBundleName)) { + getSslBundles().addBundleUpdateHandler(sslBundleName, customizer::update); + } } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java index d244d200526c..ae6ccf95fbd9 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java @@ -19,6 +19,7 @@ import java.io.File; import java.io.InputStream; import java.lang.reflect.Method; +import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; @@ -38,6 +39,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.apache.catalina.Context; import org.apache.catalina.Engine; +import org.apache.catalina.Executor; import org.apache.catalina.Host; import org.apache.catalina.Lifecycle; import org.apache.catalina.LifecycleEvent; @@ -46,6 +48,7 @@ import org.apache.catalina.Manager; import org.apache.catalina.Valve; import org.apache.catalina.WebResource; +import org.apache.catalina.WebResourceRoot; import org.apache.catalina.WebResourceRoot.ResourceSetType; import org.apache.catalina.WebResourceSet; import org.apache.catalina.Wrapper; @@ -60,6 +63,8 @@ import org.apache.catalina.webresources.AbstractResourceSet; import org.apache.catalina.webresources.EmptyResource; import org.apache.catalina.webresources.StandardRoot; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.coyote.AbstractProtocol; import org.apache.coyote.ProtocolHandler; import org.apache.coyote.http2.Http2Protocol; @@ -101,6 +106,7 @@ * @author Eddú Meléndez * @author Christoffer Sawicki * @author Dawid Antecki + * @author Moritz Halbritter * @since 2.0.0 * @see #setPort(int) * @see #setContextLifecycleListeners(Collection) @@ -109,6 +115,8 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFactory implements ConfigurableTomcatWebServerFactory, ResourceLoaderAware { + private static final Log logger = LogFactory.getLog(TomcatServletWebServerFactory.class); + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; private static final Set> NO_CLASSES = Collections.emptySet(); @@ -202,15 +210,23 @@ public WebServer getWebServer(ServletContextInitializer... initializers) { tomcat.getService().addConnector(connector); customizeConnector(connector); tomcat.setConnector(connector); + registerConnectorExecutor(tomcat, connector); tomcat.getHost().setAutoDeploy(false); configureEngine(tomcat.getEngine()); for (Connector additionalConnector : this.additionalTomcatConnectors) { tomcat.getService().addConnector(additionalConnector); + registerConnectorExecutor(tomcat, additionalConnector); } prepareContext(tomcat.getHost(), initializers); return getTomcatWebServer(tomcat); } + private void registerConnectorExecutor(Tomcat tomcat, Connector connector) { + if (connector.getProtocolHandler().getExecutor() instanceof Executor executor) { + tomcat.getService().addExecutor(executor); + } + } + private void configureEngine(Engine engine) { engine.setBackgroundProcessorDelay(this.backgroundProcessorDelay); for (Valve valve : this.engineValves) { @@ -335,8 +351,6 @@ protected void customizeConnector(Connector connector) { if (getUriEncoding() != null) { connector.setURIEncoding(getUriEncoding().name()); } - // Don't bind to the socket prematurely if ApplicationContext is slow to start - connector.setProperty("bindOnInit", "false"); if (getHttp2() != null && getHttp2().isEnabled()) { connector.addUpgradeProtocol(new Http2Protocol()); } @@ -364,7 +378,12 @@ private void invokeProtocolHandlerCustomizers(ProtocolHandler protocolHandler) { } private void customizeSsl(Connector connector) { - new SslConnectorCustomizer(getSsl().getClientAuth(), getSslBundle()).customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(logger, connector, getSsl().getClientAuth()); + customizer.customize(getSslBundle()); + String sslBundleName = getSsl().getBundle(); + if (StringUtils.hasText(sslBundleName)) { + getSslBundles().addBundleUpdateHandler(sslBundleName, customizer::update); + } } /** @@ -775,6 +794,10 @@ public void lifecycleEvent(LifecycleEvent event) { private final class StaticResourceConfigurer implements LifecycleListener { + private static final String WEB_APP_MOUNT = "/"; + + private static final String INTERNAL_PATH = "/META-INF/resources"; + private final Context context; private StaticResourceConfigurer(Context context) { @@ -807,23 +830,39 @@ private void addResourceJars(List resourceJarUrls) { private void addResourceSet(String resource) { try { - if (isInsideNestedJar(resource)) { - // It's a nested jar but we now don't want the suffix because Tomcat - // is going to try and locate it as a root URL (not the resource - // inside it) - resource = resource.substring(0, resource.length() - 2); + if (isInsideClassicNestedJar(resource)) { + addClassicNestedResourceSet(resource); + return; } + WebResourceRoot root = this.context.getResources(); URL url = new URL(resource); - String path = "/META-INF/resources"; - this.context.getResources().createWebResourceSet(ResourceSetType.RESOURCE_JAR, "/", url, path); + if (isInsideNestedJar(resource)) { + root.addJarResources(new NestedJarResourceSet(url, root, WEB_APP_MOUNT, INTERNAL_PATH)); + } + else { + root.createWebResourceSet(ResourceSetType.RESOURCE_JAR, WEB_APP_MOUNT, url, INTERNAL_PATH); + } } catch (Exception ex) { // Ignore (probably not a directory) } } - private boolean isInsideNestedJar(String dir) { - return dir.indexOf("!/") < dir.lastIndexOf("!/"); + private void addClassicNestedResourceSet(String resource) throws MalformedURLException { + // It's a nested jar but we now don't want the suffix because Tomcat + // is going to try and locate it as a root URL (not the resource + // inside it) + URL url = new URL(resource.substring(0, resource.length() - 2)); + this.context.getResources() + .createWebResourceSet(ResourceSetType.RESOURCE_JAR, WEB_APP_MOUNT, url, INTERNAL_PATH); + } + + private boolean isInsideClassicNestedJar(String resource) { + return !isInsideNestedJar(resource) && resource.indexOf("!/") < resource.lastIndexOf("!/"); + } + + private boolean isInsideNestedJar(String resource) { + return resource.startsWith("jar:nested:"); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java index 4c43a38d4520..baeb5cc4a9e7 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import java.util.stream.Collectors; import javax.naming.NamingException; @@ -31,6 +32,7 @@ import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.Service; +import org.apache.catalina.Wrapper; import org.apache.catalina.connector.Connector; import org.apache.catalina.startup.Tomcat; import org.apache.commons.logging.Log; @@ -44,6 +46,7 @@ import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * {@link WebServer} that can be used to control a Tomcat web server. Usually this class @@ -105,7 +108,7 @@ public TomcatWebServer(Tomcat tomcat, boolean autoStart, Shutdown shutdown) { } private void initialize() throws WebServerException { - logger.info("Tomcat initialized with port(s): " + getPortsDescription(false)); + logger.info("Tomcat initialized with " + getPortsDescription(false)); synchronized (this.monitor) { try { addInstanceIdToEngineName(); @@ -119,6 +122,8 @@ private void initialize() throws WebServerException { } }); + disableBindOnInit(); + // Start the server to trigger initialization listeners this.tomcat.start(); @@ -162,12 +167,29 @@ private void addInstanceIdToEngineName() { } private void removeServiceConnectors() { - for (Service service : this.tomcat.getServer().findServices()) { - Connector[] connectors = service.findConnectors().clone(); + doWithConnectors((service, connectors) -> { this.serviceConnectors.put(service, connectors); for (Connector connector : connectors) { service.removeConnector(connector); } + }); + } + + private void disableBindOnInit() { + doWithConnectors((service, connectors) -> { + for (Connector connector : connectors) { + Object bindOnInit = connector.getProperty("bindOnInit"); + if (bindOnInit == null) { + connector.setProperty("bindOnInit", "false"); + } + } + }); + } + + private void doWithConnectors(BiConsumer consumer) { + for (Service service : this.tomcat.getServer().findServices()) { + Connector[] connectors = service.findConnectors().clone(); + consumer.accept(service, connectors); } } @@ -209,6 +231,7 @@ public void start() throws WebServerException { if (this.started) { return; } + try { addPreviouslyRemovedConnectors(); Connector connector = this.tomcat.getConnector(); @@ -217,8 +240,7 @@ public void start() throws WebServerException { } checkThatConnectorsHaveStarted(); this.started = true; - logger.info("Tomcat started on port(s): " + getPortsDescription(true) + " with context path '" - + getContextPath() + "'"); + logger.info(getStartedLogMessage()); } catch (ConnectorStartFailedException ex) { stopSilently(); @@ -235,6 +257,12 @@ public void start() throws WebServerException { } } + String getStartedLogMessage() { + String contextPath = getContextPath(); + return "Tomcat started on " + getPortsDescription(true) + + ((contextPath != null) ? " with context path '" + contextPath + "'" : ""); + } + private void checkThatConnectorsHaveStarted() { checkConnectorHasStarted(this.tomcat.getConnector()); for (Connector connector : this.tomcat.getService().findConnectors()) { @@ -324,16 +352,10 @@ public void stop() throws WebServerException { boolean wasStarted = this.started; try { this.started = false; - try { - if (this.gracefulShutdown != null) { - this.gracefulShutdown.abort(); - } - stopTomcat(); - this.tomcat.destroy(); - } - catch (LifecycleException ex) { - // swallow and continue + if (this.gracefulShutdown != null) { + this.gracefulShutdown.abort(); } + removeServiceConnectors(); } catch (Exception ex) { throw new WebServerException("Unable to stop embedded Tomcat", ex); @@ -346,16 +368,37 @@ public void stop() throws WebServerException { } } + @Override + public void destroy() throws WebServerException { + try { + stopTomcat(); + this.tomcat.destroy(); + } + catch (LifecycleException ex) { + // Swallow and continue + } + catch (Exception ex) { + throw new WebServerException("Unable to destroy embedded Tomcat", ex); + } + } + private String getPortsDescription(boolean localPort) { - StringBuilder ports = new StringBuilder(); - for (Connector connector : this.tomcat.getService().findConnectors()) { - if (ports.length() != 0) { - ports.append(' '); + StringBuilder description = new StringBuilder(); + Connector[] connectors = this.tomcat.getService().findConnectors(); + description.append("port"); + if (connectors.length != 1) { + description.append("s"); + } + description.append(" "); + for (int i = 0; i < connectors.length; i++) { + if (i != 0) { + description.append(", "); } + Connector connector = connectors[i]; int port = localPort ? connector.getLocalPort() : connector.getPort(); - ports.append(port).append(" (").append(connector.getScheme()).append(')'); + description.append(port).append(" (").append(connector.getScheme()).append(')'); } - return ports.toString(); + return description.toString(); } @Override @@ -368,11 +411,26 @@ public int getPort() { } private String getContextPath() { - return Arrays.stream(this.tomcat.getHost().findChildren()) + String contextPath = Arrays.stream(this.tomcat.getHost().findChildren()) .filter(TomcatEmbeddedContext.class::isInstance) .map(TomcatEmbeddedContext.class::cast) + .filter(this::imperative) .map(TomcatEmbeddedContext::getPath) + .map((path) -> path.equals("") ? "/" : path) .collect(Collectors.joining(" ")); + return StringUtils.hasText(contextPath) ? contextPath : null; + } + + private boolean imperative(TomcatEmbeddedContext context) { + for (Container container : context.findChildren()) { + if (container instanceof Wrapper wrapper) { + if (wrapper.getServletClass() + .equals("org.springframework.http.server.reactive.TomcatHttpHandlerAdapter")) { + return false; + } + } + } + return true; } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java index 11ce72755bae..56ffd9416aed 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,11 +78,12 @@ protected HttpHandler createHttpHandler() { @Override protected String getStartLogMessage() { - String message = super.getStartLogMessage(); - if (StringUtils.hasText(this.contextPath)) { - message += " with context path '" + this.contextPath + "'"; - } - return message; + String contextPath = StringUtils.hasText(this.contextPath) ? this.contextPath : "/"; + StringBuilder message = new StringBuilder(super.getStartLogMessage()); + message.append(" with context path '"); + message.append(contextPath); + message.append("'"); + return message.toString(); } public DeploymentManager getDeploymentManager() { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java index c4045af0cad3..20a34d020eb3 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServer.java @@ -132,13 +132,13 @@ public void start() throws WebServerException { throw new WebServerException("Unable to start embedded Undertow", ex); } finally { - stopSilently(); + destroySilently(); } } } } - private void stopSilently() { + private void destroySilently() { try { if (this.undertow != null) { this.undertow.stop(); @@ -183,11 +183,20 @@ protected HttpHandler createHttpHandler() { } private String getPortsDescription() { + StringBuilder description = new StringBuilder(); List ports = getActualPorts(); + description.append("port"); + if (ports.size() != 1) { + description.append("s"); + } + description.append(" "); if (!ports.isEmpty()) { - return StringUtils.collectionToDelimitedString(ports, " "); + description.append(StringUtils.collectionToDelimitedString(ports, ", ")); + } + else { + description.append("unknown"); } - return "unknown"; + return description.toString(); } private List getActualPorts() { @@ -275,7 +284,7 @@ public void stop() throws WebServerException { } } catch (Exception ex) { - throw new WebServerException("Unable to stop undertow", ex); + throw new WebServerException("Unable to stop Undertow", ex); } } } @@ -316,7 +325,7 @@ private void notifyGracefulCallback(boolean success) { } protected String getStartLogMessage() { - return "Undertow started on port(s) " + getPortsDescription(); + return "Undertow started on " + getPortsDescription(); } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/ErrorAttributeOptions.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/ErrorAttributeOptions.java index c32f683276e3..faaa61f31e7b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/ErrorAttributeOptions.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/ErrorAttributeOptions.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,7 +88,7 @@ private EnumSet copyIncludes() { * @return an {@code ErrorAttributeOptions} */ public static ErrorAttributeOptions defaults() { - return of(); + return of(Include.PATH); } /** @@ -135,7 +135,13 @@ public enum Include { /** * Include the binding errors attribute. */ - BINDING_ERRORS + BINDING_ERRORS, + + /** + * Include the request path. + * @since 3.3.0 + */ + PATH } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java index d55366b53c4a..564f16e8fa86 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,6 +57,7 @@ * @author Stephane Nicoll * @author Michele Mancioppi * @author Scott Frederick + * @author Moritz Halbritter * @since 2.0.0 * @see ErrorAttributes */ @@ -79,13 +80,16 @@ public Map getErrorAttributes(ServerRequest request, ErrorAttrib if (!options.isIncluded(Include.BINDING_ERRORS)) { errorAttributes.remove("errors"); } + if (!options.isIncluded(Include.PATH)) { + errorAttributes.remove("path"); + } return errorAttributes; } private Map getErrorAttributes(ServerRequest request, boolean includeStackTrace) { Map errorAttributes = new LinkedHashMap<>(); errorAttributes.put("timestamp", new Date()); - errorAttributes.put("path", request.path()); + errorAttributes.put("path", request.requestPath().value()); Throwable error = getError(request); MergedAnnotation responseStatusAnnotation = MergedAnnotations .from(error.getClass(), SearchStrategy.TYPE_HIERARCHY) @@ -152,7 +156,6 @@ private void handleException(Map errorAttributes, Throwable erro @Override public Throwable getError(ServerRequest request) { Optional error = request.attribute(ERROR_INTERNAL_ATTRIBUTE); - error.ifPresent((value) -> request.attributes().putIfAbsent(ErrorAttributes.ERROR_ATTRIBUTE, value)); return (Throwable) error .orElseThrow(() -> new IllegalStateException("Missing exception attribute in ServerWebExchange")); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/ErrorAttributes.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/ErrorAttributes.java index 3c9a1d5567ba..aab94b4e31ec 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/ErrorAttributes.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/error/ErrorAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -34,13 +34,6 @@ */ public interface ErrorAttributes { - /** - * Name of the {@link ServerRequest#attribute(String) request attribute} holding the - * error resolved by the {@code ErrorAttributes} implementation. - * @since 2.5.0 - */ - String ERROR_ATTRIBUTE = ErrorAttributes.class.getName() + ".error"; - /** * Return a {@link Map} of the error attributes. The map can be used as the model of * an error page, or returned as a {@link ServerResponse} body. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java index 4a23afd75332..b77b4f56c269 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,9 +51,6 @@ public abstract class AbstractConfigurableWebServerFactory implements Configurab private Ssl ssl; - @SuppressWarnings("removal") - private SslStoreProvider sslStoreProvider; - private SslBundles sslBundles; private Http2 http2; @@ -135,15 +132,13 @@ public void setSsl(Ssl ssl) { this.ssl = ssl; } - @SuppressWarnings("removal") - public SslStoreProvider getSslStoreProvider() { - return this.sslStoreProvider; - } - - @Override - @SuppressWarnings("removal") - public void setSslStoreProvider(SslStoreProvider sslStoreProvider) { - this.sslStoreProvider = sslStoreProvider; + /** + * Return the configured {@link SslBundles}. + * @return the {@link SslBundles} or {@code null} + * @since 3.2.0 + */ + public SslBundles getSslBundles() { + return this.sslBundles; } @Override @@ -192,28 +187,12 @@ public Shutdown getShutdown() { return this.shutdown; } - /** - * Return the provided {@link SslStoreProvider} or create one using {@link Ssl} - * properties. - * @return the {@code SslStoreProvider} - * @deprecated since 3.1.0 for removal in 3.3.0 in favor of {@link #getSslBundle()} - */ - @Deprecated(since = "3.1.0", forRemoval = true) - @SuppressWarnings("removal") - public final SslStoreProvider getOrCreateSslStoreProvider() { - if (this.sslStoreProvider != null) { - return this.sslStoreProvider; - } - return CertificateFileSslStoreProvider.from(this.ssl); - } - /** * Return the {@link SslBundle} that should be used with this server. * @return the SSL bundle */ - @SuppressWarnings("removal") protected final SslBundle getSslBundle() { - return WebServerSslBundle.get(this.ssl, this.sslBundles, this.sslStoreProvider); + return WebServerSslBundle.get(this.ssl, this.sslBundles); } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateFileSslStoreProvider.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateFileSslStoreProvider.java deleted file mode 100644 index d3ed1a091edf..000000000000 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateFileSslStoreProvider.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2012-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.boot.web.server; - -import java.security.KeyStore; - -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.pem.PemSslStoreBundle; - -/** - * An {@link SslStoreProvider} that creates key and trust stores from certificate and - * private key PEM files. - * - * @author Scott Frederick - * @since 2.7.0 - * @deprecated since 3.1.0 for removal in 3.3.0 in favor of registering a - * {@link SslBundle} backed by a {@link PemSslStoreBundle}. - */ -@Deprecated(since = "3.1.0", forRemoval = true) -@SuppressWarnings({ "deprecation", "removal" }) -public final class CertificateFileSslStoreProvider implements SslStoreProvider { - - private final SslBundle delegate; - - private CertificateFileSslStoreProvider(SslBundle delegate) { - this.delegate = delegate; - } - - @Override - public KeyStore getKeyStore() throws Exception { - return this.delegate.getStores().getKeyStore(); - } - - @Override - public KeyStore getTrustStore() throws Exception { - return this.delegate.getStores().getTrustStore(); - } - - @Override - public String getKeyPassword() { - return this.delegate.getKey().getPassword(); - } - - /** - * Create an {@link SslStoreProvider} if the appropriate SSL properties are - * configured. - * @param ssl the SSL properties - * @return an {@code SslStoreProvider} or {@code null} - */ - public static SslStoreProvider from(Ssl ssl) { - SslBundle delegate = WebServerSslBundle.createCertificateFileSslStoreProviderDelegate(ssl); - return (delegate != null) ? new CertificateFileSslStoreProvider(delegate) : null; - } - -} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ConfigurableWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ConfigurableWebServerFactory.java index c10580aa3dd7..bc90e66b7600 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ConfigurableWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/ConfigurableWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,6 @@ public interface ConfigurableWebServerFactory extends WebServerFactory, ErrorPag */ void setSsl(Ssl ssl); - /** - * Sets a provider that will be used to obtain SSL stores. - * @param sslStoreProvider the SSL store provider - * @deprecated since 3.1.0 for removal in 3.3.0, in favor of - * {@link #setSslBundles(SslBundles)} - */ - @Deprecated(since = "3.1.0", forRemoval = true) - @SuppressWarnings("removal") - void setSslStoreProvider(SslStoreProvider sslStoreProvider); - /** * Sets the SSL bundles that can be used to configure SSL connections. * @param sslBundles the SSL bundles diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/GracefulShutdownResult.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/GracefulShutdownResult.java index c1a94d106e01..67b2c5e61fdb 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/GracefulShutdownResult.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/GracefulShutdownResult.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author 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,6 @@ public enum GracefulShutdownResult { /** * The server was shutdown immediately, ignoring any active requests. */ - IMMEDIATE; + IMMEDIATE } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Shutdown.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Shutdown.java index c09fab575081..caa22eac98dc 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Shutdown.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Shutdown.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author 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,6 @@ public enum Shutdown { /** * The {@link WebServer} should shut down immediately. */ - IMMEDIATE; + IMMEDIATE } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/SslStoreProvider.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/SslStoreProvider.java deleted file mode 100644 index 31f2de86de6f..000000000000 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/SslStoreProvider.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2012-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.boot.web.server; - -import java.security.KeyStore; - -import org.springframework.boot.ssl.SslBundle; - -/** - * Interface to provide SSL key stores for an {@link WebServer} to use. Can be used when - * file based key stores cannot be used. - * - * @author Phillip Webb - * @since 2.0.0 - * @deprecated since 3.1.0 for removal in 3.3.0 in favor of registering an - * {@link SslBundle}. - */ -@Deprecated(since = "3.1.0", forRemoval = true) -public interface SslStoreProvider { - - /** - * Return the key store that should be used. - * @return the key store to use - * @throws Exception on load error - */ - KeyStore getKeyStore() throws Exception; - - /** - * Return the trust store that should be used. - * @return the trust store to use - * @throws Exception on load error - */ - KeyStore getTrustStore() throws Exception; - - /** - * Return the password of the private key in the key store. - * @return the key password - * @since 2.7.2 - */ - default String getKeyPassword() { - return null; - } - -} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java index 6b70c02ca7b0..e5c02a86f801 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -61,4 +61,12 @@ default void shutDownGracefully(GracefulShutdownCallback callback) { callback.shutdownComplete(GracefulShutdownResult.IMMEDIATE); } + /** + * Destroys the web server such that it cannot be started again. + * @since 3.2.0 + */ + default void destroy() { + stop(); + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java index c9fc5e456ce9..87c8727f832e 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/WebServerSslBundle.java @@ -32,10 +32,9 @@ import org.springframework.core.style.ToStringCreator; import org.springframework.util.Assert; import org.springframework.util.StringUtils; -import org.springframework.util.function.ThrowingSupplier; /** - * {@link SslBundle} backed by {@link Ssl} or an {@link SslStoreProvider}. + * {@link SslBundle} backed by {@link Ssl}. * * @author Scott Frederick * @author Phillip Webb @@ -61,24 +60,18 @@ private WebServerSslBundle(SslStoreBundle stores, String keyPassword, Ssl ssl) { this.managers = SslManagerBundle.from(this.stores, this.key); } - private static SslStoreBundle createPemStoreBundle(Ssl ssl) { - PemSslStoreDetails keyStoreDetails = new PemSslStoreDetails(ssl.getKeyStoreType(), ssl.getCertificate(), - ssl.getCertificatePrivateKey()); - PemSslStoreDetails trustStoreDetails = new PemSslStoreDetails(ssl.getTrustStoreType(), - ssl.getTrustCertificate(), ssl.getTrustCertificatePrivateKey()); - return new PemSslStoreBundle(keyStoreDetails, trustStoreDetails, ssl.getKeyAlias()); - } - private static SslStoreBundle createPemKeyStoreBundle(Ssl ssl) { PemSslStoreDetails keyStoreDetails = new PemSslStoreDetails(ssl.getKeyStoreType(), ssl.getCertificate(), - ssl.getCertificatePrivateKey()); - return new PemSslStoreBundle(keyStoreDetails, null, ssl.getKeyAlias()); + ssl.getCertificatePrivateKey()) + .withAlias(ssl.getKeyAlias()); + return new PemSslStoreBundle(keyStoreDetails, null); } private static SslStoreBundle createPemTrustStoreBundle(Ssl ssl) { PemSslStoreDetails trustStoreDetails = new PemSslStoreDetails(ssl.getTrustStoreType(), - ssl.getTrustCertificate(), ssl.getTrustCertificatePrivateKey()); - return new PemSslStoreBundle(null, trustStoreDetails, ssl.getKeyAlias()); + ssl.getTrustCertificate(), ssl.getTrustCertificatePrivateKey()) + .withAlias(ssl.getKeyAlias()); + return new PemSslStoreBundle(null, trustStoreDetails); } private static SslStoreBundle createJksKeyStoreBundle(Ssl ssl) { @@ -125,7 +118,7 @@ public SslManagerBundle getManagers() { * @throws NoSuchSslBundleException if a bundle lookup fails */ public static SslBundle get(Ssl ssl) throws NoSuchSslBundleException { - return get(ssl, null, null); + return get(ssl, null); } /** @@ -137,30 +130,8 @@ public static SslBundle get(Ssl ssl) throws NoSuchSslBundleException { * @throws NoSuchSslBundleException if a bundle lookup fails */ public static SslBundle get(Ssl ssl, SslBundles sslBundles) throws NoSuchSslBundleException { - return get(ssl, sslBundles, null); - } - - /** - * Get the {@link SslBundle} that should be used for the given {@link Ssl} and - * {@link SslStoreProvider} instances. - * @param ssl the source {@link Ssl} instance - * @param sslBundles the bundles that should be used when {@link Ssl#getBundle()} is - * set - * @param sslStoreProvider the {@link SslStoreProvider} to use or {@code null} - * @return a {@link SslBundle} instance - * @throws NoSuchSslBundleException if a bundle lookup fails - * @deprecated since 3.1.0 for removal in 3.3.0 along with {@link SslStoreProvider} - */ - @Deprecated(since = "3.1.0", forRemoval = true) - @SuppressWarnings("removal") - public static SslBundle get(Ssl ssl, SslBundles sslBundles, SslStoreProvider sslStoreProvider) { Assert.state(Ssl.isEnabled(ssl), "SSL is not enabled"); - String keyPassword = (sslStoreProvider != null) ? sslStoreProvider.getKeyPassword() : null; - keyPassword = (keyPassword != null) ? keyPassword : ssl.getKeyPassword(); - if (sslStoreProvider != null) { - SslStoreBundle stores = new SslStoreProviderBundleAdapter(sslStoreProvider); - return new WebServerSslBundle(stores, keyPassword, ssl); - } + String keyPassword = ssl.getKeyPassword(); String bundleName = ssl.getBundle(); if (StringUtils.hasText(bundleName)) { Assert.state(sslBundles != null, @@ -198,14 +169,6 @@ else if (hasJksTrustStoreProperties(ssl)) { return null; } - static SslBundle createCertificateFileSslStoreProviderDelegate(Ssl ssl) { - if (!hasPemKeyStoreProperties(ssl)) { - return null; - } - SslStoreBundle stores = createPemStoreBundle(ssl); - return new WebServerSslBundle(stores, ssl.getKeyPassword(), ssl); - } - private static boolean hasPemKeyStoreProperties(Ssl ssl) { return Ssl.isEnabled(ssl) && ssl.getCertificate() != null && ssl.getCertificatePrivateKey() != null; } @@ -234,46 +197,6 @@ public String toString() { return creator.toString(); } - /** - * Class to adapt a {@link SslStoreProvider} into a {@link SslStoreBundle}. - */ - @SuppressWarnings("removal") - private static class SslStoreProviderBundleAdapter implements SslStoreBundle { - - private final SslStoreProvider sslStoreProvider; - - SslStoreProviderBundleAdapter(SslStoreProvider sslStoreProvider) { - this.sslStoreProvider = sslStoreProvider; - } - - @Override - public KeyStore getKeyStore() { - return ThrowingSupplier.of(this.sslStoreProvider::getKeyStore).get(); - } - - @Override - public String getKeyStorePassword() { - return null; - } - - @Override - public KeyStore getTrustStore() { - return ThrowingSupplier.of(this.sslStoreProvider::getTrustStore).get(); - } - - @Override - public String toString() { - ToStringCreator creator = new ToStringCreator(this); - KeyStore keyStore = getKeyStore(); - creator.append("keyStore.type", (keyStore != null) ? keyStore.getType() : "none"); - creator.append("keyStorePassword", null); - KeyStore trustStore = getTrustStore(); - creator.append("trustStore.type", (trustStore != null) ? trustStore.getType() : "none"); - return creator.toString(); - } - - } - private static final class WebServerSslStoreBundle implements SslStoreBundle { private final KeyStore keyStore; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBean.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBean.java index 71f8c5086dcf..fa64a89724b0 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBean.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/AbstractFilterRegistrationBean.java @@ -158,6 +158,28 @@ public void addUrlPatterns(String... urlPatterns) { Collections.addAll(this.urlPatterns, urlPatterns); } + /** + * Determines the {@link DispatcherType dispatcher types} for which the filter should + * be registered. Applies defaults based on the type of filter being registered if + * none have been configured. Modifications to the returned {@link EnumSet} will have + * no effect on the registration. + * @return the dispatcher types, never {@code null} + * @since 3.2.0 + */ + public EnumSet determineDispatcherTypes() { + if (this.dispatcherTypes == null) { + T filter = getFilter(); + if (ClassUtils.isPresent("org.springframework.web.filter.OncePerRequestFilter", + filter.getClass().getClassLoader()) && filter instanceof OncePerRequestFilter) { + return EnumSet.allOf(DispatcherType.class); + } + else { + return EnumSet.of(DispatcherType.REQUEST); + } + } + return EnumSet.copyOf(this.dispatcherTypes); + } + /** * Convenience method to {@link #setDispatcherTypes(EnumSet) set dispatcher types} * using the specified elements. @@ -216,17 +238,7 @@ protected Dynamic addRegistration(String description, ServletContext servletCont @Override protected void configure(FilterRegistration.Dynamic registration) { super.configure(registration); - EnumSet dispatcherTypes = this.dispatcherTypes; - if (dispatcherTypes == null) { - T filter = getFilter(); - if (ClassUtils.isPresent("org.springframework.web.filter.OncePerRequestFilter", - filter.getClass().getClassLoader()) && filter instanceof OncePerRequestFilter) { - dispatcherTypes = EnumSet.allOf(DispatcherType.class); - } - else { - dispatcherTypes = EnumSet.of(DispatcherType.REQUEST); - } - } + EnumSet dispatcherTypes = determineDispatcherTypes(); Set servletNames = new LinkedHashSet<>(); for (ServletRegistrationBean servletRegistrationBean : this.servletRegistrationBeans) { servletNames.add(servletRegistrationBean.getServletName()); @@ -253,6 +265,15 @@ protected void configure(FilterRegistration.Dynamic registration) { */ public abstract T getFilter(); + /** + * Returns the filter name that will be registered. + * @return the filter name + * @since 3.2.0 + */ + public String getFilterName() { + return getOrDeduceName(getFilter()); + } + @Override public String toString() { StringBuilder builder = new StringBuilder(getOrDeduceName(this)); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletContextInitializerBeans.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletContextInitializerBeans.java index 7a5c24cc1d31..07a5933fafc2 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletContextInitializerBeans.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/ServletContextInitializerBeans.java @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.EventListener; +import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; @@ -67,7 +68,7 @@ public class ServletContextInitializerBeans extends AbstractCollection seen = new HashSet<>(); + private final Seen seen = new Seen(); private final MultiValueMap, ServletContextInitializer> initializers; @@ -129,7 +130,7 @@ private void addServletContextInitializerBean(Class type, String beanName, Se this.initializers.add(type, initializer); if (source != null) { // Mark the underlying source as seen in case it wraps an existing bean - this.seen.add(source); + this.seen.add(type, source); } if (logger.isTraceEnabled()) { String resourceDescription = getResourceDescription(beanName, beanFactory); @@ -174,7 +175,7 @@ private void addAsRegistrationBean(ListableBeanFactory beanFact for (Entry entry : entries) { String beanName = entry.getKey(); B bean = entry.getValue(); - if (this.seen.add(bean)) { + if (this.seen.add(type, bean)) { // One that we haven't already seen RegistrationBean registration = adapter.createRegistrationBean(beanName, bean, entries.size()); int order = getOrder(bean); @@ -198,17 +199,17 @@ public int getOrder(Object obj) { } private List> getOrderedBeansOfType(ListableBeanFactory beanFactory, Class type) { - return getOrderedBeansOfType(beanFactory, type, Collections.emptySet()); + return getOrderedBeansOfType(beanFactory, type, Seen.empty()); } private List> getOrderedBeansOfType(ListableBeanFactory beanFactory, Class type, - Set excludes) { + Seen seen) { String[] names = beanFactory.getBeanNamesForType(type, true, false); Map map = new LinkedHashMap<>(); for (String name : names) { - if (!excludes.contains(name) && !ScopedProxyUtils.isScopedTarget(name)) { + if (!seen.contains(type, name) && !ScopedProxyUtils.isScopedTarget(name)) { T bean = beanFactory.getBean(name, type); - if (!excludes.contains(bean)) { + if (!seen.contains(type, bean)) { map.put(name, bean); } } @@ -310,4 +311,34 @@ public RegistrationBean createRegistrationBean(String name, EventListener source } + private static final class Seen { + + private final Map, Set> seen = new HashMap<>(); + + boolean add(Class type, Object object) { + if (contains(type, object)) { + return false; + } + return this.seen.computeIfAbsent(type, (ignore) -> new HashSet<>()).add(object); + } + + boolean contains(Class type, Object object) { + if (this.seen.isEmpty()) { + return false; + } + // If it has been directly seen, or the implemented ServletContextInitializer + // has been seen already + if (type != ServletContextInitializer.class + && this.seen.getOrDefault(type, Collections.emptySet()).contains(object)) { + return true; + } + return this.seen.getOrDefault(ServletContextInitializer.class, Collections.emptySet()).contains(object); + } + + static Seen empty() { + return new Seen(); + } + + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java index 534237cc7728..d6513792d21b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -149,6 +149,7 @@ public final void refresh() throws BeansException, IllegalStateException { WebServer webServer = this.webServer; if (webServer != null) { webServer.stop(); + webServer.destroy(); } throw ex; } @@ -171,6 +172,10 @@ protected void doClose() { AvailabilityChangeEvent.publish(this, ReadinessState.REFUSING_TRAFFIC); } super.doClose(); + WebServer webServer = this.webServer; + if (webServer != null) { + webServer.destroy(); + } } private void createWebServer() { diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java index 21e6bd068fe0..c523627c1fcb 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,6 +61,7 @@ * @author Stephane Nicoll * @author Vedran Pavic * @author Scott Frederick + * @author Moritz Halbritter * @since 2.0.0 * @see ErrorAttributes */ @@ -100,6 +101,9 @@ public Map getErrorAttributes(WebRequest webRequest, ErrorAttrib if (!options.isIncluded(Include.BINDING_ERRORS)) { errorAttributes.remove("errors"); } + if (!options.isIncluded(Include.PATH)) { + errorAttributes.remove("path"); + } return errorAttributes; } @@ -216,9 +220,6 @@ public Throwable getError(WebRequest webRequest) { if (exception == null) { exception = getAttribute(webRequest, RequestDispatcher.ERROR_EXCEPTION); } - // store the exception in a well-known attribute to make it available to metrics - // instrumentation. - webRequest.setAttribute(ErrorAttributes.ERROR_ATTRIBUTE, exception, WebRequest.SCOPE_REQUEST); return exception; } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/ErrorAttributes.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/ErrorAttributes.java index 898363885eac..3f2fc64d1e12 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/ErrorAttributes.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error/ErrorAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -34,14 +34,6 @@ */ public interface ErrorAttributes { - /** - * Name of the {@link jakarta.servlet.http.HttpServletRequest#getAttribute(String) - * request attribute} holding the error resolved by the {@code ErrorAttributes} - * implementation. - * @since 2.5.0 - */ - String ERROR_ATTRIBUTE = ErrorAttributes.class.getName() + ".error"; - /** * Returns a {@link Map} of the error attributes. The map can be used as the model of * an error page {@link ModelAndView}, or returned as a diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactory.java index 349212d7e6c0..d34dabce3326 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -39,6 +39,7 @@ import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory; +import org.springframework.boot.web.server.Cookie; import org.springframework.boot.web.server.MimeMappings; import org.springframework.boot.web.servlet.ServletContextInitializer; import org.springframework.util.Assert; @@ -335,14 +336,12 @@ public void onStartup(ServletContext servletContext) throws ServletException { configureSessionCookie(servletContext.getSessionCookieConfig()); } - @SuppressWarnings("removal") private void configureSessionCookie(SessionCookieConfig config) { - Session.Cookie cookie = this.session.getCookie(); + Cookie cookie = this.session.getCookie(); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(cookie::getName).to(config::setName); map.from(cookie::getDomain).to(config::setDomain); map.from(cookie::getPath).to(config::setPath); - map.from(cookie::getComment).to(config::setComment); map.from(cookie::getHttpOnly).to(config::setHttpOnly); map.from(cookie::getSecure).to(config::setSecure); map.from(cookie::getMaxAge).asInt(Duration::getSeconds).to(config::setMaxAge); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/Session.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/Session.java index 336c03ced541..d561342bfacf 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/Session.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/server/Session.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -21,7 +21,7 @@ import java.time.temporal.ChronoUnit; import java.util.Set; -import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; +import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.boot.convert.DurationUnit; /** @@ -44,6 +44,7 @@ public class Session { */ private File storeDir; + @NestedConfigurationProperty private final Cookie cookie = new Cookie(); private final SessionStoreDirectory sessionStoreDirectory = new SessionStoreDirectory(); @@ -102,31 +103,12 @@ SessionStoreDirectory getSessionStoreDirectory() { } /** - * Session cookie properties. + * Session cookie properties. This class is provided only for back-compatibility + * reasons, consider using {@link org.springframework.boot.web.server.Cookie} whever + * possible. */ public static class Cookie extends org.springframework.boot.web.server.Cookie { - /** - * Comment for the session cookie. - */ - private String comment; - - /** - * Return the comment for the session cookie. - * @return the session cookie comment - * @deprecated since 3.0.0 without replacement - */ - @Deprecated(since = "3.0.0", forRemoval = true) - @DeprecatedConfigurationProperty - public String getComment() { - return this.comment; - } - - @Deprecated(since = "3.0.0", forRemoval = true) - public void setComment(String comment) { - this.comment = comment; - } - } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilder.java index 8f5dbb3d7c0e..5d25c93e601a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilder.java @@ -113,7 +113,7 @@ public WebServiceMessageSender build() { private ClientHttpRequestFactory getRequestFactory() { ClientHttpRequestFactorySettings settings = new ClientHttpRequestFactorySettings(this.connectTimeout, - this.readTimeout, null, this.sslBundle); + this.readTimeout, this.sslBundle); return (this.requestFactory != null) ? this.requestFactory.apply(settings) : ClientHttpRequestFactories.get(settings); } diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 4835aa0c30a3..3484dd4ad100 100644 --- a/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -103,6 +103,13 @@ "description": "Log groups to quickly change multiple loggers at the same time. For instance, `logging.group.db=org.hibernate,org.springframework.jdbc`.", "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener" }, + { + "name": "logging.include-application-name", + "type": "java.lang.Boolean", + "description": "Whether to include the application name in the logs.", + "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener", + "defaultValue": true + }, { "name": "logging.level", "type": "java.util.Map", @@ -165,6 +172,12 @@ "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener", "defaultValue": "%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}" }, + { + "name": "logging.pattern.correlation", + "type": "java.lang.String", + "description": "Appender pattern for log correlation. Supported only with the default Logback setup.", + "sourceType": "org.springframework.boot.context.logging.LoggingApplicationListener" + }, { "name": "logging.pattern.dateformat", "type": "java.lang.String", @@ -348,485 +361,6 @@ "description": "Whether to defer DataSource initialization until after any EntityManagerFactory beans have been created and initialized.", "defaultValue": false }, - { - "name": "spring.jta.atomikos.connectionfactory.borrow-connection-timeout", - "type": "java.lang.Integer", - "description": "Timeout, in seconds, for borrowing connections from the pool.", - "defaultValue": 30 - }, - { - "name": "spring.jta.atomikos.connectionfactory.ignore-session-transacted-flag", - "type": "java.lang.Boolean", - "description": "Whether to ignore the transacted flag when creating session.", - "defaultValue": true - }, - { - "name": "spring.jta.atomikos.connectionfactory.local-transaction-mode", - "type": "java.lang.Boolean", - "description": "Whether local transactions are desired.", - "defaultValue": false - }, - { - "name": "spring.jta.atomikos.connectionfactory.maintenance-interval", - "type": "java.lang.Integer", - "description": "Time, in seconds, between runs of the pool's maintenance thread.", - "defaultValue": 60 - }, - { - "name": "spring.jta.atomikos.connectionfactory.max-idle-time", - "type": "java.lang.Integer", - "description": "Time, in seconds, after which connections are cleaned up from the pool.", - "defaultValue": 60 - }, - { - "name": "spring.jta.atomikos.connectionfactory.max-lifetime", - "type": "java.lang.Integer", - "description": "Time, in seconds, that a connection can be pooled for before being destroyed. 0 denotes no limit.", - "defaultValue": 0 - }, - { - "name": "spring.jta.atomikos.connectionfactory.max-pool-size", - "type": "java.lang.Integer", - "description": "Maximum size of the pool.", - "defaultValue": 1 - }, - { - "name": "spring.jta.atomikos.connectionfactory.min-pool-size", - "type": "java.lang.Integer", - "description": "Minimum size of the pool.", - "defaultValue": 1 - }, - { - "name": "spring.jta.atomikos.connectionfactory.reap-timeout", - "type": "java.lang.Integer", - "description": "Reap timeout, in seconds, for borrowed connections. 0 denotes no limit.", - "defaultValue": 0 - }, - { - "name": "spring.jta.atomikos.connectionfactory.unique-resource-name", - "type": "java.lang.String", - "description": "Unique name used to identify the resource during recovery.", - "defaultValue": "jmsConnectionFactory" - }, - { - "name": "spring.jta.atomikos.connectionfactory.xa-connection-factory-class-name", - "type": "java.lang.String", - "description": "Vendor-specific implementation of XAConnectionFactory." - }, - { - "name": "spring.jta.atomikos.connectionfactory.xa-properties", - "type": "java.util.Properties", - "description": "Vendor-specific XA properties." - }, - { - "name": "spring.jta.atomikos.datasource.borrow-connection-timeout", - "type": "java.lang.Integer", - "description": "Timeout, in seconds, for borrowing connections from the pool.", - "defaultValue": 30 - }, - { - "name": "spring.jta.atomikos.datasource.concurrent-connection-validation", - "type": "java.lang.Boolean", - "description": "Whether to use concurrent connection validation.", - "defaultValue": true - }, - { - "name": "spring.jta.atomikos.datasource.default-isolation-level", - "type": "java.lang.Integer", - "description": "Default isolation level of connections provided by the pool." - }, - { - "name": "spring.jta.atomikos.datasource.login-timeout", - "type": "java.lang.Integer", - "description": "Timeout, in seconds, for establishing a database connection.", - "defaultValue": 0 - }, - { - "name": "spring.jta.atomikos.datasource.maintenance-interval", - "type": "java.lang.Integer", - "description": "Time, in seconds, between runs of the pool's maintenance thread.", - "defaultValue": 60 - }, - { - "name": "spring.jta.atomikos.datasource.max-idle-time", - "type": "java.lang.Integer", - "description": "Time, in seconds, after which connections are cleaned up from the pool.", - "defaultValue": 60 - }, - { - "name": "spring.jta.atomikos.datasource.max-lifetime", - "type": "java.lang.Integer", - "description": "Time, in seconds, that a connection can be pooled for before being destroyed. 0 denotes no limit.", - "defaultValue": 0 - }, - { - "name": "spring.jta.atomikos.datasource.max-pool-size", - "type": "java.lang.Integer", - "description": "Maximum size of the pool.", - "defaultValue": 1 - }, - { - "name": "spring.jta.atomikos.datasource.min-pool-size", - "type": "java.lang.Integer", - "description": "Minimum size of the pool.", - "defaultValue": 1 - }, - { - "name": "spring.jta.atomikos.datasource.reap-timeout", - "type": "java.lang.Integer", - "description": "Reap timeout, in seconds, for borrowed connections. 0 denotes no limit.", - "defaultValue": 0 - }, - { - "name": "spring.jta.atomikos.datasource.test-query", - "type": "java.lang.String", - "description": "SQL query or statement used to validate a connection before returning it." - }, - { - "name": "spring.jta.atomikos.datasource.unique-resource-name", - "type": "java.lang.String", - "description": "Unique name used to identify the resource during recovery.", - "defaultValue": "dataSource" - }, - { - "name": "spring.jta.atomikos.datasource.xa-data-source-class-name", - "type": "java.lang.String", - "description": "Vendor-specific implementation of XAConnectionFactory." - }, - { - "name": "spring.jta.atomikos.datasource.xa-properties", - "type": "java.util.Properties", - "description": "Vendor-specific XA properties." - }, - { - "name": "spring.jta.bitronix.connectionfactory.acquire-increment", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.acquisition-interval", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.acquisition-timeout", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.allow-local-transactions", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.apply-transaction-timeout", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.automatic-enlisting-enabled", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.cache-producers-consumers", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.class-name", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.defer-connection-release", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.disabled", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.driver-properties", - "type": "java.util.Properties", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.ignore-recovery-failures", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.max-idle-time", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.max-pool-size", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.min-pool-size", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.password", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.share-transaction-connections", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.test-connections", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.two-pc-ordering-position", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.unique-name", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.use-tm-join", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.connectionfactory.user", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.acquire-increment", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.acquisition-interval", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.acquisition-timeout", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.allow-local-transactions", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.apply-transaction-timeout", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.automatic-enlisting-enabled", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.class-name", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.cursor-holdability", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.defer-connection-release", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.disabled", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.driver-properties", - "type": "java.util.Properties", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.enable-jdbc4-connection-test", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.ignore-recovery-failures", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.isolation-level", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.local-auto-commit", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.login-timeout", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.max-idle-time", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.max-pool-size", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.min-pool-size", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.prepared-statement-cache-size", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.share-transaction-connections", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.test-query", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.two-pc-ordering-position", - "type": "java.lang.Integer", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.unique-name", - "type": "java.lang.String", - "deprecation": { - "level": "error" - } - }, - { - "name": "spring.jta.bitronix.datasource.use-tm-join", - "type": "java.lang.Boolean", - "deprecation": { - "level": "error" - } - }, { "name": "spring.main.allow-bean-definition-overriding", "type": "java.lang.Boolean", @@ -853,6 +387,13 @@ "type": "org.springframework.boot.cloud.CloudPlatform", "description": "Override the Cloud Platform auto-detection." }, + { + "name": "spring.main.keep-alive", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.boot.SpringApplication", + "description": "Whether to keep the application alive even if there are no more non-daemon threads.", + "defaultValue": false + }, { "name": "spring.main.lazy-initialization", "type": "java.lang.Boolean", @@ -957,7 +498,7 @@ { "name": "spring.reactor.debug-agent.enabled", "type": "java.lang.Boolean", - "sourceType": "org.springframework.boot.reactor.DebugAgentEnvironmentPostProcessor", + "sourceType": "org.springframework.boot.reactor.ReactorEnvironmentPostProcessor", "description": "Whether the Reactor Debug Agent should be enabled when reactor-tools is present.", "defaultValue": true }, diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories index 4641c7b8dd61..f6cea1c9e686 100644 --- a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories @@ -57,7 +57,7 @@ org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor,\ org.springframework.boot.env.RandomValuePropertySourceEnvironmentPostProcessor,\ org.springframework.boot.env.SpringApplicationJsonEnvironmentPostProcessor,\ org.springframework.boot.env.SystemEnvironmentPropertySourceEnvironmentPostProcessor,\ -org.springframework.boot.reactor.DebugAgentEnvironmentPostProcessor +org.springframework.boot.reactor.ReactorEnvironmentPostProcessor # Failure Analyzers org.springframework.boot.diagnostics.FailureAnalyzer=\ @@ -70,6 +70,7 @@ org.springframework.boot.diagnostics.analyzer.BeanNotOfRequiredTypeFailureAnalyz org.springframework.boot.diagnostics.analyzer.BindFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.BindValidationFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.UnboundConfigurationPropertyFailureAnalyzer,\ +org.springframework.boot.diagnostics.analyzer.MissingParameterNamesFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.MutuallyExclusiveConfigurationPropertiesFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.NoSuchMethodFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.NoUniqueBeanDefinitionFailureAnalyzer,\ diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml index d7c510bb7a98..916f1126db61 100644 --- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml +++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2-file.xml @@ -4,8 +4,8 @@ %xwEx %5p yyyy-MM-dd'T'HH:mm:ss.SSSXXX - %clr{%d{${sys:LOG_DATEFORMAT_PATTERN}}}{faint} %clr{${sys:LOG_LEVEL_PATTERN}} %clr{%pid}{magenta} %clr{---}{faint} %clr{[%15.15t]}{faint} %clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD} - %d{${sys:LOG_DATEFORMAT_PATTERN}} ${sys:LOG_LEVEL_PATTERN} %pid --- [%t] %-40.40c{1.} : %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD} + %clr{%d{${sys:LOG_DATEFORMAT_PATTERN}}}{faint} %clr{${sys:LOG_LEVEL_PATTERN}} %clr{%pid}{magenta} %clr{---}{faint} %clr{${sys:LOGGED_APPLICATION_NAME:-}[%15.15t]}{faint} %clr{${sys:LOG_CORRELATION_PATTERN:-}}{faint}%clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD} + %d{${sys:LOG_DATEFORMAT_PATTERN}} ${sys:LOG_LEVEL_PATTERN} %pid --- ${sys:LOGGED_APPLICATION_NAME:-}[%t] ${sys:LOG_CORRELATION_PATTERN:-}%-40.40c{1.} : %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD} diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml index 65f1a1b612d7..239f2e35a34c 100644 --- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml +++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/log4j2/log4j2.xml @@ -4,8 +4,8 @@ %xwEx %5p yyyy-MM-dd'T'HH:mm:ss.SSSXXX - %clr{%d{${sys:LOG_DATEFORMAT_PATTERN}}}{faint} %clr{${sys:LOG_LEVEL_PATTERN}} %clr{%pid}{magenta} %clr{---}{faint} %clr{[%15.15t]}{faint} %clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD} - %d{${sys:LOG_DATEFORMAT_PATTERN}} ${sys:LOG_LEVEL_PATTERN} %pid --- [%t] %-40.40c{1.} : %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD} + %clr{%d{${sys:LOG_DATEFORMAT_PATTERN}}}{faint} %clr{${sys:LOG_LEVEL_PATTERN}} %clr{%pid}{magenta} %clr{---}{faint} %clr{${sys:LOGGED_APPLICATION_NAME:-}[%15.15t]}{faint} %clr{${sys:LOG_CORRELATION_PATTERN:-}}{faint}%clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD} + %d{${sys:LOG_DATEFORMAT_PATTERN}} ${sys:LOG_LEVEL_PATTERN} %pid --- ${sys:LOGGED_APPLICATION_NAME:-}[%t] ${sys:LOG_CORRELATION_PATTERN:-}%-40.40c{1.} : %m%n${sys:LOG_EXCEPTION_CONVERSION_WORD} diff --git a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml index bc2ec1238193..9c02f84e4099 100644 --- a/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml +++ b/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml @@ -6,13 +6,14 @@ Default logback configuration provided for import + - + - + diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationShutdownHookTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationShutdownHookTests.java index 5f2d5c1dfc06..0d821496e30d 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationShutdownHookTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationShutdownHookTests.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import org.awaitility.Awaitility; import org.junit.jupiter.api.Test; @@ -112,8 +113,8 @@ void runWhenContextIsBeingClosedInAnotherThreadWaitsUntilContextIsInactive() thr closing.await(); Thread shutdownThread = new Thread(shutdownHook); shutdownThread.start(); - // Shutdown thread should become blocked on monitor held by context thread - Awaitility.await().atMost(Duration.ofSeconds(30)).until(shutdownThread::getState, State.BLOCKED::equals); + // Shutdown thread should start waiting for context to become inactive + Awaitility.await().atMost(Duration.ofSeconds(30)).until(shutdownThread::getState, State.WAITING::equals); // Allow context thread to proceed, unblocking shutdown thread proceedWithClose.countDown(); contextThread.join(); @@ -252,7 +253,7 @@ protected void onClose() { } if (this.proceedWithClose != null) { try { - this.proceedWithClose.await(); + this.proceedWithClose.await(1, TimeUnit.MINUTES); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java index 8f8b714bd235..0039c9ce395c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/SpringApplicationTests.java @@ -16,6 +16,7 @@ package org.springframework.boot; +import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -31,6 +32,7 @@ import jakarta.annotation.PostConstruct; import org.assertj.core.api.Condition; +import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -160,7 +162,9 @@ * @author Nguyen Bao Sach * @author Chris Bono * @author Sebastien Deleuze + * @author Moritz Halbritter * @author Tadaya Tsuyukubo + * @author Yanming Zhou */ @ExtendWith(OutputCaptureExtension.class) class SpringApplicationTests { @@ -1386,6 +1390,21 @@ void shouldUseAotInitializer() { } } + @Test + void shouldReportFriendlyErrorIfAotInitializerNotFound() { + SpringApplication application = new SpringApplication(TestSpringApplication.class); + application.setWebApplicationType(WebApplicationType.NONE); + application.setMainApplicationClass(TestSpringApplication.class); + System.setProperty(AotDetector.AOT_ENABLED, "true"); + try { + assertThatIllegalStateException().isThrownBy(application::run) + .withMessageContaining("but AOT processing hasn't happened"); + } + finally { + System.clearProperty(AotDetector.AOT_ENABLED); + } + } + @Test void fromRunsWithAdditionalSources() { assertThat(ExampleAdditionalConfig.local.get()).isNull(); @@ -1415,6 +1434,32 @@ void fromWithMultipleApplicationsOnlyAppliesAdditionalSourcesOnce() { assertThatNoException().isThrownBy(() -> this.context.getBean(SingleUseAdditionalConfig.class)); } + @Test + void shouldStartDaemonThreadIfKeepAliveIsEnabled() { + SpringApplication application = new SpringApplication(ExampleConfig.class); + application.setWebApplicationType(WebApplicationType.NONE); + this.context = application.run("--spring.main.keep-alive=true"); + Set threads = getCurrentThreads(); + assertThat(threads).filteredOn((thread) -> thread.getName().equals("keep-alive")) + .singleElement() + .satisfies((thread) -> assertThat(thread.isDaemon()).isFalse()); + } + + @Test + void shouldStopKeepAliveThreadIfContextIsClosed() { + SpringApplication application = new SpringApplication(ExampleConfig.class); + application.setWebApplicationType(WebApplicationType.NONE); + application.setKeepAlive(true); + this.context = application.run(); + assertThat(getCurrentThreads()).filteredOn((thread) -> thread.getName().equals("keep-alive")).isNotEmpty(); + this.context.close(); + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> assertThat(getCurrentThreads()).filteredOn((thread) -> thread.getName().equals("keep-alive")) + .isEmpty()); + } + private ArgumentMatcher isAvailabilityChangeEventWithState( S state) { return (argument) -> (argument instanceof AvailabilityChangeEvent) @@ -1457,6 +1502,10 @@ public boolean matches(ConfigurableApplicationContext value) { }; } + private Set getCurrentThreads() { + return Thread.getAllStackTraces().keySet(); + } + static class TestEventListener implements SmartApplicationListener { private final Class eventType; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java index 6bcd00527f9b..ae1bbb4ed2ee 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java @@ -16,11 +16,10 @@ package org.springframework.boot; -import java.time.Duration; - import org.apache.commons.logging.Log; import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication.Startup; import org.springframework.boot.system.ApplicationPid; import static org.assertj.core.api.Assertions.assertThat; @@ -72,11 +71,59 @@ void startingFormatInAotMode() { @Test void startedFormat() { given(this.log.isInfoEnabled()).willReturn(true); - Duration timeTakenToStartup = Duration.ofMillis(10); - new StartupInfoLogger(getClass()).logStarted(this.log, timeTakenToStartup); + new StartupInfoLogger(getClass()).logStarted(this.log, new TestStartup(1345L, "Started")); then(this.log).should() .info(assertArg((message) -> assertThat(message.toString()).matches("Started " + getClass().getSimpleName() - + " in \\d+\\.\\d{1,3} seconds \\(process running for \\d+\\.\\d{1,3}\\)"))); + + " in \\d+\\.\\d{1,3} seconds \\(process running for 1.345\\)"))); + } + + @Test + void startedWithoutUptimeFormat() { + given(this.log.isInfoEnabled()).willReturn(true); + new StartupInfoLogger(getClass()).logStarted(this.log, new TestStartup(null, "Started")); + then(this.log).should() + .info(assertArg((message) -> assertThat(message.toString()) + .matches("Started " + getClass().getSimpleName() + " in \\d+\\.\\d{1,3} seconds"))); + } + + @Test + void restoredFormat() { + given(this.log.isInfoEnabled()).willReturn(true); + new StartupInfoLogger(getClass()).logStarted(this.log, new TestStartup(null, "Restored")); + then(this.log).should() + .info(assertArg((message) -> assertThat(message.toString()) + .matches("Restored " + getClass().getSimpleName() + " in \\d+\\.\\d{1,3} seconds"))); + } + + static class TestStartup extends Startup { + + private final long startTime = System.currentTimeMillis(); + + private final Long uptime; + + private final String action; + + TestStartup(Long uptime, String action) { + this.uptime = uptime; + this.action = action; + started(); + } + + @Override + protected long startTime() { + return this.startTime; + } + + @Override + protected Long processUptime() { + return this.uptime; + } + + @Override + protected String action() { + return this.action; + } + } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorTests.java index a63c056fbb44..f7139ad750ad 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorTests.java @@ -108,7 +108,7 @@ void applyToAppliesPostProcessing() { TestConfigDataEnvironmentUpdateListener listener = new TestConfigDataEnvironmentUpdateListener(); ConfigDataEnvironmentPostProcessor.applyTo(this.environment, null, null, Collections.singleton("dev"), listener); - assertThat(this.environment.getPropertySources().size()).isGreaterThan(before); + assertThat(this.environment.getPropertySources()).hasSizeGreaterThan(before); assertThat(this.environment.getActiveProfiles()).containsExactly("dev"); assertThat(listener.getAddedPropertySources()).isNotEmpty(); assertThat(listener.getProfiles().getActive()).containsExactly("dev"); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentTests.java index 4b046f0d8307..fdad67f2e08c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 java.util.Map; import java.util.function.Supplier; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; @@ -354,7 +355,7 @@ public Enumeration getResources(String name) throws IOException { TestConfigDataEnvironment configDataEnvironment = new TestConfigDataEnvironment(this.logFactory, this.bootstrapContext, this.environment, resourceLoader, this.additionalProfiles, null); assertThat(configDataEnvironment).extracting("loaders.loaders") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .extracting((item) -> (Class) item.getClass()) .containsOnly(SeparateClassLoaderConfigDataLoader.class); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataLoadersTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataLoadersTests.java index 50b121fcc0b8..b488be53ea77 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataLoadersTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataLoadersTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.function.Supplier; import org.apache.commons.logging.Log; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -65,7 +66,7 @@ void createWhenLoaderHasDeferredLogFactoryParameterInjectsDeferredLogFactory() { ConfigDataLoaders loaders = new ConfigDataLoaders(this.logFactory, this.bootstrapContext, springFactoriesLoader); assertThat(loaders).extracting("loaders") - .asList() + .asInstanceOf(InstanceOfAssertFactories.LIST) .satisfies(this::containsValidDeferredLogFactoryConfigDataLoader); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataPropertiesTests.java index e4027f513a76..b3635903f2e2 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataPropertiesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataPropertiesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ * * @author Phillip Webb * @author Madhura Bhave + * @author Yanming Zhou */ class ConfigDataPropertiesTests { @@ -98,6 +99,13 @@ void isActiveWhenSpecificCloudPlatformAgainstDifferentSpecificCloudPlatform() { assertThat(properties.isActive(context)).isFalse(); } + @Test + void isActiveWhenNoneCloudPlatformAgainstNullCloudPlatform() { + ConfigDataProperties properties = new ConfigDataProperties(NO_IMPORTS, new Activate(CloudPlatform.NONE, null)); + ConfigDataActivationContext context = new ConfigDataActivationContext(NULL_CLOUD_PLATFORM, NULL_PROFILES); + assertThat(properties.isActive(context)).isTrue(); + } + @Test void isActiveWhenNullProfilesAgainstNullProfiles() { ConfigDataProperties properties = new ConfigDataProperties(NO_IMPORTS, new Activate(null, null)); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializerTests.java index 748d1e65ced2..2436489db8bc 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationContextInitializerTests.java @@ -36,6 +36,8 @@ * * @author Phillip Webb */ +@Deprecated(since = "3.2.0", forRemoval = true) +@SuppressWarnings("removal") class DelegatingApplicationContextInitializerTests { private final DelegatingApplicationContextInitializer initializer = new DelegatingApplicationContextInitializer(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationListenerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationListenerTests.java index 284c9377d47d..c56460014a8b 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationListenerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/DelegatingApplicationListenerTests.java @@ -37,6 +37,8 @@ * * @author Dave Syer */ +@Deprecated(since = "3.2.0", forRemoval = true) +@SuppressWarnings("removal") class DelegatingApplicationListenerTests { private final DelegatingApplicationListener listener = new DelegatingApplicationListener(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerIntegrationTests.java index 398bc8298296..d7c8b12a4901 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerIntegrationTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerIntegrationTests.java @@ -30,7 +30,7 @@ import org.springframework.boot.context.event.ApplicationStartingEvent; import org.springframework.boot.logging.LogFile; import org.springframework.boot.logging.LoggingSystem; -import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; import org.springframework.boot.testsupport.system.CapturedOutput; import org.springframework.boot.testsupport.system.OutputCaptureExtension; import org.springframework.context.ApplicationListener; @@ -69,7 +69,7 @@ void logFileRegisteredInTheContextWhenApplicable(@TempDir File tempDir) { assertThat(service.logFile).hasToString(logFile); } finally { - System.clearProperty(LoggingSystemProperties.LOG_FILE); + System.clearProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName()); } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java index c2625e042bca..b44c893d5d70 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/logging/LoggingApplicationListenerTests.java @@ -58,7 +58,7 @@ import org.springframework.boot.logging.LoggerGroups; import org.springframework.boot.logging.LoggingInitializationContext; import org.springframework.boot.logging.LoggingSystem; -import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; import org.springframework.boot.logging.java.JavaLoggingSystem; import org.springframework.boot.system.ApplicationPid; import org.springframework.boot.testsupport.classpath.ClassPathExclusions; @@ -92,6 +92,7 @@ * @author Ben Hale * @author Fahim Farook * @author Eddú Meléndez + * @author Jonatan Ivanov */ @ExtendWith(OutputCaptureExtension.class) @ClassPathExclusions("log4j*.jar") @@ -477,16 +478,16 @@ void closingChildContextDoesNotCleanUpLoggingSystem() { void systemPropertiesAreSetForLoggingConfiguration() { addPropertiesToEnvironment(this.context, "logging.exception-conversion-word=conversion", "logging.file.name=" + this.logFile, "logging.file.path=path", "logging.pattern.console=console", - "logging.pattern.file=file", "logging.pattern.level=level", + "logging.pattern.file=file", "logging.pattern.level=level", "logging.pattern.correlation=correlation", "logging.pattern.rolling-file-name=my.log.%d{yyyyMMdd}.%i.gz"); this.listener.initialize(this.context.getEnvironment(), this.context.getClassLoader()); - assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN)).isEqualTo("console"); - assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_PATTERN)).isEqualTo("file"); - assertThat(System.getProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD)).isEqualTo("conversion"); - assertThat(System.getProperty(LoggingSystemProperties.LOG_FILE)).isEqualTo(this.logFile.getAbsolutePath()); - assertThat(System.getProperty(LoggingSystemProperties.LOG_LEVEL_PATTERN)).isEqualTo("level"); - assertThat(System.getProperty(LoggingSystemProperties.LOG_PATH)).isEqualTo("path"); - assertThat(System.getProperty(LoggingSystemProperties.PID_KEY)).isNotNull(); + assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)).isEqualTo("console"); + assertThat(getSystemProperty(LoggingSystemProperty.FILE_PATTERN)).isEqualTo("file"); + assertThat(getSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD)).isEqualTo("conversion"); + assertThat(getSystemProperty(LoggingSystemProperty.LOG_FILE)).isEqualTo(this.logFile.getAbsolutePath()); + assertThat(getSystemProperty(LoggingSystemProperty.LEVEL_PATTERN)).isEqualTo("level"); + assertThat(getSystemProperty(LoggingSystemProperty.LOG_PATH)).isEqualTo("path"); + assertThat(getSystemProperty(LoggingSystemProperty.PID)).isNotNull(); } @Test @@ -494,15 +495,14 @@ void environmentPropertiesIgnoreUnresolvablePlaceholders() { // gh-7719 addPropertiesToEnvironment(this.context, "logging.pattern.console=console ${doesnotexist}"); this.listener.initialize(this.context.getEnvironment(), this.context.getClassLoader()); - assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN)) - .isEqualTo("console ${doesnotexist}"); + assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)).isEqualTo("console ${doesnotexist}"); } @Test void environmentPropertiesResolvePlaceholders() { addPropertiesToEnvironment(this.context, "logging.pattern.console=console ${pid}"); this.listener.initialize(this.context.getEnvironment(), this.context.getClassLoader()); - assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN)) + assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)) .isEqualTo(this.context.getEnvironment().getProperty("logging.pattern.console")); } @@ -510,7 +510,7 @@ void environmentPropertiesResolvePlaceholders() { void logFilePropertiesCanReferenceSystemProperties() { addPropertiesToEnvironment(this.context, "logging.file.name=" + this.tempDir + "${PID}.log"); this.listener.initialize(this.context.getEnvironment(), this.context.getClassLoader()); - assertThat(System.getProperty(LoggingSystemProperties.LOG_FILE)) + assertThat(getSystemProperty(LoggingSystemProperty.LOG_FILE)) .isEqualTo(this.tempDir + new ApplicationPid().toString() + ".log"); } @@ -585,6 +585,10 @@ void loggingGroupsCanBeDefined() { assertTraceEnabled("com.foo.baz", true); } + private String getSystemProperty(LoggingSystemProperty property) { + return System.getProperty(property.getEnvironmentVariableName()); + } + private void assertTraceEnabled(String name, boolean expected) { assertThat(this.loggerContext.getLogger(name).isTraceEnabled()).isEqualTo(expected); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java index f48d5be9527b..7e361c87471b 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanTests.java @@ -233,24 +233,6 @@ void forValueObjectWithConstructorBindingAnnotatedClassReturnsBean() { .isNotNull(); } - @Test - void forValueObjectWithDeprecatedConstructorBindingAnnotatedClassReturnsBean() { - ConfigurationPropertiesBean propertiesBean = ConfigurationPropertiesBean - .forValueObject(DeprecatedConstructorBindingOnConstructor.class, "valueObjectBean"); - assertThat(propertiesBean.getName()).isEqualTo("valueObjectBean"); - assertThat(propertiesBean.getInstance()).isNull(); - assertThat(propertiesBean.getType()).isEqualTo(DeprecatedConstructorBindingOnConstructor.class); - assertThat(propertiesBean.asBindTarget().getBindMethod()).isEqualTo(BindMethod.VALUE_OBJECT); - assertThat(propertiesBean.getAnnotation()).isNotNull(); - Bindable target = propertiesBean.asBindTarget(); - assertThat(target.getType()) - .isEqualTo(ResolvableType.forClass(DeprecatedConstructorBindingOnConstructor.class)); - assertThat(target.getValue()).isNull(); - assertThat(BindConstructorProvider.DEFAULT.getBindConstructor(DeprecatedConstructorBindingOnConstructor.class, - false)) - .isNotNull(); - } - @Test void forValueObjectWithRecordReturnsBean() { Class implicitConstructorBinding = new ByteBuddy(ClassFileVersion.JAVA_V16).makeRecord() @@ -558,20 +540,6 @@ static class ConstructorBindingOnConstructor { } - @ConfigurationProperties - @SuppressWarnings("removal") - static class DeprecatedConstructorBindingOnConstructor { - - DeprecatedConstructorBindingOnConstructor(String name) { - this(name, -1); - } - - @org.springframework.boot.context.properties.ConstructorBinding - DeprecatedConstructorBindingOnConstructor(String name, int age) { - } - - } - @ConfigurationProperties static class ConstructorBindingOnMultipleConstructors { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesCharSequenceToObjectConverterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesCharSequenceToObjectConverterTests.java new file mode 100644 index 000000000000..3b9c12c26be1 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesCharSequenceToObjectConverterTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.properties; + +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.provider.Arguments; + +import org.springframework.boot.convert.ApplicationConversionService; +import org.springframework.boot.convert.ConversionServiceArguments; +import org.springframework.boot.convert.ConversionServiceTest; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.Converter; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ConfigurationPropertiesCharSequenceToObjectConverter} + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class ConfigurationPropertiesCharSequenceToObjectConverterTests { + + @ConversionServiceTest + void convertWhenCanConvertViaToString(ConversionService conversionService) { + assertThat(conversionService.convert(new StringBuilder("1"), Integer.class)).isOne(); + } + + @ConversionServiceTest + void convertWhenCanConvertDirectlySkipsStringConversion(ConversionService conversionService) { + assertThat(conversionService.convert(new String("1"), Long.class)).isOne(); + if (!ConversionServiceArguments.isApplicationConversionService(conversionService)) { + assertThat(conversionService.convert(new StringBuilder("1"), Long.class)).isEqualTo(2); + } + } + + @Test + @SuppressWarnings("unchecked") + void convertWhenTargetIsList() { + ConversionService conversionService = new ApplicationConversionService(); + StringBuilder source = new StringBuilder("1,2,3"); + TypeDescriptor sourceType = TypeDescriptor.valueOf(StringBuilder.class); + TypeDescriptor targetType = TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(String.class)); + List converted = (List) conversionService.convert(source, sourceType, targetType); + assertThat(converted).containsExactly("1", "2", "3"); + } + + @Test + @SuppressWarnings("unchecked") + void convertWhenTargetIsListAndNotUsingApplicationConversionService() { + FormattingConversionService conversionService = new DefaultFormattingConversionService(); + conversionService.addConverter(new ConfigurationPropertiesCharSequenceToObjectConverter(conversionService)); + StringBuilder source = new StringBuilder("1,2,3"); + TypeDescriptor sourceType = TypeDescriptor.valueOf(StringBuilder.class); + TypeDescriptor targetType = TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(String.class)); + List converted = (List) conversionService.convert(source, sourceType, targetType); + assertThat(converted).containsExactly("1", "2", "3"); + } + + static Stream conversionServices() { + return ConversionServiceArguments.with((conversionService) -> { + conversionService.addConverter(new StringToIntegerConverter()); + conversionService.addConverter(new StringToLongConverter()); + conversionService.addConverter(new CharSequenceToLongConverter()); + conversionService.addConverter(new ConfigurationPropertiesCharSequenceToObjectConverter(conversionService)); + }); + } + + static class StringToIntegerConverter implements Converter { + + @Override + public Integer convert(String source) { + return Integer.valueOf(source); + } + + } + + static class StringToLongConverter implements Converter { + + @Override + public Long convert(String source) { + return Long.valueOf(source); + } + + } + + static class CharSequenceToLongConverter implements Converter { + + @Override + public Long convert(CharSequence source) { + return Long.parseLong(source.toString()) + 1; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java index 56243ba8a682..bebaa9ae3703 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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.time.Period; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -82,6 +83,7 @@ import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; import org.springframework.core.env.StandardEnvironment; import org.springframework.core.env.SystemEnvironmentPropertySource; import org.springframework.core.io.ClassPathResource; @@ -646,24 +648,85 @@ void customProtocolResolver() { @Test void loadShouldUseConverterBean() { - prepareConverterContext(ConverterConfiguration.class, PersonProperties.class); + prepareConverterContext(PersonConverterConfiguration.class, PersonProperties.class); Person person = this.context.getBean(PersonProperties.class).getPerson(); assertThat(person.firstName).isEqualTo("John"); assertThat(person.lastName).isEqualTo("Smith"); } @Test - void loadWhenBeanFactoryConversionServiceAndConverterBean() { + void loadShouldUseStringConverterBeanWhenValueIsCharSequence() { + this.context.register(PersonConverterConfiguration.class, PersonProperties.class); + PropertySource testProperties = new MapPropertySource("test", Map.of("test.person", new CharSequence() { + + private final String value = "John Smith"; + + @Override + public int length() { + return this.value.length(); + } + + @Override + public char charAt(int index) { + return this.value.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return this.value.subSequence(start, end); + } + + @Override + public String toString() { + return this.value; + } + + })); + this.context.getEnvironment().getPropertySources().addLast(testProperties); + this.context.refresh(); + Person person = this.context.getBean(PersonProperties.class).getPerson(); + assertThat(person.firstName).isEqualTo("John"); + assertThat(person.lastName).isEqualTo("Smith"); + } + + @Test + void loadWhenBeanFactoryConversionServiceAndConverterBeanCanUseBeanFactoryConverter() { DefaultConversionService conversionService = new DefaultConversionService(); conversionService.addConverter(new AlienConverter()); this.context.getBeanFactory().setConversionService(conversionService); - load(new Class[] { ConverterConfiguration.class, PersonAndAlienProperties.class }, "test.person=John Smith", - "test.alien=Alf Tanner"); + load(new Class[] { PersonConverterConfiguration.class, PersonAndAlienProperties.class }, + "test.person=John Smith", "test.alien=Alf Tanner"); PersonAndAlienProperties properties = this.context.getBean(PersonAndAlienProperties.class); assertThat(properties.getPerson().firstName).isEqualTo("John"); assertThat(properties.getPerson().lastName).isEqualTo("Smith"); - assertThat(properties.getAlien().firstName).isEqualTo("Alf"); - assertThat(properties.getAlien().lastName).isEqualTo("Tanner"); + assertThat(properties.getAlien().name).isEqualTo("rennaT flA"); + } + + @Test + void loadWhenBeanFactoryConversionServiceAndConverterBeanCanUseConverterBean() { + DefaultConversionService conversionService = new DefaultConversionService(); + conversionService.addConverter(new PersonConverter()); + this.context.getBeanFactory().setConversionService(conversionService); + load(new Class[] { AlienConverterConfiguration.class, PersonAndAlienProperties.class }, + "test.person=John Smith", "test.alien=Alf Tanner"); + PersonAndAlienProperties properties = this.context.getBean(PersonAndAlienProperties.class); + assertThat(properties.getPerson().firstName).isEqualTo("John"); + assertThat(properties.getPerson().lastName).isEqualTo("Smith"); + assertThat(properties.getAlien().name).isEqualTo("rennaT flA"); + } + + @Test // gh-38734 + void loadWhenBeanFactoryConversionServiceAndConverterBeanCanUseConverterBeanWithCollections() { + DefaultConversionService conversionService = new DefaultConversionService(); + conversionService.addConverter(new PersonConverter()); + this.context.getBeanFactory().setConversionService(conversionService); + load(new Class[] { AlienConverterConfiguration.class, PersonAndAliensProperties.class }, + "test.person=John Smith", "test.aliens=Alf Tanner,Gilbert"); + PersonAndAliensProperties properties = this.context.getBean(PersonAndAliensProperties.class); + assertThat(properties.getPerson().firstName).isEqualTo("John"); + assertThat(properties.getPerson().lastName).isEqualTo("Smith"); + assertThat(properties.getAliens().get(0).name).isEqualTo("rennaT flA"); + assertThat(properties.getAliens().get(1).name).isEqualTo("trebliG"); } @Test @@ -1157,6 +1220,22 @@ void loadWhenPotentiallyConstructorBoundPropertiesAreImportedUsesJavaBeanBinding assertThat(properties.getProp()).isEqualTo("alpha"); } + @Test + void loadWhenBindingClasspathPatternToResourceArrayShouldBindMultipleValues() { + load(ResourceArrayPropertiesConfiguration.class, + "test.resources=classpath*:org/springframework/boot/context/properties/*.class"); + ResourceArrayProperties properties = this.context.getBean(ResourceArrayProperties.class); + assertThat(properties.getResources()).hasSizeGreaterThan(1); + } + + @Test + void loadWhenBindingClasspathPatternToResourceCollectionShouldBindMultipleValues() { + load(ResourceCollectionPropertiesConfiguration.class, + "test.resources=classpath*:org/springframework/boot/context/properties/*.class"); + ResourceCollectionProperties properties = this.context.getBean(ResourceCollectionProperties.class); + assertThat(properties.getResources()).hasSizeGreaterThan(1); + } + @Test void loadWhenBindingToConstructorParametersWithConversionToCustomListImplementation() { load(ConstructorBoundCustomListPropertiesConfiguration.class, "test.values=a,b"); @@ -1458,7 +1537,7 @@ public Resource resolve(String location, ResourceLoader resourceLoader) { } @Configuration(proxyBeanMethods = false) - static class ConverterConfiguration { + static class PersonConverterConfiguration { @Bean @ConfigurationPropertiesBinding @@ -1468,6 +1547,17 @@ Converter personConverter() { } + @Configuration(proxyBeanMethods = false) + static class AlienConverterConfiguration { + + @Bean + @ConfigurationPropertiesBinding + Converter alienConverter() { + return new AlienConverter(); + } + + } + @Configuration(proxyBeanMethods = false) static class NonQualifiedConverterConfiguration { @@ -2050,6 +2140,32 @@ void setAlien(Alien alien) { } + @EnableConfigurationProperties + @ConfigurationProperties(prefix = "test") + static class PersonAndAliensProperties { + + private Person person; + + private List aliens; + + Person getPerson() { + return this.person; + } + + void setPerson(Person person) { + this.person = person; + } + + List getAliens() { + return this.aliens; + } + + void setAliens(List aliens) { + this.aliens = aliens; + } + + } + @EnableConfigurationProperties @ConfigurationProperties(prefix = "sample") static class MapWithNumericKeyProperties { @@ -2416,8 +2532,7 @@ static class AlienConverter implements Converter { @Override public Alien convert(String source) { - String[] content = StringUtils.split(source, " "); - return new Alien(content[0], content[1]); + return new Alien(new StringBuilder(source).reverse().toString()); } } @@ -2485,21 +2600,14 @@ String getLastName() { static class Alien { - private final String firstName; - - private final String lastName; - - Alien(String firstName, String lastName) { - this.firstName = firstName; - this.lastName = lastName; - } + private final String name; - String getFirstName() { - return this.firstName; + Alien(String name) { + this.name = name; } - String getLastName() { - return this.lastName; + String getName() { + return this.name; } } @@ -3052,6 +3160,46 @@ void setProp(String prop) { } + @EnableConfigurationProperties(ResourceArrayProperties.class) + static class ResourceArrayPropertiesConfiguration { + + } + + @ConfigurationProperties("test") + static class ResourceArrayProperties { + + private Resource[] resources; + + Resource[] getResources() { + return this.resources; + } + + void setResources(Resource[] resources) { + this.resources = resources; + } + + } + + @EnableConfigurationProperties(ResourceCollectionProperties.class) + static class ResourceCollectionPropertiesConfiguration { + + } + + @ConfigurationProperties("test") + static class ResourceCollectionProperties { + + private Collection resources; + + Collection getResources() { + return this.resources; + } + + void setResources(Collection resources) { + this.resources = resources; + } + + } + @EnableConfigurationProperties(ConstructorBoundCustomListProperties.class) static class ConstructorBoundCustomListPropertiesConfiguration { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConversionServiceDeducerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConversionServiceDeducerTests.java index 1e4213830ebd..0027cb1d7cfe 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConversionServiceDeducerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConversionServiceDeducerTests.java @@ -30,6 +30,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.converter.Converter; +import org.springframework.format.support.FormattingConversionService; import static org.assertj.core.api.Assertions.assertThat; @@ -69,14 +70,16 @@ void getConversionServiceWhenHasNoConversionServiceBeanAndNoQualifiedBeansAndBea } @Test - void getConversionServiceWhenHasQualifiedConverterBeansContainsCustomizedApplicationService() { + void getConversionServiceWhenHasQualifiedConverterBeansContainsCustomizedFormattingService() { ApplicationContext applicationContext = new AnnotationConfigApplicationContext( CustomConverterConfiguration.class); ConversionServiceDeducer deducer = new ConversionServiceDeducer(applicationContext); List conversionServices = deducer.getConversionServices(); - assertThat(conversionServices).hasSize(1); - assertThat(conversionServices.get(0)).isNotSameAs(ApplicationConversionService.getSharedInstance()); + assertThat(conversionServices).hasSize(2); + assertThat(conversionServices.get(0)).isExactlyInstanceOf(FormattingConversionService.class); assertThat(conversionServices.get(0).canConvert(InputStream.class, OutputStream.class)).isTrue(); + assertThat(conversionServices.get(0).canConvert(CharSequence.class, InputStream.class)).isTrue(); + assertThat(conversionServices.get(1)).isSameAs(ApplicationConversionService.getSharedInstance()); } @Configuration(proxyBeanMethods = false) @@ -103,6 +106,12 @@ TestConverter testConverter() { return new TestConverter(); } + @Bean + @ConfigurationPropertiesBinding + StringConverter stringConverter() { + return new StringConverter(); + } + } private static final class TestApplicationConversionService extends ApplicationConversionService { @@ -118,4 +127,13 @@ public OutputStream convert(InputStream source) { } + private static final class StringConverter implements Converter { + + @Override + public InputStream convert(String source) { + throw new UnsupportedOperationException(); + } + + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java index aeffd12431fb..ac95a9d1d617 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/ValueObjectBinderTests.java @@ -26,11 +26,14 @@ import java.util.Objects; import java.util.Optional; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.internal.CharacterIndex; import org.junit.jupiter.api.Test; import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.boot.context.properties.source.ConfigurationPropertySource; import org.springframework.boot.context.properties.source.MockConfigurationPropertySource; +import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.ResolvableType; import org.springframework.core.convert.ConversionService; import org.springframework.core.test.tools.SourceFile; @@ -391,6 +394,36 @@ public record RecordProperties( }); } + @Test // gh-38201 + void bindWhenNonExtractableParameterNamesOnPropertyAndNonIterablePropertySource() throws Exception { + verifyJsonPathParametersCannotBeResolved(); + MockConfigurationPropertySource source = new MockConfigurationPropertySource(); + source.put("test.value", "test"); + this.sources.add(source.nonIterable()); + Bindable target = Bindable.of(NonExtractableParameterName.class); + NonExtractableParameterName bound = this.binder.bindOrCreate("test", target); + assertThat(bound.getValue()).isEqualTo("test"); + } + + @Test + void createWhenNonExtractableParameterNamesOnPropertyAndNonIterablePropertySource() throws Exception { + assertThat(new DefaultParameterNameDiscoverer() + .getParameterNames(CharacterIndex.class.getDeclaredConstructor(CharSequence.class))).isNull(); + MockConfigurationPropertySource source = new MockConfigurationPropertySource(); + this.sources.add(source.nonIterable()); + Bindable target = Bindable.of(CharacterIndex.class).withBindMethod(BindMethod.VALUE_OBJECT); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> this.binder.bindOrCreate("test", target)) + .withStackTraceContaining("Ensure that the compiler uses the '-parameters' flag"); + } + + private void verifyJsonPathParametersCannotBeResolved() throws NoSuchFieldException { + Class jsonPathClass = NonExtractableParameterName.class.getDeclaredField("jsonPath").getType(); + Constructor[] constructors = jsonPathClass.getDeclaredConstructors(); + assertThat(constructors).hasSize(1); + constructors[0].setAccessible(true); + assertThat(new DefaultParameterNameDiscoverer().getParameterNames(constructors[0])).isNull(); + } + private void noConfigurationProperty(BindException ex) { assertThat(ex.getProperty()).isNull(); } @@ -845,4 +878,28 @@ String getImportName() { } + static class NonExtractableParameterName { + + private String value; + + private JsonPath jsonPath; + + String getValue() { + return this.value; + } + + void setValue(String value) { + this.value = value; + } + + JsonPath getJsonPath() { + return this.jsonPath; + } + + void setJsonPath(JsonPath jsonPath) { + this.jsonPath = jsonPath; + } + + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/ConfigurationPropertySourcesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/ConfigurationPropertySourcesTests.java index 552bbbc2effa..5907688999b9 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/ConfigurationPropertySourcesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/ConfigurationPropertySourcesTests.java @@ -54,7 +54,7 @@ void attachShouldAddAdapterAtBeginning() { sources.addLast(new MapPropertySource("config", Collections.singletonMap("server.port", "4568"))); int size = sources.size(); ConfigurationPropertySources.attach(environment); - assertThat(sources.size()).isEqualTo(size + 1); + assertThat(sources).hasSize(size + 1); PropertyResolver resolver = new PropertySourcesPropertyResolver(sources); assertThat(resolver.getProperty("server.port")).isEqualTo("1234"); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/CharSequenceToObjectConverterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/CharSequenceToObjectConverterTests.java index 49269b03e596..10382dd4e01c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/CharSequenceToObjectConverterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/CharSequenceToObjectConverterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -104,7 +104,7 @@ static class CharSequenceToLongConverter implements Converter with(Formatter formatter) { + public static Stream with(Formatter formatter) { return with((conversionService) -> conversionService.addFormatter(formatter)); } - static Stream with(GenericConverter converter) { + public static Stream with(GenericConverter converter) { return with((conversionService) -> conversionService.addConverter(converter)); } - static Stream with(Consumer initializer) { + public static Stream with(Consumer initializer) { FormattingConversionService withoutDefaults = new FormattingConversionService(); initializer.accept(withoutDefaults); return Stream.of( @@ -57,7 +57,7 @@ static Stream with(Consumer in "Application conversion service"))); } - static boolean isApplicationConversionService(ConversionService conversionService) { + public static boolean isApplicationConversionService(ConversionService conversionService) { if (conversionService instanceof NamedConversionService namedConversionService) { return isApplicationConversionService(namedConversionService.delegate); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/ConversionServiceTest.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/ConversionServiceTest.java index 37ba16740fd7..522a010813d8 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/ConversionServiceTest.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/convert/ConversionServiceTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 the original author 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,6 @@ @MethodSource("conversionServices") @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -@interface ConversionServiceTest { +public @interface ConversionServiceTest { } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/FailureAnalyzersTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/FailureAnalyzersTests.java index d1fdcd7747ed..0ed9267e6ee0 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/FailureAnalyzersTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/FailureAnalyzersTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,16 +21,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.BeanFactory; -import org.springframework.beans.factory.BeanFactoryAware; -import org.springframework.boot.testsupport.system.CapturedOutput; import org.springframework.boot.testsupport.system.OutputCaptureExtension; -import org.springframework.context.EnvironmentAware; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.core.env.Environment; import org.springframework.core.test.io.support.MockSpringFactoriesLoader; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.same; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -45,52 +41,34 @@ @ExtendWith(OutputCaptureExtension.class) class FailureAnalyzersTests { - private static AwareFailureAnalyzer failureAnalyzer; + private static FailureAnalyzer failureAnalyzer; private final AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); @BeforeEach void configureMock() { - failureAnalyzer = mock(AwareFailureAnalyzer.class); + failureAnalyzer = mock(FailureAnalyzer.class); } @Test void analyzersAreLoadedAndCalled() { RuntimeException failure = new RuntimeException(); - analyzeAndReport(failure, BasicFailureAnalyzer.class, StandardAwareFailureAnalyzer.class); + analyzeAndReport(failure, BasicFailureAnalyzer.class, BasicFailureAnalyzer.class); then(failureAnalyzer).should(times(2)).analyze(failure); } @Test - void analyzerIsConstructedWithBeanFactory(CapturedOutput output) { + void analyzerIsConstructedWithBeanFactory() { RuntimeException failure = new RuntimeException(); analyzeAndReport(failure, BasicFailureAnalyzer.class, BeanFactoryConstructorFailureAnalyzer.class); then(failureAnalyzer).should(times(2)).analyze(failure); - assertThat(output).doesNotContain("implement BeanFactoryAware or EnvironmentAware"); } @Test - void analyzerIsConstructedWithEnvironment(CapturedOutput output) { + void analyzerIsConstructedWithEnvironment() { RuntimeException failure = new RuntimeException(); analyzeAndReport(failure, BasicFailureAnalyzer.class, EnvironmentConstructorFailureAnalyzer.class); then(failureAnalyzer).should(times(2)).analyze(failure); - assertThat(output).doesNotContain("implement BeanFactoryAware or EnvironmentAware"); - } - - @Test - void beanFactoryIsInjectedIntoBeanFactoryAwareFailureAnalyzers(CapturedOutput output) { - RuntimeException failure = new RuntimeException(); - analyzeAndReport(failure, BasicFailureAnalyzer.class, StandardAwareFailureAnalyzer.class); - then(failureAnalyzer).should().setBeanFactory(same(this.context.getBeanFactory())); - assertThat(output).contains("FailureAnalyzers [" + StandardAwareFailureAnalyzer.class.getName() - + "] implement BeanFactoryAware or EnvironmentAware."); - } - - @Test - void environmentIsInjectedIntoEnvironmentAwareFailureAnalyzers() { - RuntimeException failure = new RuntimeException(); - analyzeAndReport(failure, BasicFailureAnalyzer.class, StandardAwareFailureAnalyzer.class); - then(failureAnalyzer).should().setEnvironment(same(this.context.getEnvironment())); } @Test @@ -170,22 +148,4 @@ static class EnvironmentConstructorFailureAnalyzer extends BasicFailureAnalyzer } - interface AwareFailureAnalyzer extends BeanFactoryAware, EnvironmentAware, FailureAnalyzer { - - } - - static class StandardAwareFailureAnalyzer extends BasicFailureAnalyzer implements AwareFailureAnalyzer { - - @Override - public void setEnvironment(Environment environment) { - failureAnalyzer.setEnvironment(environment); - } - - @Override - public void setBeanFactory(BeanFactory beanFactory) { - failureAnalyzer.setBeanFactory(beanFactory); - } - - } - } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BindFailureAnalyzerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BindFailureAnalyzerTests.java index 5d5f09d03230..5d03732ff9e0 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BindFailureAnalyzerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/BindFailureAnalyzerTests.java @@ -99,6 +99,19 @@ void bindExceptionDueToMapConversionFailure() { + "org.springframework.boot.logging.LogLevel>]")); } + @Test + void bindExceptionWithSupressedMissingParametersException() { + BeanCreationException failure = createFailure(GenericFailureConfiguration.class, "test.foo.value=alpha"); + failure.addSuppressed(new IllegalStateException( + "Missing parameter names. Ensure that the compiler uses the '-parameters' flag")); + FailureAnalysis analysis = new BindFailureAnalyzer().analyze(failure); + assertThat(analysis.getDescription()) + .contains(failure("test.foo.value", "alpha", "\"test.foo.value\" from property source \"test\"", + "failed to convert java.lang.String to int")) + .contains(MissingParameterNamesFailureAnalyzer.POSSIBILITY); + assertThat(analysis.getAction()).contains(MissingParameterNamesFailureAnalyzer.ACTION); + } + private static String failure(String property, String value, String origin, String reason) { return String.format("Property: %s%n Value: \"%s\"%n Origin: %s%n Reason: %s", property, value, origin, reason); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/MissingParameterNamesFailureAnalyzerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/MissingParameterNamesFailureAnalyzerTests.java new file mode 100644 index 000000000000..9251d201d8d4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/MissingParameterNamesFailureAnalyzerTests.java @@ -0,0 +1,123 @@ +/* + * Copyright 2012-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.boot.diagnostics.analyzer; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.diagnostics.FailureAnalysis; +import org.springframework.core.MethodParameter; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MissingParameterNamesFailureAnalyzer}. + * + * @author Phillip Webb + */ +class MissingParameterNamesFailureAnalyzerTests { + + @Test + void analyzeWhenMissingParametersExceptionReturnsFailure() throws Exception { + MissingParameterNamesFailureAnalyzer analyzer = new MissingParameterNamesFailureAnalyzer(); + FailureAnalysis analysis = analyzer.analyze(getSpringFrameworkMissingParameterException()); + assertThat(analysis.getDescription()) + .isEqualTo(String.format("Name for argument of type [java.lang.String] not specified, and parameter name " + + "information not available via reflection. Ensure that the compiler uses the '-parameters' flag.:%n")); + assertThat(analysis.getAction()).isEqualTo(MissingParameterNamesFailureAnalyzer.ACTION); + } + + @Test + void analyzeForMissingParametersWhenMissingParametersExceptionReturnsFailure() throws Exception { + FailureAnalysis analysis = MissingParameterNamesFailureAnalyzer + .analyzeForMissingParameters(getSpringFrameworkMissingParameterException()); + assertThat(analysis.getDescription()) + .isEqualTo(String.format("Name for argument of type [java.lang.String] not specified, and parameter name " + + "information not available via reflection. Ensure that the compiler uses the '-parameters' flag.:%n")); + assertThat(analysis.getAction()).isEqualTo(MissingParameterNamesFailureAnalyzer.ACTION); + } + + @Test + void analyzeForMissingParametersWhenInCauseReturnsFailure() throws Exception { + RuntimeException exception = new RuntimeException("Badness", getSpringFrameworkMissingParameterException()); + FailureAnalysis analysis = MissingParameterNamesFailureAnalyzer.analyzeForMissingParameters(exception); + assertThat(analysis.getDescription()) + .isEqualTo(String.format("Name for argument of type [java.lang.String] not specified, and parameter name " + + "information not available via reflection. Ensure that the compiler uses the '-parameters' flag.:%n%n" + + " Resulting Failure: java.lang.RuntimeException: Badness")); + assertThat(analysis.getAction()).isEqualTo(MissingParameterNamesFailureAnalyzer.ACTION); + } + + @Test + void analyzeForMissingParametersWhenInSuppressedReturnsFailure() throws Exception { + RuntimeException exception = new RuntimeException("Badness"); + exception.addSuppressed(getSpringFrameworkMissingParameterException()); + FailureAnalysis analysis = MissingParameterNamesFailureAnalyzer.analyzeForMissingParameters(exception); + assertThat(analysis.getDescription()) + .isEqualTo(String.format("Name for argument of type [java.lang.String] not specified, and parameter name " + + "information not available via reflection. Ensure that the compiler uses the '-parameters' flag.:%n%n" + + " Resulting Failure: java.lang.RuntimeException: Badness")); + assertThat(analysis.getAction()).isEqualTo(MissingParameterNamesFailureAnalyzer.ACTION); + } + + @Test + void analyzeForMissingParametersWhenNotPresentReturnsNull() { + RuntimeException exception = new RuntimeException("Badness"); + FailureAnalysis analysis = MissingParameterNamesFailureAnalyzer.analyzeForMissingParameters(exception); + assertThat(analysis).isNull(); + } + + private RuntimeException getSpringFrameworkMissingParameterException() throws Exception { + MockResolver resolver = new MockResolver(); + Method method = getClass().getDeclaredMethod("example", String.class); + MethodParameter parameter = new MethodParameter(method, 0); + try { + resolver.resolveArgument(parameter, null, null, null); + } + catch (RuntimeException ex) { + return ex; + } + throw new AssertionError("Did not throw"); + } + + void example(String name) { + } + + static class MockResolver extends AbstractNamedValueMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return true; + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + return new NamedValueInfo("", false, null); + } + + @Override + protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) + throws Exception { + return null; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/NoUniqueBeanDefinitionFailureAnalyzerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/NoUniqueBeanDefinitionFailureAnalyzerTests.java index 35943e7ccf64..e8600faabe2a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/NoUniqueBeanDefinitionFailureAnalyzerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/NoUniqueBeanDefinitionFailureAnalyzerTests.java @@ -93,6 +93,14 @@ void failureAnalysisForObjectProviderConstructorConsumer() { assertFoundBeans(failureAnalysis); } + @Test + void failureAnalysisIncludesPossiblyMissingParameterNames() { + FailureAnalysis failureAnalysis = analyzeFailure(createFailure(MethodConsumer.class)); + assertThat(failureAnalysis.getDescription()).contains(MissingParameterNamesFailureAnalyzer.POSSIBILITY); + assertThat(failureAnalysis.getAction()).contains(MissingParameterNamesFailureAnalyzer.ACTION); + assertFoundBeans(failureAnalysis); + } + private BeanCreationException createFailure(Class consumer) { this.context.registerBean("beanOne", TestBean.class); this.context.register(DuplicateBeansProducer.class, consumer); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/OriginTrackedYamlLoaderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/OriginTrackedYamlLoaderTests.java index b51d41a9cdfe..ce5fa86434b1 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/OriginTrackedYamlLoaderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/OriginTrackedYamlLoaderTests.java @@ -23,7 +23,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.yaml.snakeyaml.constructor.ConstructorException; +import org.yaml.snakeyaml.composer.ComposerException; import org.springframework.boot.origin.OriginTrackedValue; import org.springframework.boot.origin.TextResourceOrigin; @@ -134,7 +134,7 @@ void unsupportedType() { String yaml = "value: !!java.net.URL [!!java.lang.String [!!java.lang.StringBuilder [\"http://localhost:9000/\"]]]"; Resource resource = new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)); this.loader = new OriginTrackedYamlLoader(resource); - assertThatExceptionOfType(ConstructorException.class).isThrownBy(this.loader::load); + assertThatExceptionOfType(ComposerException.class).isThrownBy(this.loader::load); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/RandomValuePropertySourceTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/RandomValuePropertySourceTests.java index 77732be5dbd9..1fb9ad176d81 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/RandomValuePropertySourceTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/env/RandomValuePropertySourceTests.java @@ -37,6 +37,7 @@ * * @author Dave Syer * @author Matt Benson + * @author Moritz Halbritter */ class RandomValuePropertySourceTests { @@ -192,4 +193,9 @@ void addToEnvironmentAddsAfterSystemEnvironment() { RandomValuePropertySource.RANDOM_PROPERTY_SOURCE_NAME, "mockProperties"); } + @Test + void randomStringIs32CharsLong() { + assertThat(this.source.getProperty("random.string")).asString().hasSize(32); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/ProcessInfoTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/ProcessInfoTests.java new file mode 100644 index 000000000000..289581f53775 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/ProcessInfoTests.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.info; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ProcessInfo}. + * + * @author Jonatan Ivanov + */ +class ProcessInfoTests { + + @Test + void processInfoIsAvailable() { + ProcessInfo processInfo = new ProcessInfo(); + assertThat(processInfo.getCpus()).isEqualTo(Runtime.getRuntime().availableProcessors()); + assertThat(processInfo.getOwner()).isEqualTo(ProcessHandle.current().info().user().orElse(null)); + assertThat(processInfo.getPid()).isEqualTo(ProcessHandle.current().pid()); + assertThat(processInfo.getParentPid()) + .isEqualTo(ProcessHandle.current().parent().map(ProcessHandle::pid).orElse(null)); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java index 42bf7a8f8181..768a1c0b8da4 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jackson/JsonMixinModuleTests.java @@ -17,7 +17,6 @@ package org.springframework.boot.jackson; import java.util.Arrays; -import java.util.Collections; import java.util.List; import com.fasterxml.jackson.databind.Module; @@ -35,7 +34,6 @@ import org.springframework.util.ClassUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** * Tests for {@link JsonMixinModule}. @@ -53,14 +51,6 @@ void closeContext() { } } - @Test - @Deprecated(since = "3.0.0", forRemoval = true) - @SuppressWarnings("removal") - void createWhenContextIsNullShouldThrowException() { - assertThatIllegalArgumentException().isThrownBy(() -> new JsonMixinModule(null, Collections.emptyList())) - .withMessageContaining("Context must not be null"); - } - @Test void jsonWithModuleWithRenameMixInClassShouldBeMixedIn() throws Exception { load(RenameMixInClass.class); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java new file mode 100644 index 000000000000..119af02e6021 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2012-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.boot.jdbc; + +import java.util.UUID; + +import javax.sql.DataSource; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link HikariCheckpointRestoreLifecycle}. + * + * @author Christoph Strobl + * @author Andy Wilkinson + */ +class HikariCheckpointRestoreLifecycleTests { + + private final HikariCheckpointRestoreLifecycle lifecycle; + + private final HikariDataSource dataSource; + + HikariCheckpointRestoreLifecycleTests() { + HikariConfig config = new HikariConfig(); + config.setAllowPoolSuspension(true); + config.setJdbcUrl("jdbc:hsqldb:mem:test-" + UUID.randomUUID()); + config.setPoolName("lifecycle-tests"); + this.dataSource = new HikariDataSource(config); + this.lifecycle = new HikariCheckpointRestoreLifecycle(this.dataSource); + } + + @Test + void startedWhenStartedShouldSucceed() { + assertThat(this.lifecycle.isRunning()).isTrue(); + this.lifecycle.start(); + assertThat(this.lifecycle.isRunning()).isTrue(); + } + + @Test + void stopWhenStoppedShouldSucceed() { + assertThat(this.lifecycle.isRunning()).isTrue(); + this.lifecycle.stop(); + assertThat(this.dataSource.isRunning()).isFalse(); + assertThatNoException().isThrownBy(this.lifecycle::stop); + } + + @Test + void whenStoppedAndStartedDataSourceShouldPauseAndResume() { + assertThat(this.lifecycle.isRunning()).isTrue(); + this.lifecycle.stop(); + assertThat(this.dataSource.isRunning()).isFalse(); + assertThat(this.dataSource.isClosed()).isFalse(); + assertThat(this.lifecycle.isRunning()).isFalse(); + assertThat(this.dataSource.getHikariPoolMXBean().getTotalConnections()).isZero(); + this.lifecycle.start(); + assertThat(this.dataSource.isRunning()).isTrue(); + assertThat(this.dataSource.isClosed()).isFalse(); + assertThat(this.lifecycle.isRunning()).isTrue(); + } + + @Test + void whenDataSourceIsClosedThenStartShouldThrow() { + this.dataSource.close(); + assertThatExceptionOfType(RuntimeException.class).isThrownBy(this.lifecycle::start); + } + + @Test + void startHasNoEffectWhenDataSourceIsNotAHikariDataSource() { + HikariCheckpointRestoreLifecycle nonHikariLifecycle = new HikariCheckpointRestoreLifecycle( + mock(DataSource.class)); + assertThat(nonHikariLifecycle.isRunning()).isFalse(); + nonHikariLifecycle.start(); + assertThat(nonHikariLifecycle.isRunning()).isFalse(); + } + + @Test + void stopHasNoEffectWhenDataSourceIsNotAHikariDataSource() { + HikariCheckpointRestoreLifecycle nonHikariLifecycle = new HikariCheckpointRestoreLifecycle( + mock(DataSource.class)); + assertThat(nonHikariLifecycle.isRunning()).isFalse(); + nonHikariLifecycle.stop(); + assertThat(nonHikariLifecycle.isRunning()).isFalse(); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/metadata/CommonsDbcp2DataSourcePoolMetadataTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/metadata/CommonsDbcp2DataSourcePoolMetadataTests.java index 75d4ac1a55e9..72c9420a98ef 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/metadata/CommonsDbcp2DataSourcePoolMetadataTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/jdbc/metadata/CommonsDbcp2DataSourcePoolMetadataTests.java @@ -16,6 +16,8 @@ package org.springframework.boot.jdbc.metadata; +import java.time.Duration; + import org.apache.commons.dbcp2.BasicDataSource; import org.junit.jupiter.api.Test; @@ -83,7 +85,7 @@ private CommonsDbcp2DataSourcePoolMetadata createDataSourceMetadata(int minSize, BasicDataSource dataSource = createDataSource(); dataSource.setMinIdle(minSize); dataSource.setMaxTotal(maxSize); - dataSource.setMinEvictableIdleTimeMillis(5000); + dataSource.setMinEvictableIdle(Duration.ofSeconds(5)); return new CommonsDbcp2DataSourcePoolMetadata(dataSource); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/AbstractJsonParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/AbstractJsonParserTests.java index 446d0ffe236b..fed13ff2b31c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/AbstractJsonParserTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/AbstractJsonParserTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import org.springframework.util.StreamUtils; @@ -105,7 +106,7 @@ void mapOfLists() { .parseMap("{\"foo\":[{\"foo\":\"bar\",\"spam\":1},{\"foo\":\"baz\",\"spam\":2}]}"); assertThat(map).hasSize(1); assertThat(((List) map.get("foo"))).hasSize(2); - assertThat(map.get("foo")).asList().allMatch(Map.class::isInstance); + assertThat(map.get("foo")).asInstanceOf(InstanceOfAssertFactories.LIST).allMatch(Map.class::isInstance); } @SuppressWarnings("unchecked") @@ -115,7 +116,7 @@ void nestedLeadingAndTrailingWhitespace() { .parseMap(" {\"foo\": [ { \"foo\" : \"bar\" , \"spam\" : 1 } , { \"foo\" : \"baz\" , \"spam\" : 2 } ] } "); assertThat(map).hasSize(1); assertThat(((List) map.get("foo"))).hasSize(2); - assertThat(map.get("foo")).asList().allMatch(Map.class::isInstance); + assertThat(map.get("foo")).asInstanceOf(InstanceOfAssertFactories.LIST).allMatch(Map.class::isInstance); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/GsonJsonParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/GsonJsonParserTests.java index 757a2d3b0da6..3a7b7f04aa84 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/GsonJsonParserTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/GsonJsonParserTests.java @@ -18,8 +18,6 @@ import java.io.IOException; -import org.junit.jupiter.api.Disabled; - /** * Tests for {@link GsonJsonParser}. * @@ -33,9 +31,8 @@ protected JsonParser getParser() { } @Override - @Disabled("Gson does not protect against deeply nested JSON") void listWithRepeatedOpenArray() throws IOException { - super.listWithRepeatedOpenArray(); + // Gson does not protect against deeply nested JSON } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/AbstractLoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/AbstractLoggingSystemTests.java index 3ccc16255886..cba035869fff 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/AbstractLoggingSystemTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/AbstractLoggingSystemTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-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. @@ -16,14 +16,19 @@ package org.springframework.boot.logging; +import java.io.File; import java.nio.file.Path; +import java.util.Arrays; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.io.TempDir; +import org.slf4j.MDC; import org.springframework.util.StringUtils; +import static org.assertj.core.api.Assertions.contentOf; + /** * Base for {@link LoggingSystem} tests. * @@ -41,6 +46,7 @@ public abstract class AbstractLoggingSystemTests { void configureTempDir(@TempDir Path temp) { this.originalTempDirectory = System.getProperty(JAVA_IO_TMPDIR); System.setProperty(JAVA_IO_TMPDIR, temp.toAbsolutePath().toString()); + MDC.clear(); } @AfterEach @@ -50,8 +56,10 @@ void reinstateTempDir() { @AfterEach void clear() { - System.clearProperty(LoggingSystemProperties.LOG_FILE); - System.clearProperty(LoggingSystemProperties.PID_KEY); + for (LoggingSystemProperty property : LoggingSystemProperty.values()) { + System.getProperties().remove(property.getEnvironmentVariableName()); + } + MDC.clear(); } protected final String[] getSpringConfigLocations(AbstractLoggingSystem system) { @@ -78,4 +86,15 @@ protected final String tmpDir() { return path; } + protected final String getLineWithText(File file, CharSequence outputSearch) { + return getLineWithText(contentOf(file), outputSearch); + } + + protected final String getLineWithText(CharSequence output, CharSequence outputSearch) { + return Arrays.stream(output.toString().split("\\r?\\n")) + .filter((line) -> line.contains(outputSearch)) + .findFirst() + .orElse(null); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/CorrelationIdFormatterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/CorrelationIdFormatterTests.java new file mode 100644 index 000000000000..2e36d0f00fc0 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/CorrelationIdFormatterTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-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.boot.logging; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link CorrelationIdFormatter}. + * + * @author Phillip Webb + */ +class CorrelationIdFormatterTests { + + @Test + void formatWithDefaultSpecWhenHasBothParts() { + Map context = new HashMap<>(); + context.put("traceId", "01234567890123456789012345678901"); + context.put("spanId", "0123456789012345"); + String formatted = CorrelationIdFormatter.DEFAULT.format(context::get); + assertThat(formatted).isEqualTo("[01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void formatWithDefaultSpecWhenHasNoParts() { + Map context = new HashMap<>(); + String formatted = CorrelationIdFormatter.DEFAULT.format(context::get); + assertThat(formatted).isEqualTo("[ ] "); + } + + @Test + void formatWithDefaultSpecWhenHasOnlyFirstPart() { + Map context = new HashMap<>(); + context.put("traceId", "01234567890123456789012345678901"); + String formatted = CorrelationIdFormatter.DEFAULT.format(context::get); + assertThat(formatted).isEqualTo("[01234567890123456789012345678901- ] "); + } + + @Test + void formatWithDefaultSpecWhenHasOnlySecondPart() { + Map context = new HashMap<>(); + context.put("spanId", "0123456789012345"); + String formatted = CorrelationIdFormatter.DEFAULT.format(context::get); + assertThat(formatted).isEqualTo("[ -0123456789012345] "); + } + + @Test + void formatWhenPartsAreShort() { + Map context = new HashMap<>(); + context.put("traceId", "0123456789012345678901234567"); + context.put("spanId", "012345678901"); + String formatted = CorrelationIdFormatter.DEFAULT.format(context::get); + assertThat(formatted).isEqualTo("[0123456789012345678901234567 -012345678901 ] "); + } + + @Test + void formatWhenPartsAreLong() { + Map context = new HashMap<>(); + context.put("traceId", "01234567890123456789012345678901FFFF"); + context.put("spanId", "0123456789012345FFFF"); + String formatted = CorrelationIdFormatter.DEFAULT.format(context::get); + assertThat(formatted).isEqualTo("[01234567890123456789012345678901FFFF-0123456789012345FFFF] "); + } + + @Test + void formatWithCustomSpec() { + Map context = new HashMap<>(); + context.put("a", "01234567890123456789012345678901"); + context.put("b", "0123456789012345"); + String formatted = CorrelationIdFormatter.of("a(32),b(16)").format(context::get); + assertThat(formatted).isEqualTo("[01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void formatToWithDefaultSpec() { + Map context = new HashMap<>(); + context.put("traceId", "01234567890123456789012345678901"); + context.put("spanId", "0123456789012345"); + StringBuilder formatted = new StringBuilder(); + CorrelationIdFormatter.DEFAULT.formatTo(context::get, formatted); + assertThat(formatted).hasToString("[01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void ofWhenSpecIsMalformed() { + assertThatIllegalStateException().isThrownBy(() -> CorrelationIdFormatter.of("good(12),bad")) + .withMessage("Unable to parse correlation formatter spec 'good(12),bad'") + .havingCause() + .withMessage("Invalid specification part 'bad'"); + } + + @Test + void ofWhenSpecIsEmpty() { + assertThat(CorrelationIdFormatter.of("")).isSameAs(CorrelationIdFormatter.DEFAULT); + } + + @Test + void toStringReturnsSpec() { + assertThat(CorrelationIdFormatter.DEFAULT).hasToString("traceId(32),spanId(16)"); + assertThat(CorrelationIdFormatter.of("a(32),b(16)")).hasToString("a(32),b(16)"); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LogFileTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LogFileTests.java index 46ab410e0ee2..6639436655dd 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LogFileTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LogFileTests.java @@ -57,8 +57,9 @@ private void testLoggingFile(PropertyResolver resolver) { Properties properties = new Properties(); logFile.applyTo(properties); assertThat(logFile).hasToString("log.file"); - assertThat(properties.getProperty(LoggingSystemProperties.LOG_FILE)).isEqualTo("log.file"); - assertThat(properties.getProperty(LoggingSystemProperties.LOG_PATH)).isNull(); + assertThat(properties.getProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName())) + .isEqualTo("log.file"); + assertThat(properties.getProperty(LoggingSystemProperty.LOG_PATH.getEnvironmentVariableName())).isNull(); } @Test @@ -72,9 +73,10 @@ private void testLoggingPath(PropertyResolver resolver) { Properties properties = new Properties(); logFile.applyTo(properties); assertThat(logFile).hasToString("logpath" + File.separatorChar + "spring.log"); - assertThat(properties.getProperty(LoggingSystemProperties.LOG_FILE)) + assertThat(properties.getProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName())) .isEqualTo("logpath" + File.separatorChar + "spring.log"); - assertThat(properties.getProperty(LoggingSystemProperties.LOG_PATH)).isEqualTo("logpath"); + assertThat(properties.getProperty(LoggingSystemProperty.LOG_PATH.getEnvironmentVariableName())) + .isEqualTo("logpath"); } @Test @@ -91,8 +93,10 @@ private void testLoggingFileAndPath(PropertyResolver resolver) { Properties properties = new Properties(); logFile.applyTo(properties); assertThat(logFile).hasToString("log.file"); - assertThat(properties.getProperty(LoggingSystemProperties.LOG_FILE)).isEqualTo("log.file"); - assertThat(properties.getProperty(LoggingSystemProperties.LOG_PATH)).isEqualTo("logpath"); + assertThat(properties.getProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName())) + .isEqualTo("log.file"); + assertThat(properties.getProperty(LoggingSystemProperty.LOG_PATH.getEnvironmentVariableName())) + .isEqualTo("logpath"); } private PropertyResolver getPropertyResolver(Map properties) { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java index 3fb0106d718a..2c5a1aaf2855 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/LoggingSystemPropertiesTests.java @@ -18,6 +18,7 @@ import java.util.Collections; import java.util.HashSet; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.AfterEach; @@ -36,6 +37,7 @@ * * @author Andy Wilkinson * @author Eddú Meléndez + * @author Jonatan Ivanov */ class LoggingSystemPropertiesTests { @@ -43,8 +45,9 @@ class LoggingSystemPropertiesTests { @BeforeEach void captureSystemPropertyNames() { - System.getProperties().remove(LoggingSystemProperties.CONSOLE_LOG_CHARSET); - System.getProperties().remove(LoggingSystemProperties.FILE_LOG_CHARSET); + for (LoggingSystemProperty property : LoggingSystemProperty.values()) { + System.getProperties().remove(property.getEnvironmentVariableName()); + } this.systemPropertyNames = new HashSet<>(System.getProperties().keySet()); } @@ -56,58 +59,100 @@ void restoreSystemProperties() { @Test void pidIsSet() { new LoggingSystemProperties(new MockEnvironment()).apply(null); - assertThat(System.getProperty(LoggingSystemProperties.PID_KEY)).isNotNull(); + assertThat(getSystemProperty(LoggingSystemProperty.PID)).isNotNull(); } @Test void consoleLogPatternIsSet() { new LoggingSystemProperties(new MockEnvironment().withProperty("logging.pattern.console", "console pattern")) .apply(null); - assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN)).isEqualTo("console pattern"); + assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)).isEqualTo("console pattern"); } @Test void consoleCharsetWhenNoPropertyUsesUtf8() { new LoggingSystemProperties(new MockEnvironment()).apply(null); - assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_CHARSET)).isEqualTo("UTF-8"); + assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_CHARSET)).isEqualTo("UTF-8"); } @Test void consoleCharsetIsSet() { new LoggingSystemProperties(new MockEnvironment().withProperty("logging.charset.console", "UTF-16")) .apply(null); - assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_CHARSET)).isEqualTo("UTF-16"); + assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_CHARSET)).isEqualTo("UTF-16"); } @Test void fileLogPatternIsSet() { new LoggingSystemProperties(new MockEnvironment().withProperty("logging.pattern.file", "file pattern")) .apply(null); - assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_PATTERN)).isEqualTo("file pattern"); + assertThat(getSystemProperty(LoggingSystemProperty.FILE_PATTERN)).isEqualTo("file pattern"); } @Test void fileCharsetWhenNoPropertyUsesUtf8() { new LoggingSystemProperties(new MockEnvironment()).apply(null); - assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_CHARSET)).isEqualTo("UTF-8"); + assertThat(getSystemProperty(LoggingSystemProperty.FILE_CHARSET)).isEqualTo("UTF-8"); } @Test void fileCharsetIsSet() { new LoggingSystemProperties(new MockEnvironment().withProperty("logging.charset.file", "UTF-16")).apply(null); - assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_CHARSET)).isEqualTo("UTF-16"); + assertThat(getSystemProperty(LoggingSystemProperty.FILE_CHARSET)).isEqualTo("UTF-16"); } @Test void consoleLogPatternCanReferencePid() { new LoggingSystemProperties(environment("logging.pattern.console", "${PID:unknown}")).apply(null); - assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_PATTERN)).matches("[0-9]+"); + assertThat(getSystemProperty(LoggingSystemProperty.CONSOLE_PATTERN)).matches("[0-9]+"); } @Test void fileLogPatternCanReferencePid() { new LoggingSystemProperties(environment("logging.pattern.file", "${PID:unknown}")).apply(null); - assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_PATTERN)).matches("[0-9]+"); + assertThat(getSystemProperty(LoggingSystemProperty.FILE_PATTERN)).matches("[0-9]+"); + } + + private String getSystemProperty(LoggingSystemProperty property) { + return System.getProperty(property.getEnvironmentVariableName()); + } + + @Test + void correlationPatternIsSet() { + new LoggingSystemProperties( + new MockEnvironment().withProperty("logging.pattern.correlation", "correlation pattern")) + .apply(null); + assertThat(System.getProperty(LoggingSystemProperty.CORRELATION_PATTERN.getEnvironmentVariableName())) + .isEqualTo("correlation pattern"); + } + + @Test + void defaultValueResolverIsUsed() { + MockEnvironment environment = new MockEnvironment(); + Map defaultValues = Map + .of(LoggingSystemProperty.CORRELATION_PATTERN.getApplicationPropertyName(), "default correlation pattern"); + new LoggingSystemProperties(environment, defaultValues::get, null).apply(null); + assertThat(System.getProperty(LoggingSystemProperty.CORRELATION_PATTERN.getEnvironmentVariableName())) + .isEqualTo("default correlation pattern"); + } + + @Test + void loggedApplicationNameWhenHasApplicationName() { + new LoggingSystemProperties(new MockEnvironment().withProperty("spring.application.name", "test")).apply(null); + assertThat(getSystemProperty(LoggingSystemProperty.APPLICATION_NAME)).isEqualTo("[test] "); + } + + @Test + void loggedApplicationNameWhenHasNoApplicationName() { + new LoggingSystemProperties(new MockEnvironment()).apply(null); + assertThat(getSystemProperty(LoggingSystemProperty.APPLICATION_NAME)).isNull(); + } + + @Test + void loggedApplicationNameWhenApplicationNameLoggingDisabled() { + new LoggingSystemProperties(new MockEnvironment().withProperty("spring.application.name", "test") + .withProperty("logging.include-application-name", "false")).apply(null); + assertThat(getSystemProperty(LoggingSystemProperty.APPLICATION_NAME)).isNull(); } private Environment environment(String key, Object value) { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/java/JavaLoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/java/JavaLoggingSystemTests.java index 54ee6ab325c8..31802e0eab6d 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/java/JavaLoggingSystemTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/java/JavaLoggingSystemTests.java @@ -33,7 +33,7 @@ import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggerConfiguration; import org.springframework.boot.logging.LoggingSystem; -import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; import org.springframework.boot.testsupport.system.CapturedOutput; import org.springframework.boot.testsupport.system.OutputCaptureExtension; import org.springframework.util.ClassUtils; @@ -113,7 +113,7 @@ void testCustomFormatter(CapturedOutput output) { @Test void testSystemPropertyInitializesFormat(CapturedOutput output) { - System.setProperty(LoggingSystemProperties.PID_KEY, "1234"); + System.setProperty(LoggingSystemProperty.PID.getEnvironmentVariableName(), "1234"); this.loggingSystem.beforeInitialize(); this.loggingSystem.initialize(null, "classpath:" + ClassUtils.addResourcePathToPackagePath(getClass(), "logging.properties"), null); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/CorrelationIdConverterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/CorrelationIdConverterTests.java new file mode 100644 index 000000000000..87d9b22cf3f5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/CorrelationIdConverterTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-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.boot.logging.log4j2; + +import java.util.Map; + +import org.apache.logging.log4j.core.AbstractLogEvent; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.impl.JdkMapAdapterStringMap; +import org.apache.logging.log4j.util.ReadOnlyStringMap; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CorrelationIdConverter}. + * + * @author Phillip Webb + */ +class CorrelationIdConverterTests { + + private CorrelationIdConverter converter = CorrelationIdConverter.newInstance(null); + + private final LogEvent event = new TestLogEvent(); + + @Test + void defaultPattern() { + StringBuilder result = new StringBuilder(); + this.converter.format(this.event, result); + assertThat(result).hasToString("[01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void customPattern() { + this.converter = CorrelationIdConverter.newInstance(new String[] { "traceId(0),spanId(0)" }); + StringBuilder result = new StringBuilder(); + this.converter.format(this.event, result); + assertThat(result).hasToString("[01234567890123456789012345678901-0123456789012345] "); + } + + static class TestLogEvent extends AbstractLogEvent { + + @Override + public ReadOnlyStringMap getContextData() { + return new JdkMapAdapterStringMap( + Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystemTests.java index 7228940e2297..75564c7662e7 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystemTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4J2LoggingSystemTests.java @@ -39,6 +39,7 @@ import org.apache.logging.log4j.core.config.LoggerConfig; import org.apache.logging.log4j.core.config.Reconfigurable; import org.apache.logging.log4j.core.config.composite.CompositeConfiguration; +import org.apache.logging.log4j.core.config.plugins.util.PluginRegistry; import org.apache.logging.log4j.core.util.ShutdownCallbackRegistry; import org.apache.logging.log4j.jul.Log4jBridgeHandler; import org.apache.logging.log4j.util.PropertiesUtil; @@ -47,13 +48,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.MDC; import org.springframework.boot.logging.AbstractLoggingSystemTests; +import org.springframework.boot.logging.LogFile; import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggerConfiguration; import org.springframework.boot.logging.LoggingInitializationContext; import org.springframework.boot.logging.LoggingSystem; import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import org.springframework.boot.testsupport.logging.ConfigureClasspathToPreferLog4j2; import org.springframework.boot.testsupport.system.CapturedOutput; @@ -86,12 +90,11 @@ @ConfigureClasspathToPreferLog4j2 class Log4J2LoggingSystemTests extends AbstractLoggingSystemTests { - private final TestLog4J2LoggingSystem loggingSystem = new TestLog4J2LoggingSystem(); + private TestLog4J2LoggingSystem loggingSystem; - private final MockEnvironment environment = new MockEnvironment(); + private MockEnvironment environment; - private final LoggingInitializationContext initializationContext = new LoggingInitializationContext( - this.environment); + private LoggingInitializationContext initializationContext; private Logger logger; @@ -99,6 +102,10 @@ class Log4J2LoggingSystemTests extends AbstractLoggingSystemTests { @BeforeEach void setup() { + PluginRegistry.getInstance().clear(); + this.loggingSystem = new TestLog4J2LoggingSystem(); + this.environment = new MockEnvironment(); + this.initializationContext = new LoggingInitializationContext(this.environment); LoggerContext loggerContext = (LoggerContext) LogManager.getContext(false); this.configuration = loggerContext.getConfiguration(); this.loggingSystem.cleanUp(); @@ -113,6 +120,7 @@ void cleanUp() { loggerContext.stop(); loggerContext.start(((Reconfigurable) this.configuration).reconfigure()); cleanUpPropertySources(); + PluginRegistry.getInstance().clear(); } @SuppressWarnings("unchecked") @@ -361,7 +369,7 @@ void beforeInitializeFilterDisablesErrorLogging() { @Test void customExceptionConversionWord(CapturedOutput output) { - System.setProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD, "%ex"); + System.setProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName(), "%ex"); try { this.loggingSystem.beforeInitialize(); this.logger.info("Hidden"); @@ -373,7 +381,7 @@ void customExceptionConversionWord(CapturedOutput output) { assertThat(output).contains("java.lang.RuntimeException: Expected").doesNotContain("Wrapped by:"); } finally { - System.clearProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD); + System.clearProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName()); } } @@ -495,6 +503,99 @@ void nonFileUrlsAreResolvedUsingLog4J2UrlConnectionFactory() { .withMessageContaining("http has not been enabled"); } + @Test + void correlationLoggingToFileWhenExpectCorrelationIdTrueAndMdcContent() { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true"); + new LoggingSystemProperties(this.environment).apply(); + File file = new File(tmpDir(), "log4j2-test.log"); + LogFile logFile = getLogFile(file.getPath(), null); + this.loggingSystem.setStandardConfigLocations(false); + this.loggingSystem.beforeInitialize(); + this.loggingSystem.initialize(this.initializationContext, null, logFile); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(file, "Hello world")) + .contains(" [01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void correlationLoggingToConsoleWhenExpectCorrelationIdTrueAndMdcContent(CapturedOutput output) { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true"); + this.loggingSystem.setStandardConfigLocations(false); + this.loggingSystem.beforeInitialize(); + this.loggingSystem.initialize(this.initializationContext, null, null); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")) + .contains(" [01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void correlationLoggingToConsoleWhenExpectCorrelationIdFalseAndMdcContent(CapturedOutput output) { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "false"); + this.loggingSystem.setStandardConfigLocations(false); + this.loggingSystem.beforeInitialize(); + this.loggingSystem.initialize(this.initializationContext, null, null); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")).doesNotContain("0123456789012345"); + } + + @Test + void correlationLoggingToConsoleWhenExpectCorrelationIdTrueAndNoMdcContent(CapturedOutput output) { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true"); + this.loggingSystem.setStandardConfigLocations(false); + this.loggingSystem.beforeInitialize(); + this.loggingSystem.initialize(this.initializationContext, null, null); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")) + .contains(" [ ] "); + } + + @Test + void correlationLoggingToConsoleWhenHasCorrelationPattern(CapturedOutput output) { + this.environment.setProperty("logging.pattern.correlation", "%correlationId{spanId(0),traceId(0)}"); + this.loggingSystem.setStandardConfigLocations(false); + this.loggingSystem.beforeInitialize(); + this.loggingSystem.initialize(this.initializationContext, null, null); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")) + .contains(" [0123456789012345-01234567890123456789012345678901] "); + } + + @Test + void applicationNameLoggingWhenHasApplicationName(CapturedOutput output) { + this.environment.setProperty("spring.application.name", "myapp"); + this.loggingSystem.setStandardConfigLocations(false); + this.loggingSystem.beforeInitialize(); + this.loggingSystem.initialize(this.initializationContext, null, null); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")).contains("[myapp] "); + } + + @Test + void applicationNamePlaceHolderNotShowingWhenDisabled(CapturedOutput output) { + this.environment.setProperty("spring.application.name", "application-name"); + this.environment.setProperty("logging.include-application-name", "false"); + this.loggingSystem.setStandardConfigLocations(false); + this.loggingSystem.beforeInitialize(); + this.loggingSystem.initialize(this.initializationContext, null, null); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")).doesNotContain("${sys:LOGGED_APPLICATION_NAME}"); + } + + @Test + void applicationNameLoggingWhenDisabled(CapturedOutput output) { + this.environment.setProperty("spring.application.name", "myapp"); + this.environment.setProperty("logging.include-application-name", "false"); + this.loggingSystem.setStandardConfigLocations(false); + this.loggingSystem.beforeInitialize(); + this.loggingSystem.initialize(this.initializationContext, null, null); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")).doesNotContain("myapp"); + } + private String getRelativeClasspathLocation(String fileName) { String defaultPath = ClassUtils.getPackageName(getClass()); defaultPath = defaultPath.replace('.', '/'); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2FileXmlTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2FileXmlTests.java index e5ea6eb56bdd..53517e76dc29 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2FileXmlTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2FileXmlTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -23,7 +23,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; import static org.assertj.core.api.Assertions.assertThat; @@ -42,7 +42,9 @@ class Log4j2FileXmlTests extends Log4j2XmlTests { @AfterEach void stopConfiguration() { super.stopConfiguration(); - System.clearProperty(LoggingSystemProperties.LOG_FILE); + for (LoggingSystemProperty property : LoggingSystemProperty.values()) { + System.getProperties().remove(property.getEnvironmentVariableName()); + } } @Test @@ -52,7 +54,7 @@ void whenLogExceptionConversionWordIsNotConfiguredThenFileAppenderUsesDefault() @Test void whenLogExceptionConversionWordIsSetThenFileAppenderUsesIt() { - withSystemProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD, "custom", + withSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName(), "custom", () -> assertThat(fileAppenderPattern()).contains("custom")); } @@ -63,7 +65,7 @@ void whenLogLevelPatternIsNotConfiguredThenFileAppenderUsesDefault() { @Test void whenLogLevelPatternIsSetThenFileAppenderUsesIt() { - withSystemProperty(LoggingSystemProperties.LOG_LEVEL_PATTERN, "custom", + withSystemProperty(LoggingSystemProperty.LEVEL_PATTERN.getEnvironmentVariableName(), "custom", () -> assertThat(fileAppenderPattern()).contains("custom")); } @@ -74,7 +76,7 @@ void whenLogLDateformatPatternIsNotConfiguredThenFileAppenderUsesDefault() { @Test void whenLogDateformatPatternIsSetThenFileAppenderUsesIt() { - withSystemProperty(LoggingSystemProperties.LOG_DATEFORMAT_PATTERN, "dd-MM-yyyy", + withSystemProperty(LoggingSystemProperty.DATEFORMAT_PATTERN.getEnvironmentVariableName(), "dd-MM-yyyy", () -> assertThat(fileAppenderPattern()).contains("dd-MM-yyyy")); } @@ -85,7 +87,8 @@ protected String getConfigFileName() { @Override protected void prepareConfiguration() { - System.setProperty(LoggingSystemProperties.LOG_FILE, new File(this.temp, "test.log").getAbsolutePath()); + System.setProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName(), + new File(this.temp, "test.log").getAbsolutePath()); super.prepareConfiguration(); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2XmlTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2XmlTests.java index 850d7a4d6b45..86ecd1e293c7 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2XmlTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/Log4j2XmlTests.java @@ -27,7 +27,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; import static org.assertj.core.api.Assertions.assertThat; @@ -53,7 +53,7 @@ void whenLogExceptionConversionWordIsNotConfiguredThenConsoleUsesDefault() { @Test void whenLogExceptionConversionWordIsSetThenConsoleUsesIt() { - withSystemProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD, "custom", + withSystemProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName(), "custom", () -> assertThat(consolePattern()).contains("custom")); } @@ -64,7 +64,7 @@ void whenLogLevelPatternIsNotConfiguredThenConsoleUsesDefault() { @Test void whenLogLevelPatternIsSetThenConsoleUsesIt() { - withSystemProperty(LoggingSystemProperties.LOG_LEVEL_PATTERN, "custom", + withSystemProperty(LoggingSystemProperty.LEVEL_PATTERN.getEnvironmentVariableName(), "custom", () -> assertThat(consolePattern()).contains("custom")); } @@ -75,7 +75,7 @@ void whenLogLDateformatPatternIsNotConfiguredThenConsoleUsesDefault() { @Test void whenLogDateformatPatternIsSetThenConsoleUsesIt() { - withSystemProperty(LoggingSystemProperties.LOG_DATEFORMAT_PATTERN, "dd-MM-yyyy", + withSystemProperty(LoggingSystemProperty.DATEFORMAT_PATTERN.getEnvironmentVariableName(), "dd-MM-yyyy", () -> assertThat(consolePattern()).contains("dd-MM-yyyy")); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/TestLog4J2LoggingSystem.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/TestLog4J2LoggingSystem.java index cc945ae690a5..47f746592c03 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/TestLog4J2LoggingSystem.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/log4j2/TestLog4J2LoggingSystem.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -27,6 +27,8 @@ class TestLog4J2LoggingSystem extends Log4J2LoggingSystem { private final List availableClasses = new ArrayList<>(); + private String[] standardConfigLocations; + TestLog4J2LoggingSystem() { super(TestLog4J2LoggingSystem.class.getClassLoader()); } @@ -44,4 +46,18 @@ void availableClasses(String... classNames) { Collections.addAll(this.availableClasses, classNames); } + @Override + protected String[] getStandardConfigLocations() { + return (this.standardConfigLocations != null) ? this.standardConfigLocations + : super.getStandardConfigLocations(); + } + + void setStandardConfigLocations(boolean standardConfigLocations) { + this.standardConfigLocations = (!standardConfigLocations) ? new String[0] : null; + } + + void setStandardConfigLocations(String[] standardConfigLocations) { + this.standardConfigLocations = standardConfigLocations; + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/CorrelationIdConverterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/CorrelationIdConverterTests.java new file mode 100644 index 000000000000..061251739dc7 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/CorrelationIdConverterTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2012-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.boot.logging.logback; + +import java.util.List; +import java.util.Map; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.LoggingEvent; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CorrelationIdConverter}. + * + * @author Phillip Webb + */ +class CorrelationIdConverterTests { + + private final CorrelationIdConverter converter; + + private final LoggingEvent event = new LoggingEvent(); + + CorrelationIdConverterTests() { + this.converter = new CorrelationIdConverter(); + this.converter.setContext(new LoggerContext()); + } + + @Test + void defaultPattern() { + addMdcProperties(this.event); + this.converter.start(); + String converted = this.converter.convert(this.event); + this.converter.stop(); + assertThat(converted).isEqualTo("[01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void customPattern() { + this.converter.setOptionList(List.of("traceId(0)", "spanId(0)")); + addMdcProperties(this.event); + this.converter.start(); + String converted = this.converter.convert(this.event); + this.converter.stop(); + assertThat(converted).isEqualTo("[01234567890123456789012345678901-0123456789012345] "); + } + + private void addMdcProperties(LoggingEvent event) { + event.setMDCPropertyMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemParallelInitializationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemParallelInitializationTests.java index 2a1b69567b5f..ea9d69cdf8fe 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemParallelInitializationTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemParallelInitializationTests.java @@ -39,8 +39,7 @@ */ class LogbackLoggingSystemParallelInitializationTests { - private final LoggingSystem loggingSystem = LoggingSystem - .get(LogbackLoggingSystemParallelInitializationTests.class.getClassLoader()); + private final LoggingSystem loggingSystem = LoggingSystem.get(getClass().getClassLoader()); @AfterEach void cleanUp() { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemPropertiesTests.java index 2ab61b672f98..45c82c533de1 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemPropertiesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemPropertiesTests.java @@ -26,6 +26,7 @@ import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.mock.env.MockEnvironment; @@ -44,8 +45,9 @@ class LogbackLoggingSystemPropertiesTests { @BeforeEach void captureSystemPropertyNames() { - System.getProperties().remove(LoggingSystemProperties.CONSOLE_LOG_CHARSET); - System.getProperties().remove(LoggingSystemProperties.FILE_LOG_CHARSET); + for (LoggingSystemProperty property : LoggingSystemProperty.values()) { + System.getProperties().remove(property.getEnvironmentVariableName()); + } this.systemPropertyNames = new HashSet<>(System.getProperties().keySet()); this.environment = new MockEnvironment(); this.environment @@ -62,7 +64,8 @@ void restoreSystemProperties() { void applySetsStandardSystemProperties() { this.environment.setProperty("logging.pattern.console", "boot"); new LogbackLoggingSystemProperties(this.environment).apply(); - assertThat(System.getProperties()).containsEntry(LoggingSystemProperties.CONSOLE_LOG_PATTERN, "boot"); + assertThat(System.getProperties()) + .containsEntry(LoggingSystemProperty.CONSOLE_PATTERN.getEnvironmentVariableName(), "boot"); } @Test @@ -74,11 +77,11 @@ void applySetsLogbackSystemProperties() { this.environment.setProperty("logging.logback.rollingpolicy.max-history", "mh"); new LogbackLoggingSystemProperties(this.environment).apply(); assertThat(System.getProperties()) - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_FILE_NAME_PATTERN, "fnp") - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_CLEAN_HISTORY_ON_START, "chos") - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_MAX_FILE_SIZE, "1024") - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_TOTAL_SIZE_CAP, "2048") - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_MAX_HISTORY, "mh"); + .containsEntry(RollingPolicySystemProperty.FILE_NAME_PATTERN.getEnvironmentVariableName(), "fnp") + .containsEntry(RollingPolicySystemProperty.CLEAN_HISTORY_ON_START.getEnvironmentVariableName(), "chos") + .containsEntry(RollingPolicySystemProperty.MAX_FILE_SIZE.getEnvironmentVariableName(), "1024") + .containsEntry(RollingPolicySystemProperty.TOTAL_SIZE_CAP.getEnvironmentVariableName(), "2048") + .containsEntry(RollingPolicySystemProperty.MAX_HISTORY.getEnvironmentVariableName(), "mh"); } @Test @@ -90,24 +93,24 @@ void applySetsLogbackSystemPropertiesFromDeprecated() { this.environment.setProperty("logging.file.max-history", "mh"); new LogbackLoggingSystemProperties(this.environment).apply(); assertThat(System.getProperties()) - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_FILE_NAME_PATTERN, "fnp") - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_CLEAN_HISTORY_ON_START, "chos") - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_MAX_FILE_SIZE, "1024") - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_TOTAL_SIZE_CAP, "2048") - .containsEntry(LogbackLoggingSystemProperties.ROLLINGPOLICY_MAX_HISTORY, "mh"); + .containsEntry(RollingPolicySystemProperty.FILE_NAME_PATTERN.getEnvironmentVariableName(), "fnp") + .containsEntry(RollingPolicySystemProperty.CLEAN_HISTORY_ON_START.getEnvironmentVariableName(), "chos") + .containsEntry(RollingPolicySystemProperty.MAX_FILE_SIZE.getEnvironmentVariableName(), "1024") + .containsEntry(RollingPolicySystemProperty.TOTAL_SIZE_CAP.getEnvironmentVariableName(), "2048") + .containsEntry(RollingPolicySystemProperty.MAX_HISTORY.getEnvironmentVariableName(), "mh"); } @Test void consoleCharsetWhenNoPropertyUsesDefault() { new LoggingSystemProperties(new MockEnvironment()).apply(null); - assertThat(System.getProperty(LoggingSystemProperties.CONSOLE_LOG_CHARSET)) + assertThat(System.getProperty(LoggingSystemProperty.CONSOLE_CHARSET.getEnvironmentVariableName())) .isEqualTo(Charset.defaultCharset().name()); } @Test void fileCharsetWhenNoPropertyUsesDefault() { new LoggingSystemProperties(new MockEnvironment()).apply(null); - assertThat(System.getProperty(LoggingSystemProperties.FILE_LOG_CHARSET)) + assertThat(System.getProperty(LoggingSystemProperty.FILE_CHARSET.getEnvironmentVariableName())) .isEqualTo(Charset.defaultCharset().name()); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java index 9e129fc1b5fb..845048e3473f 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/logging/logback/LogbackLoggingSystemTests.java @@ -47,6 +47,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.slf4j.ILoggerFactory; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.slf4j.bridge.SLF4JBridgeHandler; import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; @@ -58,6 +59,7 @@ import org.springframework.boot.logging.LoggingInitializationContext; import org.springframework.boot.logging.LoggingSystem; import org.springframework.boot.logging.LoggingSystemProperties; +import org.springframework.boot.logging.LoggingSystemProperty; import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import org.springframework.boot.testsupport.classpath.ClassPathOverrides; import org.springframework.boot.testsupport.system.CapturedOutput; @@ -90,6 +92,7 @@ * @author Robert Thornton * @author Eddú Meléndez * @author Scott Frederick + * @author Jonatan Ivanov * @author Moritz Halbritter */ @ExtendWith(OutputCaptureExtension.class) @@ -108,8 +111,9 @@ class LogbackLoggingSystemTests extends AbstractLoggingSystemTests { @BeforeEach void setup() { - System.getProperties().remove(LoggingSystemProperties.CONSOLE_LOG_CHARSET); - System.getProperties().remove(LoggingSystemProperties.FILE_LOG_CHARSET); + for (LoggingSystemProperty property : LoggingSystemProperty.values()) { + System.getProperties().remove(property.getEnvironmentVariableName()); + } this.systemPropertyNames = new HashSet<>(System.getProperties().keySet()); this.loggingSystem.cleanUp(); this.logger = ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(getClass()); @@ -509,7 +513,7 @@ void exceptionsIncludeClassPackaging(CapturedOutput output) { @Test void customExceptionConversionWord(CapturedOutput output) { - System.setProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD, "%ex"); + System.setProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName(), "%ex"); try { this.loggingSystem.beforeInitialize(); this.logger.info("Hidden"); @@ -520,7 +524,7 @@ void customExceptionConversionWord(CapturedOutput output) { assertThat(output).contains("java.lang.RuntimeException: Expected").doesNotContain("Wrapped by:"); } finally { - System.clearProperty(LoggingSystemProperties.EXCEPTION_CONVERSION_WORD); + System.clearProperty(LoggingSystemProperty.EXCEPTION_CONVERSION_WORD.getEnvironmentVariableName()); } } @@ -531,7 +535,8 @@ void initializeShouldSetSystemProperty() { this.logger.info("Hidden"); LogFile logFile = getLogFile(tmpDir() + "/example.log", null, false); initialize(this.initializationContext, "classpath:logback-nondefault.xml", logFile); - assertThat(System.getProperty(LoggingSystemProperties.LOG_FILE)).endsWith("example.log"); + assertThat(System.getProperty(LoggingSystemProperty.LOG_FILE.getEnvironmentVariableName())) + .endsWith("example.log"); } @Test @@ -550,6 +555,7 @@ void initializeShouldApplyLogbackSystemPropertiesToTheContext() { (field) -> expectedProperties.add((String) field.get(null)), this::isPublicStaticFinal); expectedProperties.removeAll(Arrays.asList("LOG_FILE", "LOG_PATH")); expectedProperties.add("org.jboss.logging.provider"); + expectedProperties.add("LOG_CORRELATION_PATTERN"); assertThat(properties).containsOnlyKeys(expectedProperties); assertThat(properties).containsEntry("CONSOLE_LOG_CHARSET", Charset.defaultCharset().name()); } @@ -682,6 +688,95 @@ void springProfileIfNestedWithinSecondPhaseElementSanityChecker(CapturedOutput o assertThat(output).contains(" elements cannot be nested within an"); } + @Test + void correlationLoggingToFileWhenExpectCorrelationIdTrueAndMdcContent() { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true"); + File file = new File(tmpDir(), "logback-test.log"); + LogFile logFile = getLogFile(file.getPath(), null); + initialize(this.initializationContext, null, logFile); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(file, "Hello world")) + .contains(" [01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void correlationLoggingToConsoleWhenExpectCorrelationIdTrueAndMdcContent(CapturedOutput output) { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true"); + initialize(this.initializationContext, null, null); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")) + .contains(" [01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void correlationLoggingToConsoleWhenExpectCorrelationIdFalseAndMdcContent(CapturedOutput output) { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "false"); + initialize(this.initializationContext, null, null); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")).doesNotContain("0123456789012345"); + } + + @Test + void correlationLoggingToConsoleWhenExpectCorrelationIdTrueAndNoMdcContent(CapturedOutput output) { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true"); + initialize(this.initializationContext, null, null); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")) + .contains(" [ ] "); + } + + @Test + void correlationLoggingToConsoleWhenHasCorrelationPattern(CapturedOutput output) { + this.environment.setProperty("logging.pattern.correlation", "%correlationId{spanId(0),traceId(0)}"); + initialize(this.initializationContext, null, null); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")) + .contains(" [0123456789012345-01234567890123456789012345678901] "); + } + + @Test + void correlationLoggingToConsoleWhenUsingXmlConfiguration(CapturedOutput output) { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true"); + initialize(this.initializationContext, "classpath:logback-include-base.xml", null); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")) + .contains(" [01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void correlationLoggingToFileWhenUsingFileConfiguration() { + this.environment.setProperty(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY, "true"); + File file = new File(tmpDir(), "logback-test.log"); + LogFile logFile = getLogFile(file.getPath(), null); + initialize(this.initializationContext, "classpath:logback-include-base.xml", logFile); + MDC.setContextMap(Map.of("traceId", "01234567890123456789012345678901", "spanId", "0123456789012345")); + this.logger.info("Hello world"); + assertThat(getLineWithText(file, "Hello world")) + .contains(" [01234567890123456789012345678901-0123456789012345] "); + } + + @Test + void applicationNameLoggingWhenHasApplicationName(CapturedOutput output) { + this.environment.setProperty("spring.application.name", "myapp"); + initialize(this.initializationContext, null, null); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")).contains("[myapp] "); + } + + @Test + void applicationNameLoggingWhenDisabled(CapturedOutput output) { + this.environment.setProperty("spring.application.name", "myapp"); + this.environment.setProperty("logging.include-application-name", "false"); + initialize(this.initializationContext, null, null); + this.logger.info("Hello world"); + assertThat(getLineWithText(output, "Hello world")).doesNotContain("myapp"); + } + @Test void whenConfigurationErrorIsDetectedUnderlyingCausesAreIncludedAsSuppressedExceptions() { this.loggingSystem.beforeInitialize(); @@ -745,6 +840,7 @@ void applyingSystemPropertiesDoesNotCauseUnwantedStatusWarnings(CapturedOutput o private void initialize(LoggingInitializationContext context, String configLocation, LogFile logFile) { this.loggingSystem.getSystemProperties((ConfigurableEnvironment) context.getEnvironment()).apply(logFile); + this.loggingSystem.beforeInitialize(); this.loggingSystem.initialize(context, configLocation, logFile); } @@ -766,15 +862,4 @@ private static SizeAndTimeBasedRollingPolicy getRollingPolicy() { return (SizeAndTimeBasedRollingPolicy) getFileAppender().getRollingPolicy(); } - private String getLineWithText(File file, CharSequence outputSearch) { - return getLineWithText(contentOf(file), outputSearch); - } - - private String getLineWithText(CharSequence output, CharSequence outputSearch) { - return Arrays.stream(output.toString().split("\\r?\\n")) - .filter((line) -> line.contains(outputSearch)) - .findFirst() - .orElse(null); - } - } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilderTests.java index e9e5e3988da0..561bb0693ea6 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/r2dbc/ConnectionFactoryBuilderTests.java @@ -26,7 +26,9 @@ import io.r2dbc.pool.ConnectionPool; import io.r2dbc.pool.ConnectionPoolConfiguration; import io.r2dbc.pool.PoolingConnectionFactoryProvider; +import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; import io.r2dbc.spi.ConnectionFactoryOptions; import io.r2dbc.spi.Option; import io.r2dbc.spi.ValidationDepth; @@ -34,6 +36,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.reactivestreams.Publisher; import org.springframework.boot.r2dbc.ConnectionFactoryBuilder.PoolingAwareOptionsCapableWrapper; import org.springframework.core.ResolvableType; @@ -50,6 +53,7 @@ * @author Mark Paluch * @author Tadaya Tsuyukubo * @author Stephane Nicoll + * @author Moritz Halbritter */ class ConnectionFactoryBuilderTests { @@ -235,6 +239,15 @@ void stringlyTypedOptionIsMappedWhenCreatingPoolConfiguration(Option option) { assertThat(configuration).extracting(expectedOption.property).isEqualTo(expectedOption.value); } + @Test + void shouldApplyDecorators() { + String url = "r2dbc:pool:h2:mem:///" + UUID.randomUUID(); + ConnectionFactory connectionFactory = ConnectionFactoryBuilder.withUrl(url) + .decorator((ignored) -> new MyConnectionFactory()) + .build(); + assertThat(connectionFactory).isInstanceOf(MyConnectionFactory.class); + } + private static Iterable primitivePoolingConnectionProviderOptions() { return extractPoolingConnectionProviderOptions((field) -> { ResolvableType type = ResolvableType.forField(field); @@ -320,4 +333,18 @@ static ExpectedOption get(Option option) { } + private static final class MyConnectionFactory implements ConnectionFactory { + + @Override + public Publisher create() { + return null; + } + + @Override + public ConnectionFactoryMetadata getMetadata() { + return null; + } + + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/InstrumentedFluxProvider.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/InstrumentedFluxProvider.java index 2ec6e0c03b02..eaf3abfa3401 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/InstrumentedFluxProvider.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/InstrumentedFluxProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -22,7 +22,7 @@ * Utility class that should be instrumented by the reactor debug agent. * * @author Brian Clozel - * @see DebugAgentEnvironmentPostProcessorTests + * @see ReactorEnvironmentPostProcessorTests */ class InstrumentedFluxProvider { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessorTests.java similarity index 58% rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessorTests.java rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessorTests.java index 87efbcc782f4..29efc1f0b27a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/DebugAgentEnvironmentPostProcessorTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessorTests.java @@ -16,28 +16,31 @@ package org.springframework.boot.reactor; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import reactor.core.Scannable; import reactor.core.publisher.Flux; -import org.springframework.boot.testsupport.classpath.ClassPathOverrides; import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link DebugAgentEnvironmentPostProcessor}. + * Tests for {@link ReactorEnvironmentPostProcessor}. * * @author Brian Clozel */ -@Disabled("We need the not-yet-released reactor-tools 3.4.11 for JDK 17 compatibility") -@ClassPathOverrides("io.projectreactor:reactor-tools:3.4.11") -class DebugAgentEnvironmentPostProcessorTests { + +@Disabled("Tests rely on static initialization and are flaky on CI") +class ReactorEnvironmentPostProcessorTests { static { MockEnvironment environment = new MockEnvironment(); - DebugAgentEnvironmentPostProcessor postProcessor = new DebugAgentEnvironmentPostProcessor(); + environment.setProperty("spring.threads.virtual.enabled", "true"); + ReactorEnvironmentPostProcessor postProcessor = new ReactorEnvironmentPostProcessor(); postProcessor.postProcessEnvironment(environment, null); } @@ -49,4 +52,21 @@ void enablesReactorDebugAgent() { .startsWith("Flux.just ⇢ at org.springframework.boot.reactor.InstrumentedFluxProvider.newFluxJust"); } + @Test + @EnabledForJreRange(max = JRE.JAVA_20) + void shouldNotEnableVirtualThreads() { + assertThat(System.getProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads")).isNotEqualTo("true"); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void shouldEnableVirtualThreads() { + assertThat(System.getProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads")).isEqualTo("true"); + } + + @AfterEach + void cleanup() { + System.setProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads", "false"); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java index 4d206e26fee1..0c1f8196b200 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java @@ -56,7 +56,7 @@ import org.springframework.core.codec.CharSequenceEncoder; import org.springframework.core.codec.StringDecoder; import org.springframework.core.io.buffer.NettyDataBufferFactory; -import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.messaging.rsocket.RSocketRequester; import org.springframework.messaging.rsocket.RSocketStrategies; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java index d8cf034eef5f..110e4c79fbf3 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java @@ -16,7 +16,16 @@ package org.springframework.boot.ssl; +import java.util.concurrent.atomic.AtomicReference; + +import org.awaitility.Awaitility; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -28,14 +37,21 @@ * Tests for {@link DefaultSslBundleRegistry}. * * @author Phillip Webb + * @author Moritz Halbritter */ +@ExtendWith(OutputCaptureExtension.class) class DefaultSslBundleRegistryTests { - private SslBundle bundle1 = mock(SslBundle.class); + private final SslBundle bundle1 = mock(SslBundle.class); - private SslBundle bundle2 = mock(SslBundle.class); + private final SslBundle bundle2 = mock(SslBundle.class); - private DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry(); + private DefaultSslBundleRegistry registry; + + @BeforeEach + void setUp() { + this.registry = new DefaultSslBundleRegistry(); + } @Test void createWithNameAndBundleRegistersBundle() { @@ -89,4 +105,29 @@ void getBundleReturnsBundle() { assertThat(this.registry.getBundle("test2")).isSameAs(this.bundle2); } + @Test + void updateBundleShouldNotifyUpdateHandlers() { + AtomicReference updatedBundle = new AtomicReference<>(); + this.registry.registerBundle("test1", this.bundle1); + this.registry.addBundleUpdateHandler("test1", updatedBundle::set); + this.registry.updateBundle("test1", this.bundle2); + Awaitility.await().untilAtomic(updatedBundle, Matchers.equalTo(this.bundle2)); + } + + @Test + void shouldFailIfUpdatingNonRegisteredBundle() { + assertThatExceptionOfType(NoSuchSslBundleException.class) + .isThrownBy(() -> this.registry.updateBundle("dummy", this.bundle1)) + .withMessageContaining("'dummy'"); + } + + @Test + void shouldLogIfUpdatingBundleWithoutListeners(CapturedOutput output) { + this.registry.registerBundle("test1", this.bundle1); + this.registry.getBundle("test1"); + this.registry.updateBundle("test1", this.bundle2); + assertThat(output).contains( + "SSL bundle 'test1' has been updated but may be in use by a technology that doesn't support SSL reloading"); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/LoadedPemSslStoreTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/LoadedPemSslStoreTests.java new file mode 100644 index 000000000000..b7bccce838a5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/LoadedPemSslStoreTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-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.boot.ssl.pem; + +import java.io.UncheckedIOException; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link LoadedPemSslStore}. + * + * @author Phillip Webb + */ +class LoadedPemSslStoreTests { + + @Test + void certificatesAreLoadedLazily() { + PemSslStoreDetails details = PemSslStoreDetails.forCertificate("classpath:missing-test-cert.pem") + .withPrivateKey("classpath:test-key.pem"); + LoadedPemSslStore store = new LoadedPemSslStore(details); + assertThatExceptionOfType(UncheckedIOException.class).isThrownBy(store::certificates); + } + + @Test + void privateKeyIsLoadedLazily() { + PemSslStoreDetails details = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:missing-test-key.pem"); + LoadedPemSslStore store = new LoadedPemSslStore(details); + assertThatExceptionOfType(UncheckedIOException.class).isThrownBy(store::privateKey); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java index 20ceee1b9a4d..db8f71f6b744 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.cert.X509Certificate; +import java.util.List; import org.junit.jupiter.api.Test; @@ -35,19 +36,19 @@ class PemCertificateParserTests { @Test void parseCertificate() throws Exception { - X509Certificate[] certificates = PemCertificateParser.parse(read("test-cert.pem")); + List certificates = PemCertificateParser.parse(read("test-cert.pem")); assertThat(certificates).isNotNull(); assertThat(certificates).hasSize(1); - assertThat(certificates[0].getType()).isEqualTo("X.509"); + assertThat(certificates.get(0).getType()).isEqualTo("X.509"); } @Test void parseCertificateChain() throws Exception { - X509Certificate[] certificates = PemCertificateParser.parse(read("test-cert-chain.pem")); + List certificates = PemCertificateParser.parse(read("test-cert-chain.pem")); assertThat(certificates).isNotNull(); assertThat(certificates).hasSize(2); - assertThat(certificates[0].getType()).isEqualTo("X.509"); - assertThat(certificates[1].getType()).isEqualTo("X.509"); + assertThat(certificates.get(0).getType()).isEqualTo("X.509"); + assertThat(certificates.get(1).getType()).isEqualTo("X.509"); } private String read(String path) throws IOException { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java index 649d66f699b2..fac38bc5fd50 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java @@ -18,12 +18,17 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link PemContent}. @@ -33,12 +38,61 @@ class PemContentTests { @Test - void loadWhenContentIsNullReturnsNull() { - assertThat(PemContent.load(null)).isNull(); + void getCertificateWhenNoCertificatesThrowsException() { + PemContent content = PemContent.of(""); + assertThatIllegalStateException().isThrownBy(content::getCertificates) + .withMessage("Missing certificates or unrecognized format"); } @Test - void loadWhenContentIsPemContentReturnsContent() { + void getCertificateReturnsCertificates() throws Exception { + PemContent content = PemContent.load(contentFromClasspath("/test-cert-chain.pem")); + List certificates = content.getCertificates(); + assertThat(certificates).isNotNull(); + assertThat(certificates).hasSize(2); + assertThat(certificates.get(0).getType()).isEqualTo("X.509"); + assertThat(certificates.get(1).getType()).isEqualTo("X.509"); + } + + @Test + void getPrivateKeyWhenNoKeyThrowsException() { + PemContent content = PemContent.of(""); + assertThatIllegalStateException().isThrownBy(content::getPrivateKey) + .withMessage("Missing private key or unrecognized format"); + } + + @Test + void getPrivateKeyReturnsPrivateKey() throws Exception { + PemContent content = PemContent + .load(contentFromClasspath("/org/springframework/boot/web/server/pkcs8/dsa.key")); + PrivateKey privateKey = content.getPrivateKey(); + assertThat(privateKey).isNotNull(); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + assertThat(privateKey.getAlgorithm()).isEqualTo("DSA"); + } + + @Test + void equalsAndHashCode() { + PemContent c1 = PemContent.of("aaa"); + PemContent c2 = PemContent.of("aaa"); + PemContent c3 = PemContent.of("bbb"); + assertThat(c1.hashCode()).isEqualTo(c2.hashCode()); + assertThat(c1).isEqualTo(c1).isEqualTo(c2).isNotEqualTo(c3); + } + + @Test + void toStringReturnsString() { + PemContent content = PemContent.of("test"); + assertThat(content).hasToString("test"); + } + + @Test + void loadWithStringWhenContentIsNullReturnsNull() throws Exception { + assertThat(PemContent.load((String) null)).isNull(); + } + + @Test + void loadWithStringWhenContentIsPemContentReturnsContent() throws Exception { String content = """ -----BEGIN CERTIFICATE----- MIICpDCCAYwCCQCDOqHKPjAhCTANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDDAls @@ -57,21 +111,43 @@ void loadWhenContentIsPemContentReturnsContent() { +lGuHKdhNOVW9CmqPD1y76o6c8PQKuF7KZEoY2jvy3GeIfddBvqXgZ4PbWvFz1jO 32C9XWHwRA4= -----END CERTIFICATE-----"""; - assertThat(PemContent.load(content)).isEqualTo(content); + assertThat(PemContent.load(content)).hasToString(content); + } + + @Test + void loadWithStringWhenClasspathLocationReturnsContent() throws IOException { + String actual = PemContent.load("classpath:test-cert.pem").toString(); + String expected = contentFromClasspath("test-cert.pem"); + assertThat(actual).isEqualTo(expected); } @Test - void loadWhenClasspathLocationReturnsContent() throws IOException { - String actual = PemContent.load("classpath:test-cert.pem"); - String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); + void loadWithStringWhenFileLocationReturnsContent() throws IOException { + String actual = PemContent.load("src/test/resources/test-cert.pem").toString(); + String expected = contentFromClasspath("test-cert.pem"); assertThat(actual).isEqualTo(expected); } @Test - void loadWhenFileLocationReturnsContent() throws IOException { - String actual = PemContent.load("src/test/resources/test-cert.pem"); - String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); + void loadWithPathReturnsContent() throws IOException { + Path path = Path.of("src/test/resources/test-cert.pem"); + String actual = PemContent.load(path).toString(); + String expected = contentFromClasspath("test-cert.pem"); assertThat(actual).isEqualTo(expected); } + @Test + void ofWhenNullReturnsNull() { + assertThat(PemContent.of(null)).isNull(); + } + + @Test + void ofReturnsContent() { + assertThat(PemContent.of("test")).hasToString("test"); + } + + private static String contentFromClasspath(String path) throws IOException { + return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java index 3f5796103ab2..22ceb5455b43 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java @@ -77,10 +77,7 @@ void shouldParseTraditionalPkcs1(String file, String algorithm) throws IOExcepti void shouldNotParseUnsupportedTraditionalPkcs1(String file) { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs1/" + file))) - .withMessageContaining("Error loading private key file") - .withCauseInstanceOf(IllegalStateException.class) - .havingCause() - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } @ParameterizedTest @@ -120,10 +117,7 @@ void shouldParseEcPkcs8(String file, String curveName, String oid) throws IOExce void shouldNotParseUnsupportedEcPkcs8(String file) { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs8/" + file))) - .withMessageContaining("Error loading private key file") - .withCauseInstanceOf(IllegalStateException.class) - .havingCause() - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } @ParameterizedTest @@ -191,10 +185,7 @@ void shouldParseEcSec1(String file, String curveName, String oid) throws IOExcep void shouldNotParseUnsupportedEcSec1(String file) { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/sec1/" + file))) - .withMessageContaining("Error loading private key file") - .withCauseInstanceOf(IllegalStateException.class) - .havingCause() - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } @Test @@ -255,17 +246,17 @@ void shouldNotParseEncryptedSec1() { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser .parse(read("org/springframework/boot/web/server/sec1/prime256v1-aes-128-cbc.key"), "test")) - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } @Test - void shouldNotParseEncryptedPkcs1() throws Exception { + void shouldNotParseEncryptedPkcs1() { // created with: // openssl genrsa -aes-256-cbc -out rsa-aes-256-cbc.key assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser .parse(read("org/springframework/boot/web/server/pkcs1/rsa-aes-256-cbc.key"), "test")) - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } private String read(String path) throws IOException { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java index 61f5c4983bd0..b34901931062 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java @@ -17,6 +17,9 @@ package org.springframework.boot.ssl.pem; import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; import java.util.function.Consumer; import org.junit.jupiter.api.Test; @@ -30,11 +33,70 @@ * * @author Scott Frederick * @author Phillip Webb + * @author Moritz Halbritter */ class PemSslStoreBundleTests { + private static final String CERTIFICATE = """ + -----BEGIN CERTIFICATE----- + MIIDqzCCApOgAwIBAgIIFMqbpqvipw0wDQYJKoZIhvcNAQELBQAwbDELMAkGA1UE + BhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCVBhbG8gQWx0bzEP + MA0GA1UEChMGVk13YXJlMQ8wDQYDVQQLEwZTcHJpbmcxEjAQBgNVBAMTCWxvY2Fs + aG9zdDAgFw0yMzA1MDUxMTI2NThaGA8yMTIzMDQxMTExMjY1OFowbDELMAkGA1UE + BhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCVBhbG8gQWx0bzEP + MA0GA1UEChMGVk13YXJlMQ8wDQYDVQQLEwZTcHJpbmcxEjAQBgNVBAMTCWxvY2Fs + aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPwHWxoE3xjRmNdD + +m+e/aFlr5wEGQUdWSDD613OB1w7kqO/audEp3c6HxDB3GPcEL0amJwXgY6CQMYu + sythuZX/EZSc2HdilTBu/5T+mbdWe5JkKThpiA0RYeucQfKuB7zv4ypioa4wiR4D + nPsZXjg95OF8pCzYEssv8wT49v+M3ohWUgfF0FPlMFCSo0YVTuzB1mhDlWKq/jhQ + 11WpTmk/dQX+l6ts6bYIcJt4uItG+a68a4FutuSjZdTAE0f5SOYRBpGH96mjLwEP + fW8ZjzvKb9g4R2kiuoPxvCDs1Y/8V2yvKqLyn5Tx9x/DjFmOi0DRK/TgELvNceCb + UDJmhXMCAwEAAaNPME0wHQYDVR0OBBYEFMBIGU1nwix5RS3O5hGLLoMdR1+NMCwG + A1UdEQQlMCOCCWxvY2FsaG9zdIcQAAAAAAAAAAAAAAAAAAAAAYcEfwAAATANBgkq + hkiG9w0BAQsFAAOCAQEAhepfJgTFvqSccsT97XdAZfvB0noQx5NSynRV8NWmeOld + hHP6Fzj6xCxHSYvlUfmX8fVP9EOAuChgcbbuTIVJBu60rnDT21oOOnp8FvNonCV6 + gJ89sCL7wZ77dw2RKIeUFjXXEV3QJhx2wCOVmLxnJspDoKFIEVjfLyiPXKxqe/6b + dG8zzWDZ6z+M2JNCtVoOGpljpHqMPCmbDktncv6H3dDTZ83bmLj1nbpOU587gAJ8 + fl1PiUDyPRIl2cnOJd+wCHKsyym/FL7yzk0OSEZ81I92LpGd/0b2Ld3m/bpe+C4Z + ILzLXTnC6AhrLcDc9QN/EO+BiCL52n7EplNLtSn1LQ== + -----END CERTIFICATE----- + """.strip(); + + private static final String PRIVATE_KEY = """ + -----BEGIN PRIVATE KEY----- + MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD8B1saBN8Y0ZjX + Q/pvnv2hZa+cBBkFHVkgw+tdzgdcO5Kjv2rnRKd3Oh8Qwdxj3BC9GpicF4GOgkDG + LrMrYbmV/xGUnNh3YpUwbv+U/pm3VnuSZCk4aYgNEWHrnEHyrge87+MqYqGuMIke + A5z7GV44PeThfKQs2BLLL/ME+Pb/jN6IVlIHxdBT5TBQkqNGFU7swdZoQ5Viqv44 + UNdVqU5pP3UF/perbOm2CHCbeLiLRvmuvGuBbrbko2XUwBNH+UjmEQaRh/epoy8B + D31vGY87ym/YOEdpIrqD8bwg7NWP/Fdsryqi8p+U8fcfw4xZjotA0Sv04BC7zXHg + m1AyZoVzAgMBAAECggEAfEqiZqANaF+BqXQIb4Dw42ZTJzWsIyYYnPySOGZRoe5t + QJ03uwtULYv34xtANe1DQgd6SMyc46ugBzzjtprQ3ET5Jhn99U6kdcjf+dpf85dO + hOEppP0CkDNI39nleinSfh6uIOqYgt/D143/nqQhn8oCdSOzkbwT9KnWh1bC9T7I + vFjGfElvt1/xl88qYgrWgYLgXaencNGgiv/4/M0FNhiHEGsVC7SCu6kapC/WIQpE + 5IdV+HR+tiLoGZhXlhqorY7QC4xKC4wwafVSiFxqDOQAuK+SMD4TCEv0Aop+c+SE + YBigVTmgVeJkjK7IkTEhKkAEFmRF5/5w+bZD9FhTNQKBgQD+4fNG1ChSU8RdizZT + 5dPlDyAxpETSCEXFFVGtPPh2j93HDWn7XugNyjn5FylTH507QlabC+5wZqltdIjK + GRB5MIinQ9/nR2fuwGc9s+0BiSEwNOUB1MWm7wWL/JUIiKq6sTi6sJIfsYg79zco + qxl5WE94aoINx9Utq1cdWhwJTQKBgQD9IjPksd4Jprz8zMrGLzR8k1gqHyhv24qY + EJ7jiHKKAP6xllTUYwh1IBSL6w2j5lfZPpIkb4Jlk2KUoX6fN81pWkBC/fTBUSIB + EHM9bL51+yKEYUbGIy/gANuRbHXsWg3sjUsFTNPN4hGTFk3w2xChCyl/f5us8Lo8 + Z633SNdpvwKBgQCGyDU9XzNzVZihXtx7wS0sE7OSjKtX5cf/UCbA1V0OVUWR3SYO + J0HPCQFfF0BjFHSwwYPKuaR9C8zMdLNhK5/qdh/NU7czNi9fsZ7moh7SkRFbzJzN + OxbKD9t/CzJEMQEXeF/nWTfsSpUgILqqZtAxuuFLbAcaAnJYlCKdAumQgQKBgQCK + mqjJh68pn7gJwGUjoYNe1xtGbSsqHI9F9ovZ0MPO1v6e5M7sQJHH+Fnnxzv/y8e8 + d6tz8e73iX1IHymDKv35uuZHCGF1XOR+qrA/KQUc+vcKf21OXsP/JtkTRs1HLoRD + S5aRf2DWcfvniyYARSNU2xTM8GWgi2ueWbMDHUp+ZwKBgA/swC+K+Jg5DEWm6Sau + e6y+eC6S+SoXEKkI3wf7m9aKoZo0y+jh8Gas6gratlc181pSM8O3vZG0n19b493I + apCFomMLE56zEzvyzfpsNhFhk5MBMCn0LPyzX6MiynRlGyWIj0c99fbHI3pOMufP + WgmVLTZ8uDcSW1MbdUCwFSk5 + -----END PRIVATE KEY----- + """.strip(); + + private static final char[] EMPTY_KEY_PASSWORD = new char[] {}; + @Test - void whenNullStores() { + void createWithDetailsWhenNullStores() { PemSslStoreDetails keyStoreDetails = null; PemSslStoreDetails trustStoreDetails = null; PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); @@ -44,7 +106,7 @@ void whenNullStores() { } @Test - void whenStoresHaveNoValues() { + void createWithDetailsWhenStoresHaveNoValues() { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(null); PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(null); PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); @@ -54,7 +116,7 @@ void whenStoresHaveNoValues() { } @Test - void whenHasKeyStoreDetailsCertAndKey() { + void createWithDetailsWhenHasKeyStoreDetailsCertAndKey() { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") .withPrivateKey("classpath:test-key.pem"); PemSslStoreDetails trustStoreDetails = null; @@ -64,7 +126,7 @@ void whenHasKeyStoreDetailsCertAndKey() { } @Test - void whenHasKeyStoreDetailsCertAndEncryptedKey() { + void createWithDetailsWhenHasKeyStoreDetailsCertAndEncryptedKey() { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") .withPrivateKey("classpath:ssl/pkcs8/key-rsa-encrypted.pem") .withPrivateKeyPassword("test"); @@ -75,17 +137,17 @@ void whenHasKeyStoreDetailsCertAndEncryptedKey() { } @Test - void whenHasKeyStoreDetailsAndTrustStoreDetailsWithoutKey() { + void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsWithoutKey() { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") .withPrivateKey("classpath:test-key.pem"); PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem"); PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl")); - assertThat(bundle.getTrustStore()).satisfies(storeContainingCert("ssl-0")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCert("ssl")); } @Test - void whenHasKeyStoreDetailsAndTrustStoreDetails() { + void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetails() { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") .withPrivateKey("classpath:test-key.pem"); PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") @@ -96,7 +158,18 @@ void whenHasKeyStoreDetailsAndTrustStoreDetails() { } @Test - void whenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() { + void createWithDetailsWhenHasEmbeddedKeyStoreDetailsAndTrustStoreDetails() { + PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE).withPrivateKey(PRIVATE_KEY); + PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE) + .withPrivateKey(PRIVATE_KEY); + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("ssl")); + } + + @Test + @SuppressWarnings("removal") + void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") .withPrivateKey("classpath:test-key.pem"); PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") @@ -107,7 +180,7 @@ void whenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() { } @Test - void whenHasStoreType() { + void createWithDetailsWhenHasStoreType() { PemSslStoreDetails keyStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem", "classpath:test-key.pem"); PemSslStoreDetails trustStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem", @@ -117,6 +190,31 @@ void whenHasStoreType() { assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("PKCS12", "ssl")); } + @Test + void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsAndKeyPassword() { + PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:test-key.pem") + .withAlias("ksa") + .withPassword("kss"); + PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:test-key.pem") + .withAlias("tsa") + .withPassword("tss"); + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ksa", "kss".toCharArray())); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("tsa", "tss".toCharArray())); + } + + @Test + void createWithPemSslStoreCreatesInstance() { + List certificates = PemContent.of(CERTIFICATE).getCertificates(); + PrivateKey privateKey = PemContent.of(PRIVATE_KEY).getPrivateKey(); + PemSslStore pemSslStore = PemSslStore.of(certificates, privateKey); + PemSslStoreBundle bundle = new PemSslStoreBundle(pemSslStore, pemSslStore); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("ssl")); + } + private Consumer storeContainingCert(String keyAlias) { return storeContainingCert(KeyStore.getDefaultType(), keyAlias); } @@ -127,7 +225,7 @@ private Consumer storeContainingCert(String keyStoreType, String keyAl assertThat(keyStore.getType()).isEqualTo(keyStoreType); assertThat(keyStore.containsAlias(keyAlias)).isTrue(); assertThat(keyStore.getCertificate(keyAlias)).isNotNull(); - assertThat(keyStore.getKey(keyAlias, new char[] {})).isNull(); + assertThat(keyStore.getKey(keyAlias, EMPTY_KEY_PASSWORD)).isNull(); }); } @@ -136,12 +234,20 @@ private Consumer storeContainingCertAndKey(String keyAlias) { } private Consumer storeContainingCertAndKey(String keyStoreType, String keyAlias) { + return storeContainingCertAndKey(keyStoreType, keyAlias, EMPTY_KEY_PASSWORD); + } + + private Consumer storeContainingCertAndKey(String keyAlias, char[] keyPassword) { + return storeContainingCertAndKey(KeyStore.getDefaultType(), keyAlias, keyPassword); + } + + private Consumer storeContainingCertAndKey(String keyStoreType, String keyAlias, char[] keyPassword) { return ThrowingConsumer.of((keyStore) -> { assertThat(keyStore).isNotNull(); assertThat(keyStore.getType()).isEqualTo(keyStoreType); assertThat(keyStore.containsAlias(keyAlias)).isTrue(); assertThat(keyStore.getCertificate(keyAlias)).isNotNull(); - assertThat(keyStore.getKey(keyAlias, new char[] {})).isNotNull(); + assertThat(keyStore.getKey(keyAlias, keyPassword)).isNotNull(); }); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreTests.java new file mode 100644 index 000000000000..9e4ca402b14d --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-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.boot.ssl.pem; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +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 PemSslStore}. + * + * @author Phillip Webb + */ +class PemSslStoreTests { + + @Test + void withAliasReturnsStoreWithNewAlias() { + List certificates = List.of(mock(X509Certificate.class)); + PrivateKey privateKey = mock(PrivateKey.class); + PemSslStore store = PemSslStore.of("type", "alias", "secret", certificates, privateKey); + assertThat(store.withAlias("newalias").alias()).isEqualTo("newalias"); + } + + @Test + void withPasswordReturnsStoreWithNewPassword() { + List certificates = List.of(mock(X509Certificate.class)); + PrivateKey privateKey = mock(PrivateKey.class); + PemSslStore store = PemSslStore.of("type", "alias", "secret", certificates, privateKey); + assertThat(store.withPassword("newsecret").password()).isEqualTo("newsecret"); + } + + @Test + void ofWhenNullCertificatesThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> PemSslStore.of(null, null, null, null, null)) + .withMessage("Certificates must not be empty"); + } + + @Test + void ofWhenEmptyCertificatesThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> PemSslStore.of(null, null, null, Collections.emptyList(), null)) + .withMessage("Certificates must not be empty"); + } + + @Test + void ofReturnsPemSslStore() { + List certificates = List.of(mock(X509Certificate.class)); + PrivateKey privateKey = mock(PrivateKey.class); + PemSslStore store = PemSslStore.of("type", "alias", "password", certificates, privateKey); + assertThat(store.type()).isEqualTo("type"); + assertThat(store.alias()).isEqualTo("alias"); + assertThat(store.password()).isEqualTo("password"); + assertThat(store.certificates()).isEqualTo(certificates); + assertThat(store.privateKey()).isEqualTo(privateKey); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilderTests.java new file mode 100644 index 000000000000..8ddf5feaf261 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskExecutorBuilderTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2012-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.boot.task; + +import java.time.Duration; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +import org.springframework.boot.testsupport.assertj.SimpleAsyncTaskExecutorAssert; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskDecorator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link SimpleAsyncTaskExecutorBuilder}. + * + * @author Stephane Nicoll + * @author Filip Hrisafov + * @author Moritz Halbritter + */ +class SimpleAsyncTaskExecutorBuilderTests { + + private final SimpleAsyncTaskExecutorBuilder builder = new SimpleAsyncTaskExecutorBuilder(); + + @Test + void threadNamePrefixShouldApply() { + SimpleAsyncTaskExecutor executor = this.builder.threadNamePrefix("test-").build(); + assertThat(executor.getThreadNamePrefix()).isEqualTo("test-"); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void virtualThreadsShouldApply() { + SimpleAsyncTaskExecutor executor = this.builder.virtualThreads(true).build(); + SimpleAsyncTaskExecutorAssert.assertThat(executor).usesVirtualThreads(); + } + + @Test + void concurrencyLimitShouldApply() { + SimpleAsyncTaskExecutor executor = this.builder.concurrencyLimit(1).build(); + assertThat(executor.getConcurrencyLimit()).isEqualTo(1); + } + + @Test + void taskDecoratorShouldApply() { + TaskDecorator taskDecorator = mock(TaskDecorator.class); + SimpleAsyncTaskExecutor executor = this.builder.taskDecorator(taskDecorator).build(); + assertThat(executor).extracting("taskDecorator").isSameAs(taskDecorator); + } + + @Test + void customizersWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.customizers((SimpleAsyncTaskExecutorCustomizer[]) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void customizersCollectionWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.customizers((Set) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void customizersShouldApply() { + SimpleAsyncTaskExecutorCustomizer customizer = mock(SimpleAsyncTaskExecutorCustomizer.class); + SimpleAsyncTaskExecutor executor = this.builder.customizers(customizer).build(); + then(customizer).should().customize(executor); + } + + @Test + void customizersShouldBeAppliedLast() { + TaskDecorator taskDecorator = mock(TaskDecorator.class); + SimpleAsyncTaskExecutor executor = spy(new SimpleAsyncTaskExecutor()); + this.builder.threadNamePrefix("test-") + .virtualThreads(true) + .concurrencyLimit(1) + .taskDecorator(taskDecorator) + .additionalCustomizers((taskExecutor) -> { + then(taskExecutor).should().setConcurrencyLimit(1); + then(taskExecutor).should().setVirtualThreads(true); + then(taskExecutor).should().setThreadNamePrefix("test-"); + then(taskExecutor).should().setTaskDecorator(taskDecorator); + }); + this.builder.configure(executor); + } + + @Test + void customizersShouldReplaceExisting() { + SimpleAsyncTaskExecutorCustomizer customizer1 = mock(SimpleAsyncTaskExecutorCustomizer.class); + SimpleAsyncTaskExecutorCustomizer customizer2 = mock(SimpleAsyncTaskExecutorCustomizer.class); + SimpleAsyncTaskExecutor executor = this.builder.customizers(customizer1) + .customizers(Collections.singleton(customizer2)) + .build(); + then(customizer1).shouldHaveNoInteractions(); + then(customizer2).should().customize(executor); + } + + @Test + void additionalCustomizersWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.additionalCustomizers((SimpleAsyncTaskExecutorCustomizer[]) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void additionalCustomizersCollectionWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.additionalCustomizers((Set) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void additionalCustomizersShouldAddToExisting() { + SimpleAsyncTaskExecutorCustomizer customizer1 = mock(SimpleAsyncTaskExecutorCustomizer.class); + SimpleAsyncTaskExecutorCustomizer customizer2 = mock(SimpleAsyncTaskExecutorCustomizer.class); + SimpleAsyncTaskExecutor executor = this.builder.customizers(customizer1) + .additionalCustomizers(customizer2) + .build(); + then(customizer1).should().customize(executor); + then(customizer2).should().customize(executor); + } + + @Test + void taskTerminationTimeoutShouldApply() { + SimpleAsyncTaskExecutor executor = this.builder.taskTerminationTimeout(Duration.ofSeconds(1)).build(); + assertThat(executor).extracting("taskTerminationTimeout").isEqualTo(1000L); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilderTests.java new file mode 100644 index 000000000000..9cb06c5f3213 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/SimpleAsyncTaskSchedulerBuilderTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2012-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.boot.task; + +import java.time.Duration; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; + +import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link SimpleAsyncTaskSchedulerBuilder}. + * + * @author Stephane Nicoll + * @author Moritz Halbritter + */ +class SimpleAsyncTaskSchedulerBuilderTests { + + private final SimpleAsyncTaskSchedulerBuilder builder = new SimpleAsyncTaskSchedulerBuilder(); + + @Test + void threadNamePrefixShouldApply() { + SimpleAsyncTaskScheduler scheduler = this.builder.threadNamePrefix("test-").build(); + assertThat(scheduler.getThreadNamePrefix()).isEqualTo("test-"); + } + + @Test + void concurrencyLimitShouldApply() { + SimpleAsyncTaskScheduler scheduler = this.builder.concurrencyLimit(1).build(); + assertThat(scheduler.getConcurrencyLimit()).isEqualTo(1); + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void virtualThreadsShouldApply() { + SimpleAsyncTaskScheduler scheduler = this.builder.virtualThreads(true).build(); + assertThat(scheduler).extracting("virtualThreadDelegate").isNotNull(); + } + + @Test + void customizersWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.customizers((SimpleAsyncTaskSchedulerCustomizer[]) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void customizersCollectionWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.customizers((Set) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void customizersShouldApply() { + SimpleAsyncTaskSchedulerCustomizer customizer = mock(SimpleAsyncTaskSchedulerCustomizer.class); + SimpleAsyncTaskScheduler scheduler = this.builder.customizers(customizer).build(); + then(customizer).should().customize(scheduler); + } + + @Test + void customizersShouldBeAppliedLast() { + SimpleAsyncTaskScheduler scheduler = spy(new SimpleAsyncTaskScheduler()); + this.builder.concurrencyLimit(1).threadNamePrefix("test-").additionalCustomizers((taskScheduler) -> { + then(taskScheduler).should().setConcurrencyLimit(1); + then(taskScheduler).should().setThreadNamePrefix("test-"); + }); + this.builder.configure(scheduler); + } + + @Test + void customizersShouldReplaceExisting() { + SimpleAsyncTaskSchedulerCustomizer customizer1 = mock(SimpleAsyncTaskSchedulerCustomizer.class); + SimpleAsyncTaskSchedulerCustomizer customizer2 = mock(SimpleAsyncTaskSchedulerCustomizer.class); + SimpleAsyncTaskScheduler scheduler = this.builder.customizers(customizer1) + .customizers(Collections.singleton(customizer2)) + .build(); + then(customizer1).shouldHaveNoInteractions(); + then(customizer2).should().customize(scheduler); + } + + @Test + void additionalCustomizersWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.additionalCustomizers((SimpleAsyncTaskSchedulerCustomizer[]) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void additionalCustomizersCollectionWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.additionalCustomizers((Set) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void additionalCustomizersShouldAddToExisting() { + SimpleAsyncTaskSchedulerCustomizer customizer1 = mock(SimpleAsyncTaskSchedulerCustomizer.class); + SimpleAsyncTaskSchedulerCustomizer customizer2 = mock(SimpleAsyncTaskSchedulerCustomizer.class); + SimpleAsyncTaskScheduler scheduler = this.builder.customizers(customizer1) + .additionalCustomizers(customizer2) + .build(); + then(customizer1).should().customize(scheduler); + then(customizer2).should().customize(scheduler); + } + + @Test + void taskTerminationTimeoutShouldApply() { + SimpleAsyncTaskScheduler scheduler = this.builder.taskTerminationTimeout(Duration.ofSeconds(1)).build(); + assertThat(scheduler).extracting("taskTerminationTimeout").isEqualTo(1000L); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskExecutorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskExecutorBuilderTests.java index 52c205e8ed60..7df760b63f82 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskExecutorBuilderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskExecutorBuilderTests.java @@ -37,6 +37,7 @@ * @author Stephane Nicoll * @author Filip Hrisafov */ +@SuppressWarnings("removal") class TaskExecutorBuilderTests { private final TaskExecutorBuilder builder = new TaskExecutorBuilder(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskSchedulerBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskSchedulerBuilderTests.java index e07573910c16..095e8fda4edb 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskSchedulerBuilderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/TaskSchedulerBuilderTests.java @@ -35,6 +35,7 @@ * * @author Stephane Nicoll */ +@SuppressWarnings("removal") class TaskSchedulerBuilderTests { private final TaskSchedulerBuilder builder = new TaskSchedulerBuilder(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilderTests.java new file mode 100644 index 000000000000..8b8dc650e680 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskExecutorBuilderTests.java @@ -0,0 +1,176 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.task; + +import java.time.Duration; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link ThreadPoolTaskExecutorBuilder}. + * + * @author Stephane Nicoll + * @author Filip Hrisafov + * @author Yanming Zhou + */ +class ThreadPoolTaskExecutorBuilderTests { + + private final ThreadPoolTaskExecutorBuilder builder = new ThreadPoolTaskExecutorBuilder(); + + @Test + void poolSettingsShouldApply() { + ThreadPoolTaskExecutor executor = this.builder.queueCapacity(10) + .corePoolSize(4) + .maxPoolSize(8) + .allowCoreThreadTimeOut(true) + .keepAlive(Duration.ofMinutes(1)) + .build(); + assertThat(executor).hasFieldOrPropertyWithValue("queueCapacity", 10); + assertThat(executor.getCorePoolSize()).isEqualTo(4); + assertThat(executor.getMaxPoolSize()).isEqualTo(8); + assertThat(executor).hasFieldOrPropertyWithValue("allowCoreThreadTimeOut", true); + assertThat(executor.getKeepAliveSeconds()).isEqualTo(60); + } + + @Test + void acceptTasksAfterContextCloseShouldApply() { + ThreadPoolTaskExecutor executor = this.builder.acceptTasksAfterContextClose(true).build(); + assertThat(executor).hasFieldOrPropertyWithValue("acceptTasksAfterContextClose", true); + } + + @Test + void awaitTerminationShouldApply() { + ThreadPoolTaskExecutor executor = this.builder.awaitTermination(true).build(); + assertThat(executor).hasFieldOrPropertyWithValue("waitForTasksToCompleteOnShutdown", true); + } + + @Test + void awaitTerminationPeriodShouldApplyWithMillisecondPrecision() { + Duration period = Duration.ofMillis(50); + ThreadPoolTaskExecutor executor = this.builder.awaitTerminationPeriod(period).build(); + assertThat(executor).hasFieldOrPropertyWithValue("awaitTerminationMillis", period.toMillis()); + } + + @Test + void threadNamePrefixShouldApply() { + ThreadPoolTaskExecutor executor = this.builder.threadNamePrefix("test-").build(); + assertThat(executor.getThreadNamePrefix()).isEqualTo("test-"); + } + + @Test + void taskDecoratorShouldApply() { + TaskDecorator taskDecorator = mock(TaskDecorator.class); + ThreadPoolTaskExecutor executor = this.builder.taskDecorator(taskDecorator).build(); + assertThat(executor).extracting("taskDecorator").isSameAs(taskDecorator); + } + + @Test + void customizersWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.customizers((ThreadPoolTaskExecutorCustomizer[]) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void customizersCollectionWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.customizers((Set) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void customizersShouldApply() { + ThreadPoolTaskExecutorCustomizer customizer = mock(ThreadPoolTaskExecutorCustomizer.class); + ThreadPoolTaskExecutor executor = this.builder.customizers(customizer).build(); + then(customizer).should().customize(executor); + } + + @Test + void customizersShouldBeAppliedLast() { + TaskDecorator taskDecorator = mock(TaskDecorator.class); + ThreadPoolTaskExecutor executor = spy(new ThreadPoolTaskExecutor()); + this.builder.queueCapacity(10) + .corePoolSize(4) + .maxPoolSize(8) + .allowCoreThreadTimeOut(true) + .keepAlive(Duration.ofMinutes(1)) + .awaitTermination(true) + .awaitTerminationPeriod(Duration.ofSeconds(30)) + .threadNamePrefix("test-") + .taskDecorator(taskDecorator) + .additionalCustomizers((taskExecutor) -> { + then(taskExecutor).should().setQueueCapacity(10); + then(taskExecutor).should().setCorePoolSize(4); + then(taskExecutor).should().setMaxPoolSize(8); + then(taskExecutor).should().setAllowCoreThreadTimeOut(true); + then(taskExecutor).should().setKeepAliveSeconds(60); + then(taskExecutor).should().setWaitForTasksToCompleteOnShutdown(true); + then(taskExecutor).should().setAwaitTerminationSeconds(30); + then(taskExecutor).should().setThreadNamePrefix("test-"); + then(taskExecutor).should().setTaskDecorator(taskDecorator); + }); + this.builder.configure(executor); + } + + @Test + void customizersShouldReplaceExisting() { + ThreadPoolTaskExecutorCustomizer customizer1 = mock(ThreadPoolTaskExecutorCustomizer.class); + ThreadPoolTaskExecutorCustomizer customizer2 = mock(ThreadPoolTaskExecutorCustomizer.class); + ThreadPoolTaskExecutor executor = this.builder.customizers(customizer1) + .customizers(Collections.singleton(customizer2)) + .build(); + then(customizer1).shouldHaveNoInteractions(); + then(customizer2).should().customize(executor); + } + + @Test + void additionalCustomizersWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.additionalCustomizers((ThreadPoolTaskExecutorCustomizer[]) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void additionalCustomizersCollectionWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.additionalCustomizers((Set) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void additionalCustomizersShouldAddToExisting() { + ThreadPoolTaskExecutorCustomizer customizer1 = mock(ThreadPoolTaskExecutorCustomizer.class); + ThreadPoolTaskExecutorCustomizer customizer2 = mock(ThreadPoolTaskExecutorCustomizer.class); + ThreadPoolTaskExecutor executor = this.builder.customizers(customizer1) + .additionalCustomizers(customizer2) + .build(); + then(customizer1).should().customize(executor); + then(customizer2).should().customize(executor); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilderTests.java new file mode 100644 index 000000000000..11b4f15f49af --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/task/ThreadPoolTaskSchedulerBuilderTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-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.boot.task; + +import java.time.Duration; +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link ThreadPoolTaskSchedulerBuilder}. + * + * @author Stephane Nicoll + */ +class ThreadPoolTaskSchedulerBuilderTests { + + private final ThreadPoolTaskSchedulerBuilder builder = new ThreadPoolTaskSchedulerBuilder(); + + @Test + void poolSettingsShouldApply() { + ThreadPoolTaskScheduler scheduler = this.builder.poolSize(4).build(); + assertThat(scheduler.getPoolSize()).isEqualTo(4); + } + + @Test + void awaitTerminationShouldApply() { + ThreadPoolTaskScheduler executor = this.builder.awaitTermination(true).build(); + assertThat(executor).hasFieldOrPropertyWithValue("waitForTasksToCompleteOnShutdown", true); + } + + @Test + void awaitTerminationPeriodShouldApply() { + Duration period = Duration.ofMinutes(1); + ThreadPoolTaskScheduler executor = this.builder.awaitTerminationPeriod(period).build(); + assertThat(executor).hasFieldOrPropertyWithValue("awaitTerminationMillis", period.toMillis()); + } + + @Test + void threadNamePrefixShouldApply() { + ThreadPoolTaskScheduler scheduler = this.builder.threadNamePrefix("test-").build(); + assertThat(scheduler.getThreadNamePrefix()).isEqualTo("test-"); + } + + @Test + void customizersWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.customizers((ThreadPoolTaskSchedulerCustomizer[]) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void customizersCollectionWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.customizers((Set) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void customizersShouldApply() { + ThreadPoolTaskSchedulerCustomizer customizer = mock(ThreadPoolTaskSchedulerCustomizer.class); + ThreadPoolTaskScheduler scheduler = this.builder.customizers(customizer).build(); + then(customizer).should().customize(scheduler); + } + + @Test + void customizersShouldBeAppliedLast() { + ThreadPoolTaskScheduler scheduler = spy(new ThreadPoolTaskScheduler()); + this.builder.poolSize(4).threadNamePrefix("test-").additionalCustomizers((taskScheduler) -> { + then(taskScheduler).should().setPoolSize(4); + then(taskScheduler).should().setThreadNamePrefix("test-"); + }); + this.builder.configure(scheduler); + } + + @Test + void customizersShouldReplaceExisting() { + ThreadPoolTaskSchedulerCustomizer customizer1 = mock(ThreadPoolTaskSchedulerCustomizer.class); + ThreadPoolTaskSchedulerCustomizer customizer2 = mock(ThreadPoolTaskSchedulerCustomizer.class); + ThreadPoolTaskScheduler scheduler = this.builder.customizers(customizer1) + .customizers(Collections.singleton(customizer2)) + .build(); + then(customizer1).shouldHaveNoInteractions(); + then(customizer2).should().customize(scheduler); + } + + @Test + void additionalCustomizersWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.additionalCustomizers((ThreadPoolTaskSchedulerCustomizer[]) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void additionalCustomizersCollectionWhenCustomizersAreNullShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.builder.additionalCustomizers((Set) null)) + .withMessageContaining("Customizers must not be null"); + } + + @Test + void additionalCustomizersShouldAddToExisting() { + ThreadPoolTaskSchedulerCustomizer customizer1 = mock(ThreadPoolTaskSchedulerCustomizer.class); + ThreadPoolTaskSchedulerCustomizer customizer2 = mock(ThreadPoolTaskSchedulerCustomizer.class); + ThreadPoolTaskScheduler scheduler = this.builder.customizers(customizer1) + .additionalCustomizers(customizer2) + .build(); + then(customizer1).should().customize(scheduler); + then(customizer2).should().customize(scheduler); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesHttpComponentsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesHttpComponentsTests.java index 98b9095afec7..b8df330e7790 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesHttpComponentsTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesHttpComponentsTests.java @@ -39,7 +39,7 @@ class ClientHttpRequestFactoriesHttpComponentsTests @Override protected long connectTimeout(HttpComponentsClientHttpRequestFactory requestFactory) { - return (int) ReflectionTestUtils.getField(requestFactory, "connectTimeout"); + return (long) ReflectionTestUtils.getField(requestFactory, "connectTimeout"); } @Override diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesJettyTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesJettyTests.java new file mode 100644 index 000000000000..27bc1800477c --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesJettyTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-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.boot.web.client; + +import org.eclipse.jetty.client.HttpClient; + +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; +import org.springframework.http.client.JettyClientHttpRequestFactory; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Tests for {@link ClientHttpRequestFactories} when Jetty is the predominant HTTP client. + * + * @author Arjen Poutsma + */ +@ClassPathExclusions({ "httpclient5-*.jar", "okhttp-*.jar" }) +class ClientHttpRequestFactoriesJettyTests + extends AbstractClientHttpRequestFactoriesTests { + + ClientHttpRequestFactoriesJettyTests() { + super(JettyClientHttpRequestFactory.class); + } + + @Override + protected long connectTimeout(JettyClientHttpRequestFactory requestFactory) { + return ((HttpClient) ReflectionTestUtils.getField(requestFactory, "httpClient")).getConnectTimeout(); + } + + @Override + protected long readTimeout(JettyClientHttpRequestFactory requestFactory) { + return (long) ReflectionTestUtils.getField(requestFactory, "readTimeout"); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp3Tests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp3Tests.java index 59e4c3144474..ad414d38f298 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp3Tests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp3Tests.java @@ -27,7 +27,6 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link ClientHttpRequestFactories} when OkHttp 3 is the predominant HTTP @@ -36,7 +35,9 @@ * @author Andy Wilkinson */ @ClassPathOverrides("com.squareup.okhttp3:okhttp:3.14.9") -@ClassPathExclusions("httpclient5-*.jar") +@ClassPathExclusions({ "httpclient5-*.jar", "jetty-client-*.jar" }) +@Deprecated(since = "3.2.0") +@SuppressWarnings("removal") class ClientHttpRequestFactoriesOkHttp3Tests extends AbstractClientHttpRequestFactoriesTests { @@ -50,12 +51,6 @@ void okHttp3IsBeingUsed() { .startsWith("okhttp-3."); } - @Test - void getFailsWhenBufferRequestBodyIsEnabled() { - assertThatIllegalStateException().isThrownBy(() -> ClientHttpRequestFactories - .get(ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(true))); - } - @Override protected long connectTimeout(OkHttp3ClientHttpRequestFactory requestFactory) { return ((OkHttpClient) ReflectionTestUtils.getField(requestFactory, "client")).connectTimeoutMillis(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp4Tests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp4Tests.java index 13158708f54d..f6241c7a413e 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp4Tests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesOkHttp4Tests.java @@ -26,7 +26,6 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link ClientHttpRequestFactories} when OkHttp 4 is the predominant HTTP @@ -34,7 +33,9 @@ * * @author Andy Wilkinson */ -@ClassPathExclusions("httpclient5-*.jar") +@ClassPathExclusions({ "httpclient5-*.jar", "jetty-client-*.jar" }) +@Deprecated(since = "3.2.0") +@SuppressWarnings("removal") class ClientHttpRequestFactoriesOkHttp4Tests extends AbstractClientHttpRequestFactoriesTests { @@ -48,12 +49,6 @@ void okHttp4IsBeingUsed() { .startsWith("okhttp-4."); } - @Test - void getFailsWhenBufferRequestBodyIsEnabled() { - assertThatIllegalStateException().isThrownBy(() -> ClientHttpRequestFactories - .get(ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(true))); - } - @Override protected long connectTimeout(OkHttp3ClientHttpRequestFactory requestFactory) { return ((OkHttpClient) ReflectionTestUtils.getField(requestFactory, "client")).connectTimeoutMillis(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHintsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHintsTests.java index bb143d3297bb..1fc41a8e29e3 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHintsTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesRuntimeHintsTests.java @@ -26,6 +26,7 @@ import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; import org.springframework.http.client.AbstractClientHttpRequestFactoryWrapper; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.util.ReflectionUtils; @@ -59,15 +60,11 @@ void shouldRegisterHttpComponentHints() { assertThat(reflection .onMethod(method(HttpComponentsClientHttpRequestFactory.class, "setConnectTimeout", int.class))) .accepts(hints); - assertThat( - reflection.onMethod(method(HttpComponentsClientHttpRequestFactory.class, "setReadTimeout", int.class))) - .accepts(hints); - assertThat(reflection - .onMethod(method(HttpComponentsClientHttpRequestFactory.class, "setBufferRequestBody", boolean.class))) - .accepts(hints); } @Test + @Deprecated(since = "3.2.0") + @SuppressWarnings("removal") void shouldRegisterOkHttpHints() { RuntimeHints hints = new RuntimeHints(); new ClientHttpRequestFactoriesRuntimeHints().registerHints(hints, getClass().getClassLoader()); @@ -79,6 +76,17 @@ void shouldRegisterOkHttpHints() { assertThat(hints.reflection().getTypeHint(OkHttp3ClientHttpRequestFactory.class).methods()).hasSize(2); } + @Test + void shouldRegisterJettyClientHints() { + RuntimeHints hints = new RuntimeHints(); + new ClientHttpRequestFactoriesRuntimeHints().registerHints(hints, getClass().getClassLoader()); + ReflectionHintsPredicates reflection = RuntimeHintsPredicates.reflection(); + assertThat(reflection.onMethod(method(JettyClientHttpRequestFactory.class, "setConnectTimeout", int.class))) + .accepts(hints); + assertThat(reflection.onMethod(method(JettyClientHttpRequestFactory.class, "setReadTimeout", long.class))) + .accepts(hints); + } + @Test void shouldRegisterSimpleHttpHints() { RuntimeHints hints = new RuntimeHints(); @@ -88,9 +96,6 @@ void shouldRegisterSimpleHttpHints() { .accepts(hints); assertThat(reflection.onMethod(method(SimpleClientHttpRequestFactory.class, "setReadTimeout", int.class))) .accepts(hints); - assertThat(reflection - .onMethod(method(SimpleClientHttpRequestFactory.class, "setBufferRequestBody", boolean.class))) - .accepts(hints); } private static Method method(Class target, String name, Class... parameterTypes) { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesSimpleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesSimpleTests.java index a9e75aa6496b..bb4425484511 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesSimpleTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesSimpleTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-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. @@ -26,7 +26,7 @@ * * @author Andy Wilkinson */ -@ClassPathExclusions({ "httpclient5-*.jar", "okhttp-*.jar" }) +@ClassPathExclusions({ "httpclient5-*.jar", "jetty-client-*.jar", "okhttp-*.jar" }) class ClientHttpRequestFactoriesSimpleTests extends AbstractClientHttpRequestFactoriesTests { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesTests.java index 34d591efb33d..ff27a20d03a5 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactoriesTests.java @@ -27,6 +27,7 @@ import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; @@ -69,12 +70,21 @@ void getOfHttpComponentsFactoryReturnsHttpComponentsFactory() { } @Test + @Deprecated(since = "3.2.0") + @SuppressWarnings("removal") void getOfOkHttpFactoryReturnsOkHttpFactory() { ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(OkHttp3ClientHttpRequestFactory.class, ClientHttpRequestFactorySettings.DEFAULTS); assertThat(requestFactory).isInstanceOf(OkHttp3ClientHttpRequestFactory.class); } + @Test + void getOfJdkFactoryReturnsJdkFactory() { + ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(JdkClientHttpRequestFactory.class, + ClientHttpRequestFactorySettings.DEFAULTS); + assertThat(requestFactory).isInstanceOf(JdkClientHttpRequestFactory.class); + } + @Test void getOfUnknownTypeCreatesFactory() { ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(TestClientHttpRequestFactory.class, @@ -100,14 +110,6 @@ void getOfUnknownTypeWithReadTimeoutCreatesFactoryAndConfiguresReadTimeout() { .isEqualTo(Duration.ofSeconds(90).toMillis()); } - @Test - void getOfUnknownTypeWithBodyBufferingCreatesFactoryAndConfiguresBodyBuffering() { - ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(TestClientHttpRequestFactory.class, - ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(true)); - assertThat(requestFactory).isInstanceOf(TestClientHttpRequestFactory.class); - assertThat(((TestClientHttpRequestFactory) requestFactory).bufferRequestBody).isTrue(); - } - @Test void getOfUnconfigurableTypeWithConnectTimeoutThrows() { assertThatIllegalStateException() @@ -124,14 +126,6 @@ void getOfUnconfigurableTypeWithReadTimeoutThrows() { .withMessageContaining("suitable setReadTimeout method"); } - @Test - void getOfUnconfigurableTypeWithBodyBufferingThrows() { - assertThatIllegalStateException() - .isThrownBy(() -> ClientHttpRequestFactories.get(UnconfigurableClientHttpRequestFactory.class, - ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(true))) - .withMessageContaining("suitable setBufferRequestBody method"); - } - @Test void getOfTypeWithDeprecatedConnectTimeoutThrowsWithConnectTimeout() { assertThatIllegalStateException() @@ -148,14 +142,6 @@ void getOfTypeWithDeprecatedReadTimeoutThrowsWithReadTimeout() { .withMessageContaining("setReadTimeout method marked as deprecated"); } - @Test - void getOfTypeWithDeprecatedBufferRequestBodyThrowsWithBufferRequestBody() { - assertThatIllegalStateException() - .isThrownBy(() -> ClientHttpRequestFactories.get(DeprecatedMethodsClientHttpRequestFactory.class, - ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(false))) - .withMessageContaining("setBufferRequestBody method marked as deprecated"); - } - @Test void connectTimeoutCanBeConfiguredOnAWrappedRequestFactory() { SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); @@ -176,25 +162,12 @@ void readTimeoutCanBeConfiguredOnAWrappedRequestFactory() { assertThat(requestFactory).hasFieldOrPropertyWithValue("readTimeout", 1234); } - @Test - void bufferRequestBodyCanBeConfiguredOnAWrappedRequestFactory() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - assertThat(requestFactory).hasFieldOrPropertyWithValue("bufferRequestBody", true); - BufferingClientHttpRequestFactory result = ClientHttpRequestFactories.get( - () -> new BufferingClientHttpRequestFactory(requestFactory), - ClientHttpRequestFactorySettings.DEFAULTS.withBufferRequestBody(false)); - assertThat(result).extracting("requestFactory").isSameAs(requestFactory); - assertThat(requestFactory).hasFieldOrPropertyWithValue("bufferRequestBody", false); - } - public static class TestClientHttpRequestFactory implements ClientHttpRequestFactory { private int connectTimeout; private int readTimeout; - private boolean bufferRequestBody; - @Override public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException { throw new UnsupportedOperationException(); @@ -208,10 +181,6 @@ public void setReadTimeout(int timeout) { this.readTimeout = timeout; } - public void setBufferRequestBody(boolean bufferRequestBody) { - this.bufferRequestBody = bufferRequestBody; - } - } public static class UnconfigurableClientHttpRequestFactory implements ClientHttpRequestFactory { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettingsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettingsTests.java index 8103a3ae6973..c9145b7dd266 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettingsTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/client/ClientHttpRequestFactorySettingsTests.java @@ -39,7 +39,6 @@ void defaultsHasNullValues() { ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS; assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isNull(); - assertThat(settings.bufferRequestBody()).isNull(); assertThat(settings.sslBundle()).isNull(); } @@ -49,7 +48,6 @@ void withConnectTimeoutReturnsInstanceWithUpdatedConnectionTimeout() { .withConnectTimeout(ONE_SECOND); assertThat(settings.connectTimeout()).isEqualTo(ONE_SECOND); assertThat(settings.readTimeout()).isNull(); - assertThat(settings.bufferRequestBody()).isNull(); assertThat(settings.sslBundle()).isNull(); } @@ -59,17 +57,6 @@ void withReadTimeoutReturnsInstanceWithUpdatedReadTimeout() { .withReadTimeout(ONE_SECOND); assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isEqualTo(ONE_SECOND); - assertThat(settings.bufferRequestBody()).isNull(); - assertThat(settings.sslBundle()).isNull(); - } - - @Test - void withBufferRequestBodyReturnsInstanceWithUpdatedBufferRequestBody() { - ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS - .withBufferRequestBody(true); - assertThat(settings.connectTimeout()).isNull(); - assertThat(settings.readTimeout()).isNull(); - assertThat(settings.bufferRequestBody()).isTrue(); assertThat(settings.sslBundle()).isNull(); } @@ -79,7 +66,6 @@ void withSslBundleReturnsInstanceWithUpdatedSslBundle() { ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS.withSslBundle(sslBundle); assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isNull(); - assertThat(settings.bufferRequestBody()).isNull(); assertThat(settings.sslBundle()).isSameAs(sslBundle); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java index 09ecc2c3cef7..336ed38ab011 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactoryTests.java @@ -22,6 +22,7 @@ import java.util.Arrays; import org.awaitility.Awaitility; +import org.eclipse.jetty.server.ConnectionLimit; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; @@ -29,10 +30,9 @@ import org.junit.jupiter.api.Test; import org.mockito.InOrder; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; +import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests; import org.springframework.boot.web.server.Shutdown; -import org.springframework.http.client.reactive.JettyResourceFactory; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.web.reactive.function.client.WebClient; @@ -47,8 +47,8 @@ * * @author Brian Clozel * @author Madhura Bhave + * @author Moritz Halbritter */ -@Servlet5ClassPathOverrides class JettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactoryTests { @Override @@ -58,7 +58,8 @@ protected JettyReactiveWebServerFactory getFactory() { @Test @Override - @Disabled("Jetty 11 does not support User-Agent-based compression") + @Disabled("Jetty 12 does not support User-Agent-based compression") + // TODO Is this true with Jetty 12? protected void noCompressionForUserAgent() { } @@ -111,20 +112,6 @@ void useForwardedHeaders() { assertForwardHeaderIsUsed(factory); } - @Test - void useServerResources() throws Exception { - JettyResourceFactory resourceFactory = new JettyResourceFactory(); - resourceFactory.afterPropertiesSet(); - JettyReactiveWebServerFactory factory = getFactory(); - factory.setResourceFactory(resourceFactory); - JettyWebServer webServer = (JettyWebServer) factory.getWebServer(new EchoHandler()); - webServer.start(); - Connector connector = webServer.getServer().getConnectors()[0]; - assertThat(connector.getByteBufferPool()).isEqualTo(resourceFactory.getByteBufferPool()); - assertThat(connector.getExecutor()).isEqualTo(resourceFactory.getExecutor()); - assertThat(connector.getScheduler()).isEqualTo(resourceFactory.getScheduler()); - } - @Test void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() { JettyReactiveWebServerFactory factory = getFactory(); @@ -148,4 +135,29 @@ void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() { this.webServer.stop(); } + @Test + void shouldApplyMaxConnections() { + JettyReactiveWebServerFactory factory = getFactory(); + factory.setMaxConnections(1); + this.webServer = factory.getWebServer(new EchoHandler()); + Server server = ((JettyWebServer) this.webServer).getServer(); + ConnectionLimit connectionLimit = server.getBean(ConnectionLimit.class); + assertThat(connectionLimit).isNotNull(); + assertThat(connectionLimit.getMaxConnections()).isOne(); + } + + @Override + protected String startedLogMessage() { + return ((JettyWebServer) this.webServer).getStartedLogMessage(); + } + + @Override + protected void addConnector(int port, AbstractReactiveWebServerFactory factory) { + ((JettyReactiveWebServerFactory) factory).addServerCustomizers((server) -> { + ServerConnector connector = new ServerConnector(server); + connector.setPort(port); + server.addConnector(connector); + }); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java index 3f5d33d8c1ba..acac3103c0b4 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactoryTests.java @@ -39,29 +39,29 @@ import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpResponse; import org.apache.jasper.servlet.JspServlet; +import org.assertj.core.api.InstanceOfAssertFactories; import org.awaitility.Awaitility; +import org.eclipse.jetty.ee10.servlet.ErrorPageErrorHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.ee10.webapp.AbstractConfiguration; +import org.eclipse.jetty.ee10.webapp.ClassMatcher; +import org.eclipse.jetty.ee10.webapp.Configuration; +import org.eclipse.jetty.ee10.webapp.WebAppContext; +import org.eclipse.jetty.server.AbstractConnector; +import org.eclipse.jetty.server.ConnectionLimit; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.SslConnectionFactory; -import org.eclipse.jetty.server.handler.HandlerCollection; -import org.eclipse.jetty.server.handler.HandlerWrapper; -import org.eclipse.jetty.servlet.ErrorPageErrorHandler; -import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.ThreadPool; -import org.eclipse.jetty.webapp.AbstractConfiguration; -import org.eclipse.jetty.webapp.ClassMatcher; -import org.eclipse.jetty.webapp.Configuration; -import org.eclipse.jetty.webapp.WebAppContext; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InOrder; import org.springframework.boot.testsupport.system.CapturedOutput; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.server.Compression; import org.springframework.boot.web.server.GracefulShutdownResult; import org.springframework.boot.web.server.PortInUseException; @@ -86,13 +86,23 @@ * @author Dave Syer * @author Andy Wilkinson * @author Henri Kerola + * @author Moritz Halbritter + * @author Onur Kagan Ozcan */ -@Servlet5ClassPathOverrides class JettyServletWebServerFactoryTests extends AbstractServletWebServerFactoryTests { @Override protected JettyServletWebServerFactory getFactory() { - return new JettyServletWebServerFactory(0); + JettyServletWebServerFactory factory = new JettyServletWebServerFactory(0); + factory.addServerCustomizers((server) -> { + for (Connector connector : server.getConnectors()) { + if (connector instanceof ServerConnector serverConnector) { + // TODO Set the shutdown idle timeout in main code? + serverConnector.setShutdownIdleTimeout(10000); + } + } + }); + return factory; } @Override @@ -142,10 +152,17 @@ protected void handleExceptionCausedByBlockedPortOnSecondaryConnector(RuntimeExc @Test @Override - @Disabled("Jetty 11 does not support User-Agent-based compression") + @Disabled("Jetty 12 does not support User-Agent-based compression") protected void noCompressionForUserAgent() { } + @Test + @Override + @Disabled("Jetty 12 does not support SSL session tracking") + protected void sslSessionTracking() { + + } + @Test void contextPathIsLoggedOnStartupWhenCompressionIsEnabled(CapturedOutput output) { AbstractServletWebServerFactory factory = getFactory(); @@ -232,10 +249,10 @@ void sslCiphersConfiguration() { } @Test - void stopCalledWithoutStart() { + void destroyCalledWithoutStart() { JettyServletWebServerFactory factory = getFactory(); this.webServer = factory.getWebServer(exampleServletRegistration()); - this.webServer.stop(); + this.webServer.destroy(); Server server = ((JettyWebServer) this.webServer).getServer(); assertThat(server.isStopped()).isTrue(); } @@ -383,11 +400,9 @@ void wrappedHandlers() throws Exception { JettyServletWebServerFactory factory = getFactory(); factory.setServerCustomizers(Collections.singletonList((server) -> { Handler handler = server.getHandler(); - HandlerWrapper wrapper = new HandlerWrapper(); + Handler.Wrapper wrapper = new Handler.Wrapper(); wrapper.setHandler(handler); - HandlerCollection collection = new HandlerCollection(); - collection.addHandler(wrapper); - server.setHandler(collection); + server.setHandler(wrapper); })); this.webServer = factory.getWebServer(exampleServletRegistration()); this.webServer.start(); @@ -505,7 +520,7 @@ public void contextDestroyed(ServletContextEvent event) { @Test void errorHandlerCanBeOverridden() { JettyServletWebServerFactory factory = getFactory(); - factory.addConfigurations(new AbstractConfiguration() { + factory.addConfigurations(new AbstractConfiguration(new AbstractConfiguration.Builder()) { @Override public void configure(WebAppContext context) throws Exception { @@ -518,6 +533,35 @@ public void configure(WebAppContext context) throws Exception { assertThat(context.getErrorHandler()).isInstanceOf(CustomErrorHandler.class); } + @Test + void shouldApplyMaxConnections() { + JettyServletWebServerFactory factory = getFactory(); + factory.setMaxConnections(1); + this.webServer = factory.getWebServer(); + Server server = ((JettyWebServer) this.webServer).getServer(); + ConnectionLimit connectionLimit = server.getBean(ConnectionLimit.class); + assertThat(connectionLimit).isNotNull(); + assertThat(connectionLimit.getMaxConnections()).isOne(); + } + + @Test + void shouldApplyingMaxConnectionUseConnector() throws Exception { + JettyServletWebServerFactory factory = getFactory(); + factory.setMaxConnections(1); + this.webServer = factory.getWebServer(); + Server server = ((JettyWebServer) this.webServer).getServer(); + assertThat(server.getConnectors()).isEmpty(); + ConnectionLimit connectionLimit = server.getBean(ConnectionLimit.class); + assertThat(connectionLimit).extracting("_connectors") + .asInstanceOf(InstanceOfAssertFactories.list(AbstractConnector.class)) + .hasSize(1); + } + + @Override + protected String startedLogMessage() { + return ((JettyWebServer) this.webServer).getStartedLogMessage(); + } + private WebAppContext findWebAppContext(JettyWebServer webServer) { return findWebAppContext(webServer.getServer().getHandler()); } @@ -526,7 +570,7 @@ private WebAppContext findWebAppContext(Handler handler) { if (handler instanceof WebAppContext webAppContext) { return webAppContext; } - if (handler instanceof HandlerWrapper wrapper) { + if (handler instanceof Handler.Wrapper wrapper) { return findWebAppContext(wrapper.getHandler()); } throw new IllegalStateException("No WebAppContext found"); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java index 61f123b74bc3..d67008187107 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactoryTests.java @@ -23,6 +23,7 @@ import io.netty.channel.Channel; import org.awaitility.Awaitility; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InOrder; import reactor.core.CoreSubscriber; @@ -33,12 +34,18 @@ import reactor.netty.http.server.HttpServer; import reactor.test.StepVerifier; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.pem.PemSslStoreBundle; +import org.springframework.boot.ssl.pem.PemSslStoreDetails; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests; import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.http.MediaType; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.web.reactive.function.BodyInserters; @@ -46,6 +53,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.inOrder; @@ -56,6 +64,7 @@ * * @author Brian Clozel * @author Chris Bono + * @author Moritz Halbritter */ class NettyReactiveWebServerFactoryTests extends AbstractReactiveWebServerFactoryTests { @@ -79,6 +88,23 @@ void getPortWhenDisposableServerPortOperationIsUnsupportedReturnsMinusOne() { assertThat(this.webServer.getPort()).isEqualTo(-1); } + @Test + void resourceFactoryAndWebServerLifecycle() { + NettyReactiveWebServerFactory factory = getFactory(); + factory.setPort(0); + ReactorResourceFactory resourceFactory = new ReactorResourceFactory(); + factory.setResourceFactory(resourceFactory); + this.webServer = factory.getWebServer(new EchoHandler()); + assertThatNoException().isThrownBy(() -> { + resourceFactory.start(); + this.webServer.start(); + this.webServer.stop(); + resourceFactory.stop(); + resourceFactory.start(); + this.webServer.start(); + }); + } + private void portMatchesRequirement(PortInUseException exception) { assertThat(exception.getPort()).isEqualTo(this.webServer.getPort()); } @@ -112,6 +138,16 @@ void whenSslIsConfiguredWithAValidAliasARequestSucceeds() { StepVerifier.create(result).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30)); } + @Test + void whenSslBundleIsUpdatedThenSslIsReloaded() { + DefaultSslBundleRegistry bundles = new DefaultSslBundleRegistry("bundle1", createSslBundle("1.key", "1.crt")); + Mono result = testSslWithBundle(bundles, "bundle1"); + StepVerifier.create(result).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30)); + bundles.updateBundle("bundle1", createSslBundle("2.key", "2.crt")); + Mono result2 = executeSslRequest(); + StepVerifier.create(result2).expectNext("Hello World").expectComplete().verify(Duration.ofSeconds(30)); + } + @Test void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() { NettyReactiveWebServerFactory factory = getFactory(); @@ -135,7 +171,13 @@ void whenServerIsShuttingDownGracefullyThenNewConnectionsCannotBeMade() { this.webServer.stop(); } - protected Mono testSslWithAlias(String alias) { + @Override + @Test + @Disabled("Reactor Netty does not support mutiple ports") + protected void startedLogMessageWithMultiplePorts() { + } + + private Mono testSslWithAlias(String alias) { String keyStore = "classpath:test.jks"; String keyPassword = "password"; NettyReactiveWebServerFactory factory = getFactory(); @@ -146,6 +188,19 @@ protected Mono testSslWithAlias(String alias) { factory.setSsl(ssl); this.webServer = factory.getWebServer(new EchoHandler()); this.webServer.start(); + return executeSslRequest(); + } + + private Mono testSslWithBundle(SslBundles sslBundles, String bundle) { + NettyReactiveWebServerFactory factory = getFactory(); + factory.setSslBundles(sslBundles); + factory.setSsl(Ssl.forBundle(bundle)); + this.webServer = factory.getWebServer(new EchoHandler()); + this.webServer.start(); + return executeSslRequest(); + } + + private Mono executeSslRequest() { ReactorClientHttpConnector connector = buildTrustAllSslConnector(); WebClient client = WebClient.builder() .baseUrl("https://localhost:" + this.webServer.getPort()) @@ -164,6 +219,23 @@ protected NettyReactiveWebServerFactory getFactory() { return new NettyReactiveWebServerFactory(0); } + @Override + protected String startedLogMessage() { + return ((NettyWebServer) this.webServer).getStartedLogMessage(); + } + + @Override + protected void addConnector(int port, AbstractReactiveWebServerFactory factory) { + throw new UnsupportedOperationException("Reactor Netty does not support multiple ports"); + } + + private static SslBundle createSslBundle(String key, String certificate) { + return SslBundle.of(new PemSslStoreBundle( + new PemSslStoreDetails(null, "classpath:org/springframework/boot/web/embedded/netty/" + certificate, + "classpath:org/springframework/boot/web/embedded/netty/" + key), + null)); + } + static class NoPortNettyReactiveWebServerFactory extends NettyReactiveWebServerFactory { NoPortNettyReactiveWebServerFactory(int port) { @@ -182,7 +254,7 @@ static class NoPortNettyWebServer extends NettyWebServer { NoPortNettyWebServer(HttpServer httpServer, ReactorHttpHandlerAdapter handlerAdapter, Duration lifecycleTimeout, Shutdown shutdown) { - super(httpServer, handlerAdapter, lifecycleTimeout, shutdown); + super(httpServer, handlerAdapter, lifecycleTimeout, shutdown, null); } @Override diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java index 53b05891a7fd..0c1545b258a3 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/SslConnectorCustomizerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,40 +16,26 @@ package org.springframework.boot.web.embedded.tomcat; -import java.io.IOException; -import java.io.InputStream; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; -import java.util.Set; - -import org.apache.catalina.LifecycleState; import org.apache.catalina.connector.Connector; import org.apache.catalina.startup.Tomcat; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.tomcat.util.net.SSLHostConfig; -import org.apache.tomcat.util.net.SSLHostConfigCertificate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.testsupport.system.CapturedOutput; import org.springframework.boot.testsupport.system.OutputCaptureExtension; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; import org.springframework.boot.web.embedded.test.MockPkcs11Security; import org.springframework.boot.web.embedded.test.MockPkcs11SecurityProvider; import org.springframework.boot.web.server.Ssl; -import org.springframework.boot.web.server.SslStoreProvider; import org.springframework.boot.web.server.WebServerSslBundle; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; /** * Tests for {@link SslConnectorCustomizer} @@ -59,12 +45,13 @@ * @author Scott Frederick * @author Cyril Dangerville */ -@SuppressWarnings("removal") @ExtendWith(OutputCaptureExtension.class) @DirtiesUrlFactories @MockPkcs11Security class SslConnectorCustomizerTests { + private final Log logger = LogFactory.getLog(SslConnectorCustomizerTests.class); + private Tomcat tomcat; @BeforeEach @@ -87,10 +74,9 @@ void sslCiphersConfiguration() throws Exception { ssl.setKeyStore("classpath:test.jks"); ssl.setKeyStorePassword("secret"); ssl.setCiphers(new String[] { "ALPHA", "BRAVO", "CHARLIE" }); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl)); this.tomcat.start(); SSLHostConfig[] sslHostConfigs = connector.getProtocolHandler().findSslHostConfigs(); assertThat(sslHostConfigs[0].getCiphers()).isEqualTo("ALPHA:BRAVO:CHARLIE"); @@ -103,10 +89,9 @@ void sslEnabledMultipleProtocolsConfiguration() throws Exception { ssl.setKeyStore("src/test/resources/test.jks"); ssl.setEnabledProtocols(new String[] { "TLSv1.1", "TLSv1.2" }); ssl.setCiphers(new String[] { "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", "BRAVO" }); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl)); this.tomcat.start(); SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0]; assertThat(sslHostConfig.getSslProtocol()).isEqualTo("TLS"); @@ -120,81 +105,21 @@ void sslEnabledProtocolsConfiguration() throws Exception { ssl.setKeyStore("src/test/resources/test.jks"); ssl.setEnabledProtocols(new String[] { "TLSv1.2" }); ssl.setCiphers(new String[] { "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", "BRAVO" }); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, connector, ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl)); this.tomcat.start(); SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0]; assertThat(sslHostConfig.getSslProtocol()).isEqualTo("TLS"); assertThat(sslHostConfig.getEnabledProtocols()).containsExactly("TLSv1.2"); } - @Test - @Deprecated(since = "3.1.0", forRemoval = true) - void customizeWhenSslStoreProviderProvidesOnlyKeyStoreShouldUseDefaultTruststore() throws Exception { - Ssl ssl = new Ssl(); - ssl.setKeyPassword("password"); - ssl.setTrustStore("src/test/resources/test.jks"); - SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class); - KeyStore keyStore = loadStore(); - given(sslStoreProvider.getKeyStore()).willReturn(keyStore); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl, null, sslStoreProvider)); - Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); - this.tomcat.start(); - SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0]; - SSLHostConfig sslHostConfigWithDefaults = new SSLHostConfig(); - assertThat(sslHostConfig.getTruststoreFile()).isEqualTo(sslHostConfigWithDefaults.getTruststoreFile()); - Set certificates = sslHostConfig.getCertificates(); - assertThat(certificates).hasSize(1); - assertThat(certificates.iterator().next().getCertificateKeystore()).isEqualTo(keyStore); - } - - @Test - @Deprecated(since = "3.1.0", forRemoval = true) - void customizeWhenSslStoreProviderProvidesOnlyTrustStoreShouldUseDefaultKeystore() throws Exception { - Ssl ssl = new Ssl(); - ssl.setKeyPassword("password"); - ssl.setKeyStore("src/test/resources/test.jks"); - SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class); - KeyStore trustStore = loadStore(); - given(sslStoreProvider.getTrustStore()).willReturn(trustStore); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl, null, sslStoreProvider)); - Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); - this.tomcat.start(); - SSLHostConfig sslHostConfig = connector.getProtocolHandler().findSslHostConfigs()[0]; - assertThat(sslHostConfig.getTruststore()).isEqualTo(trustStore); - } - - @Test - @Deprecated(since = "3.1.0", forRemoval = true) - void customizeWhenSslStoreProviderPresentShouldIgnorePasswordFromSsl(CapturedOutput output) throws Exception { - System.setProperty("javax.net.ssl.trustStorePassword", "trustStoreSecret"); - Ssl ssl = new Ssl(); - ssl.setKeyPassword("password"); - ssl.setKeyStorePassword("secret"); - SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class); - given(sslStoreProvider.getTrustStore()).willReturn(loadStore()); - given(sslStoreProvider.getKeyStore()).willReturn(loadStore()); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl, null, sslStoreProvider)); - Connector connector = this.tomcat.getConnector(); - customizer.customize(connector); - this.tomcat.start(); - assertThat(connector.getState()).isEqualTo(LifecycleState.STARTED); - assertThat(output).doesNotContain("Password verification failed"); - } - @Test void customizeWhenSslIsEnabledWithNoKeyStoreAndNotPkcs11ThrowsException() { assertThatIllegalStateException().isThrownBy(() -> { - SslConnectorCustomizer customizer = new SslConnectorCustomizer(Ssl.ClientAuth.NONE, - WebServerSslBundle.get(new Ssl())); - customizer.customize(this.tomcat.getConnector()); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(), + Ssl.ClientAuth.NONE); + customizer.customize(WebServerSslBundle.get(new Ssl())); }).withMessageContaining("SSL is enabled but no trust material is configured"); } @@ -206,9 +131,9 @@ void customizeWhenSslIsEnabledWithPkcs11AndKeyStoreThrowsException() { ssl.setKeyStore("src/test/resources/test.jks"); ssl.setKeyPassword("password"); assertThatIllegalStateException().isThrownBy(() -> { - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); - customizer.customize(this.tomcat.getConnector()); + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(), + ssl.getClientAuth()); + customizer.customize(WebServerSslBundle.get(ssl)); }).withMessageContaining("must be empty or null for PKCS11 hardware key stores"); } @@ -218,18 +143,9 @@ void customizeWhenSslIsEnabledWithPkcs11AndKeyStoreProvider() { ssl.setKeyStoreType("PKCS11"); ssl.setKeyStoreProvider(MockPkcs11SecurityProvider.NAME); ssl.setKeyStorePassword("1234"); - SslConnectorCustomizer customizer = new SslConnectorCustomizer(ssl.getClientAuth(), - WebServerSslBundle.get(ssl)); - assertThatNoException().isThrownBy(() -> customizer.customize(this.tomcat.getConnector())); - } - - private KeyStore loadStore() throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { - KeyStore keyStore = KeyStore.getInstance("JKS"); - Resource resource = new ClassPathResource("test.jks"); - try (InputStream stream = resource.getInputStream()) { - keyStore.load(stream, "secret".toCharArray()); - return keyStore; - } + SslConnectorCustomizer customizer = new SslConnectorCustomizer(this.logger, this.tomcat.getConnector(), + ssl.getClientAuth()); + assertThatNoException().isThrownBy(() -> customizer.customize(WebServerSslBundle.get(ssl))); } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java index 4bbea8b0d41b..aaa1170c62ff 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactoryTests.java @@ -275,4 +275,16 @@ private void handleExceptionCausedByBlockedPortOnPrimaryConnector(RuntimeExcepti assertThat(((PortInUseException) ex).getPort()).isEqualTo(blockedPort); } + @Override + protected String startedLogMessage() { + return ((TomcatWebServer) this.webServer).getStartedLogMessage(); + } + + @Override + protected void addConnector(int port, AbstractReactiveWebServerFactory factory) { + Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol"); + connector.setPort(port); + ((TomcatReactiveWebServerFactory) factory).addAdditionalTomcatConnectors(connector); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java index 6e1851e72dc6..7255bd37b89f 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,9 @@ import javax.naming.InitialContext; import javax.naming.NamingException; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; import jakarta.servlet.MultipartConfigElement; import jakarta.servlet.ServletContext; @@ -60,8 +63,11 @@ import org.apache.hc.client5.http.HttpHostConnectException; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.TrustSelfSignedStrategy; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.NoHttpResponseException; +import org.apache.hc.core5.ssl.SSLContextBuilder; import org.apache.jasper.servlet.JspServlet; import org.apache.tomcat.JarScanFilter; import org.apache.tomcat.JarScanType; @@ -73,9 +79,11 @@ import org.junit.jupiter.api.Test; import org.mockito.InOrder; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; import org.springframework.boot.testsupport.system.CapturedOutput; import org.springframework.boot.web.server.PortInUseException; import org.springframework.boot.web.server.Shutdown; +import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.WebServerException; import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory; import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactoryTests; @@ -87,6 +95,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.util.FileSystemUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -107,6 +116,7 @@ * @author Phillip Webb * @author Dave Syer * @author Stephane Nicoll + * @author Moritz Halbritter */ class TomcatServletWebServerFactoryTests extends AbstractServletWebServerFactoryTests { @@ -120,11 +130,6 @@ void restoreTccl() { Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); } - @Override - protected boolean isCookieCommentSupported() { - return false; - } - // JMX MBean names clash if you get more than one Engine with the same name... @Test void tomcatEngineNames() { @@ -232,11 +237,23 @@ void tomcatProtocolHandlerCanBeCustomized() { void tomcatAdditionalConnectors() { TomcatServletWebServerFactory factory = getFactory(); Connector[] connectors = new Connector[4]; - Arrays.setAll(connectors, (i) -> new Connector()); + Arrays.setAll(connectors, (i) -> { + Connector connector = new Connector(); + connector.setPort(0); + return connector; + }); factory.addAdditionalTomcatConnectors(connectors); this.webServer = factory.getWebServer(); - Map connectorsByService = ((TomcatWebServer) this.webServer).getServiceConnectors(); + Map connectorsByService = new HashMap<>( + ((TomcatWebServer) this.webServer).getServiceConnectors()); assertThat(connectorsByService.values().iterator().next()).hasSize(connectors.length + 1); + this.webServer.start(); + this.webServer.stop(); + connectorsByService.forEach((service, serviceConnectors) -> { + for (Connector connector : serviceConnectors) { + assertThat(connector.getProtocolHandler()).extracting("endpoint.serverSock").isNull(); + } + }); } @Test @@ -345,10 +362,10 @@ void startupFailureDoesNotResultInUnstoppedThreadsBeingReported(CapturedOutput o } @Test - void stopCalledWithoutStart() { + void destroyCalledWithoutStart() { TomcatServletWebServerFactory factory = getFactory(); this.webServer = factory.getWebServer(exampleServletRegistration()); - this.webServer.stop(); + this.webServer.destroy(); Tomcat tomcat = ((TomcatWebServer) this.webServer).getTomcat(); assertThat(tomcat.getServer().getState()).isSameAs(LifecycleState.DESTROYED); } @@ -641,6 +658,30 @@ void whenServerIsShuttingDownARequestOnAnIdleConnectionResultsInConnectionReset( this.webServer.stop(); } + @Test + void shouldUpdateSslWhenReloadingSslBundles() throws Exception { + TomcatServletWebServerFactory factory = getFactory(); + addTestTxtFile(factory); + DefaultSslBundleRegistry bundles = new DefaultSslBundleRegistry("test", + createPemSslBundle("classpath:org/springframework/boot/web/embedded/tomcat/1.crt", + "classpath:org/springframework/boot/web/embedded/tomcat/1.key")); + factory.setSslBundles(bundles); + factory.setSsl(Ssl.forBundle("test")); + this.webServer = factory.getWebServer(); + this.webServer.start(); + RememberingHostnameVerifier verifier = new RememberingHostnameVerifier(); + SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory( + new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy()).build(), verifier); + HttpComponentsClientHttpRequestFactory requestFactory = createHttpComponentsRequestFactory(socketFactory); + assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test"); + assertThat(verifier.getLastPrincipal()).isEqualTo("CN=1"); + requestFactory = createHttpComponentsRequestFactory(socketFactory); + bundles.updateBundle("test", createPemSslBundle("classpath:org/springframework/boot/web/embedded/tomcat/2.crt", + "classpath:org/springframework/boot/web/embedded/tomcat/2.key")); + assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test"); + assertThat(verifier.getLastPrincipal()).isEqualTo("CN=2"); + } + @Override protected JspServlet getJspServlet() throws ServletException { Tomcat tomcat = ((TomcatWebServer) this.webServer).getTomcat(); @@ -694,4 +735,30 @@ protected void handleExceptionCausedByBlockedPortOnSecondaryConnector(RuntimeExc assertThat(((ConnectorStartFailedException) ex).getPort()).isEqualTo(blockedPort); } + @Override + protected String startedLogMessage() { + return ((TomcatWebServer) this.webServer).getStartedLogMessage(); + } + + private static final class RememberingHostnameVerifier implements HostnameVerifier { + + private volatile String lastPrincipal; + + @Override + public boolean verify(String hostname, SSLSession session) { + try { + this.lastPrincipal = session.getPeerPrincipal().getName(); + } + catch (SSLPeerUnverifiedException ex) { + throw new RuntimeException(ex); + } + return true; + } + + String getLastPrincipal() { + return this.lastPrincipal; + } + + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java index 836532109fff..8b607e228a97 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowReactiveWebServerFactoryTests.java @@ -27,6 +27,7 @@ import org.mockito.InOrder; import reactor.core.publisher.Mono; +import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactory; import org.springframework.boot.web.reactive.server.AbstractReactiveWebServerFactoryTests; import org.springframework.boot.web.server.Shutdown; import org.springframework.http.MediaType; @@ -155,4 +156,15 @@ private void awaitFile(File file) { Awaitility.waitAtMost(Duration.ofSeconds(10)).until(file::exists, is(true)); } + @Override + protected String startedLogMessage() { + return ((UndertowWebServer) this.webServer).getStartLogMessage(); + } + + @Override + protected void addConnector(int port, AbstractReactiveWebServerFactory factory) { + ((UndertowReactiveWebServerFactory) factory) + .addBuilderCustomizers((builder) -> builder.addHttpListener(port, "0.0.0.0")); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java index dd42e2bf9966..48d9aafda094 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/embedded/undertow/UndertowServletWebServerFactoryTests.java @@ -40,6 +40,7 @@ import org.apache.jasper.servlet.JspServlet; import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InOrder; @@ -211,6 +212,20 @@ void whenServerIsShuttingDownGracefullyThenRequestsAreRejectedWithServiceUnavail this.webServer.stop(); } + @Test + @Override + @Disabled("Restart after stop is not supported with Undertow") + protected void restartAfterStop() { + + } + + @Test + @Override + @Disabled("Undertow's architecture prevents separating stop and destroy") + protected void servletContextListenerContextDestroyedIsNotCalledWhenContainerIsStopped() { + + } + private void testAccessLog(String prefix, String suffix, String expectedFile) throws IOException, URISyntaxException { UndertowServletWebServerFactory factory = getFactory(); @@ -318,4 +333,9 @@ protected void handleExceptionCausedByBlockedPortOnSecondaryConnector(RuntimeExc handleExceptionCausedByBlockedPortOnPrimaryConnector(ex, blockedPort); } + @Override + protected String startedLogMessage() { + return ((UndertowServletWebServer) this.webServer).getStartLogMessage(); + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java index 06633045c35b..9fa2d22fcd39 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/error/DefaultErrorAttributesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,6 +50,7 @@ * @author Brian Clozel * @author Stephane Nicoll * @author Scott Frederick + * @author Moritz Halbritter */ class DefaultErrorAttributesTests { @@ -160,7 +161,6 @@ void includeException() { Map attributes = this.errorAttributes.getErrorAttributes(serverRequest, ErrorAttributeOptions.of(Include.EXCEPTION, Include.MESSAGE)); assertThat(this.errorAttributes.getError(serverRequest)).isSameAs(error); - assertThat(serverRequest.attribute(ErrorAttributes.ERROR_ATTRIBUTE)).containsSame(error); assertThat(attributes).containsEntry("exception", RuntimeException.class.getName()); assertThat(attributes).containsEntry("message", "Test"); } @@ -178,7 +178,6 @@ void processResponseStatusException() { assertThat(attributes).containsEntry("message", "invalid request"); assertThat(attributes).containsEntry("exception", RuntimeException.class.getName()); assertThat(this.errorAttributes.getError(serverRequest)).isSameAs(error); - assertThat(serverRequest.attribute(ErrorAttributes.ERROR_ATTRIBUTE)).containsSame(error); } @Test @@ -194,7 +193,6 @@ void processResponseStatusExceptionWithNoNestedCause() { assertThat(attributes).containsEntry("message", "could not process request"); assertThat(attributes).containsEntry("exception", ResponseStatusException.class.getName()); assertThat(this.errorAttributes.getError(serverRequest)).isSameAs(error); - assertThat(serverRequest.attribute(ErrorAttributes.ERROR_ATTRIBUTE)).containsSame(error); } @Test @@ -216,13 +214,37 @@ void includeTrace() { } @Test - void includePath() { + void includePathByDefault() { MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); Map attributes = this.errorAttributes.getErrorAttributes(buildServerRequest(request, NOT_FOUND), ErrorAttributeOptions.defaults()); assertThat(attributes).containsEntry("path", "/test"); } + @Test + void includePath() { + MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); + Map attributes = this.errorAttributes.getErrorAttributes(buildServerRequest(request, NOT_FOUND), + ErrorAttributeOptions.of(Include.PATH)); + assertThat(attributes).containsEntry("path", "/test"); + } + + @Test + void pathShouldIncludeContext() { + MockServerHttpRequest request = MockServerHttpRequest.get("/context/test").contextPath("/context").build(); + Map attributes = this.errorAttributes.getErrorAttributes(buildServerRequest(request, NOT_FOUND), + ErrorAttributeOptions.of(Include.PATH)); + assertThat(attributes).containsEntry("path", "/context/test"); + } + + @Test + void excludePath() { + MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); + Map attributes = this.errorAttributes.getErrorAttributes(buildServerRequest(request, NOT_FOUND), + ErrorAttributeOptions.of()); + assertThat(attributes).doesNotContainEntry("path", "/test"); + } + @Test void includeLogPrefix() { MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java index b957014feda6..116e21e01f71 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,10 +41,10 @@ import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.awaitility.Awaitility; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.util.StringRequestContent; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.StringRequestContent; import org.eclipse.jetty.http2.client.HTTP2Client; -import org.eclipse.jetty.http2.client.http.HttpClientTransportOverHTTP2; +import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -95,6 +95,12 @@ void tearDown() { if (this.webServer != null) { try { this.webServer.stop(); + try { + this.webServer.destroy(); + } + catch (Exception ex) { + // Ignore + } } catch (Exception ex) { // Ignore @@ -124,13 +130,37 @@ void specificPort() throws Exception { assertThat(this.webServer.getPort()).isEqualTo(specificPort); } + @Test + protected void restartAfterStop() throws Exception { + AbstractReactiveWebServerFactory factory = getFactory(); + this.webServer = factory.getWebServer(new EchoHandler()); + this.webServer.start(); + int port = this.webServer.getPort(); + assertThat(getResponse(port, "/test")).isEqualTo("Hello World"); + this.webServer.stop(); + assertThatException().isThrownBy(() -> getResponse(port, "/test")); + this.webServer.start(); + assertThat(getResponse(this.webServer.getPort(), "/test")).isEqualTo("Hello World"); + } + + private String getResponse(int port, String uri) { + WebClient webClient = getWebClient(port).build(); + Mono result = webClient.post() + .uri(uri) + .contentType(MediaType.TEXT_PLAIN) + .body(BodyInserters.fromValue("Hello World")) + .retrieve() + .bodyToMono(String.class); + return result.block(Duration.ofSeconds(30)); + } + @Test void portIsMinusOneWhenConnectionIsClosed() { AbstractReactiveWebServerFactory factory = getFactory(); this.webServer = factory.getWebServer(new EchoHandler()); this.webServer.start(); assertThat(this.webServer.getPort()).isGreaterThan(0); - this.webServer.stop(); + this.webServer.destroy(); assertThat(this.webServer.getPort()).isEqualTo(-1); } @@ -572,6 +602,25 @@ protected void whenHttp2IsEnabledAndSslIsDisabledThenHttp11CanStillBeUsed() { assertThat(result.block(Duration.ofSeconds(30))).isEqualTo("Hello World"); } + @Test + void startedLogMessageWithSinglePort() { + AbstractReactiveWebServerFactory factory = getFactory(); + this.webServer = factory.getWebServer(new EchoHandler()); + this.webServer.start(); + assertThat(startedLogMessage()).matches( + "(Jetty|Netty|Tomcat|Undertow) started on port " + this.webServer.getPort() + " \\(http(/1.1)?\\)"); + } + + @Test + protected void startedLogMessageWithMultiplePorts() { + AbstractReactiveWebServerFactory factory = getFactory(); + addConnector(0, factory); + this.webServer = factory.getWebServer(new EchoHandler()); + this.webServer.start(); + assertThat(startedLogMessage()).matches("(Jetty|Tomcat|Undertow) started on ports " + this.webServer.getPort() + + " \\(http(/1.1)?\\), [0-9]+ \\(http(/1.1)?\\)"); + } + protected WebClient prepareCompressionTest() { Compression compression = new Compression(); compression.setEnabled(true); @@ -643,6 +692,10 @@ protected final void doWithBlockedPort(BlockedPortAction action) throws Exceptio } } + protected abstract String startedLogMessage(); + + protected abstract void addConnector(int port, AbstractReactiveWebServerFactory factory); + public interface BlockedPortAction { void run(int port); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/SslConfigurationValidatorTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/SslConfigurationValidatorTests.java deleted file mode 100644 index df9697747967..000000000000 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/SslConfigurationValidatorTests.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2012-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.boot.web.server; - -import java.io.FileInputStream; -import java.io.InputStream; -import java.security.KeyStore; -import java.security.KeyStoreException; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; - -/** - * Tests for {@link SslConfigurationValidator}. - * - * @author Chris Bono - */ -@SuppressWarnings("removal") -@Deprecated(since = "3.1.0", forRemoval = true) -class SslConfigurationValidatorTests { - - private static final String VALID_ALIAS = "test-alias"; - - private static final String INVALID_ALIAS = "test-alias-5150"; - - private KeyStore keyStore; - - @BeforeEach - void loadKeystore() throws Exception { - this.keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - try (InputStream stream = new FileInputStream("src/test/resources/test.jks")) { - this.keyStore.load(stream, "secret".toCharArray()); - } - } - - @Test - void validateKeyAliasWhenAliasFoundShouldNotFail() { - SslConfigurationValidator.validateKeyAlias(this.keyStore, VALID_ALIAS); - } - - @Test - void validateKeyAliasWhenNullAliasShouldNotFail() { - SslConfigurationValidator.validateKeyAlias(this.keyStore, null); - } - - @Test - void validateKeyAliasWhenEmptyAliasShouldNotFail() { - SslConfigurationValidator.validateKeyAlias(this.keyStore, ""); - } - - @Test - void validateKeyAliasWhenAliasNotFoundShouldThrowException() { - assertThatIllegalStateException() - .isThrownBy(() -> SslConfigurationValidator.validateKeyAlias(this.keyStore, INVALID_ALIAS)) - .withMessage("Keystore does not contain alias '" + INVALID_ALIAS + "'"); - } - - @Test - void validateKeyAliasWhenKeyStoreThrowsExceptionOnContains() throws KeyStoreException { - KeyStore uninitializedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - assertThatIllegalStateException() - .isThrownBy(() -> SslConfigurationValidator.validateKeyAlias(uninitializedKeyStore, "alias")) - .withMessage("Could not determine if keystore contains alias 'alias'"); - } - -} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/WebServerSslBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/WebServerSslBundleTests.java index 5a8a18c622ef..41a58ce43430 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/WebServerSslBundleTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/WebServerSslBundleTests.java @@ -16,9 +16,6 @@ package org.springframework.boot.web.server; -import java.io.InputStream; -import java.security.KeyStore; - import org.junit.jupiter.api.Test; import org.springframework.boot.ssl.SslBundle; @@ -27,13 +24,9 @@ import org.springframework.boot.ssl.SslStoreBundle; import org.springframework.boot.web.embedded.test.MockPkcs11Security; import org.springframework.boot.web.embedded.test.MockPkcs11SecurityProvider; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; 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; /** * Tests for {@link WebServerSslBundle}. @@ -196,35 +189,6 @@ void whenJksKeyStoreAndPemTrustStoreProperties() { assertThat(options.getEnabledProtocols()).containsExactly("TLSv1.1", "TLSv1.2"); } - @Test - @Deprecated(since = "3.1.0", forRemoval = true) - @SuppressWarnings("removal") - void whenFromCustomSslStoreProvider() throws Exception { - SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class); - KeyStore keyStore = loadStore(); - given(sslStoreProvider.getKeyStore()).willReturn(keyStore); - given(sslStoreProvider.getTrustStore()).willReturn(keyStore); - Ssl ssl = new Ssl(); - ssl.setKeyStoreType("PKCS12"); - ssl.setTrustStoreType("PKCS12"); - ssl.setKeyPassword("password"); - ssl.setClientAuth(Ssl.ClientAuth.NONE); - ssl.setCiphers(new String[] { "ONE", "TWO", "THREE" }); - ssl.setEnabledProtocols(new String[] { "TLSv1.1", "TLSv1.2" }); - ssl.setProtocol("TLSv1.1"); - SslBundle bundle = WebServerSslBundle.get(ssl, null, sslStoreProvider); - assertThat(bundle).isNotNull(); - SslBundleKey key = bundle.getKey(); - assertThat(key.getPassword()).isEqualTo("password"); - assertThat(key.getAlias()).isNull(); - SslStoreBundle stores = bundle.getStores(); - assertThat(stores.getKeyStore()).isNotNull(); - assertThat(stores.getTrustStore()).isNotNull(); - SslOptions options = bundle.getOptions(); - assertThat(options.getCiphers()).containsExactly("ONE", "TWO", "THREE"); - assertThat(options.getEnabledProtocols()).containsExactly("TLSv1.1", "TLSv1.2"); - } - @Test void whenMissingPropertiesThrowsException() { Ssl ssl = new Ssl(); @@ -232,13 +196,4 @@ void whenMissingPropertiesThrowsException() { .withMessageContaining("SSL is enabled but no trust material is configured"); } - private KeyStore loadStore() throws Exception { - Resource resource = new ClassPathResource("test.p12"); - try (InputStream stream = resource.getInputStream()) { - KeyStore keyStore = KeyStore.getInstance("PKCS12"); - keyStore.load(stream, "secret".toCharArray()); - return keyStore; - } - } - } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/DynamicRegistrationBeanTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/DynamicRegistrationBeanTests.java index 70d598c95ea8..033792e4d9e5 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/DynamicRegistrationBeanTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/DynamicRegistrationBeanTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 @@ void shouldUseConventionBasedNameIfNoNameOrBeanNameIsSet() { } private static DynamicRegistrationBean createBean() { - return new DynamicRegistrationBean() { + return new DynamicRegistrationBean<>() { @Override protected Dynamic addRegistration(String description, ServletContext servletContext) { return null; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanIntegrationTests.java index b3e40e487041..b955eed39566 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanIntegrationTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/ServletComponentScanIntegrationTests.java @@ -38,7 +38,6 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.testsupport.classpath.ForkedClassPath; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.context.ServerPortInfoApplicationContextInitializer; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; @@ -160,7 +159,6 @@ protected ServletWebServerFactory webServerFactory(ObjectProvider ((ServletRegistrationBean) initializer).getServlet()) + .isInstanceOf(TestServletAndFilterAndListener.class); + assertThat(initializerBeans).element(1) + .isInstanceOf(FilterRegistrationBean.class) + .extracting((initializer) -> ((FilterRegistrationBean) initializer).getFilter()) + .isInstanceOf(TestServletAndFilterAndListener.class); + assertThat(initializerBeans).element(2) + .isInstanceOf(ServletListenerRegistrationBean.class) + .extracting((initializer) -> ((ServletListenerRegistrationBean) initializer).getListener()) + .isInstanceOf(TestServletAndFilterAndListener.class); + } + private void load(Class... configuration) { this.context = new AnnotationConfigApplicationContext(configuration); } @@ -106,6 +127,16 @@ TestFilter testFilter() { } + @Configuration(proxyBeanMethods = false) + static class MultipleInterfacesConfiguration { + + @Bean + TestServletAndFilterAndListener testServletAndFilterAndListener() { + return new TestServletAndFilterAndListener(); + } + + } + @Configuration(proxyBeanMethods = false) static class TestConfiguration { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/TestServletAndFilterAndListener.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/TestServletAndFilterAndListener.java new file mode 100644 index 000000000000..f3ed7aa2fae2 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/TestServletAndFilterAndListener.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.web.servlet; + +import java.io.IOException; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletRequestListener; +import jakarta.servlet.ServletResponse; + +class TestServletAndFilterAndListener implements Servlet, Filter, ServletRequestListener { + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + + } + + @Override + public void init(ServletConfig servletConfig) throws ServletException { + + } + + @Override + public ServletConfig getServletConfig() { + return null; + } + + @Override + public void service(ServletRequest servletRequest, ServletResponse servletResponse) + throws ServletException, IOException { + + } + + @Override + public String getServletInfo() { + return null; + } + + @Override + public void destroy() { + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContextTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContextTests.java index 4bde93562782..059b6224c294 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContextTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerApplicationContextTests.java @@ -84,6 +84,7 @@ import static org.mockito.Mockito.atMost; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.withSettings; /** @@ -156,12 +157,35 @@ void localPortIsAvailable() { } @Test - void stopOnClose() { + void stopOnStop() { addWebServerFactoryBean(); this.context.refresh(); MockServletWebServerFactory factory = getWebServerFactory(); - this.context.close(); + then(factory.getWebServer()).should().start(); + this.context.stop(); + then(factory.getWebServer()).should().stop(); + } + + @Test + void startOnStartAfterStop() { + addWebServerFactoryBean(); + this.context.refresh(); + MockServletWebServerFactory factory = getWebServerFactory(); + then(factory.getWebServer()).should().start(); + this.context.stop(); then(factory.getWebServer()).should().stop(); + this.context.start(); + then(factory.getWebServer()).should(times(2)).start(); + } + + @Test + void stopAndDestroyOnClose() { + addWebServerFactoryBean(); + this.context.refresh(); + MockServletWebServerFactory factory = getWebServerFactory(); + this.context.close(); + then(factory.getWebServer()).should(times(2)).stop(); + then(factory.getWebServer()).should().destroy(); } @Test diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerMvcIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerMvcIntegrationTests.java index 5680880dea13..24d04bdb3be1 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerMvcIntegrationTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/context/ServletWebServerMvcIntegrationTests.java @@ -22,7 +22,6 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; -import org.springframework.boot.testsupport.web.servlet.Servlet5ClassPathOverrides; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory; @@ -75,7 +74,6 @@ void tomcat() throws Exception { } @Test - @Servlet5ClassPathOverrides void jetty() throws Exception { this.context = new AnnotationConfigServletWebServerApplicationContext(JettyConfig.class); doTest(this.context, "/hello"); @@ -88,7 +86,6 @@ void undertow() throws Exception { } @Test - @Servlet5ClassPathOverrides void advancedConfig() throws Exception { this.context = new AnnotationConfigServletWebServerApplicationContext(AdvancedConfig.class); doTest(this.context, "/example/spring/hello"); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java index 71405232b66e..1eaaed469fe7 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/error/DefaultErrorAttributesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,6 +47,7 @@ * @author Phillip Webb * @author Vedran Pavic * @author Scott Frederick + * @author Moritz Halbritter */ class DefaultErrorAttributesTests { @@ -88,8 +89,6 @@ void mvcError() { Map attributes = this.errorAttributes.getErrorAttributes(this.webRequest, ErrorAttributeOptions.of(Include.MESSAGE)); assertThat(this.errorAttributes.getError(this.webRequest)).isSameAs(ex); - assertThat(this.webRequest.getAttribute(ErrorAttributes.ERROR_ATTRIBUTE, WebRequest.SCOPE_REQUEST)) - .isSameAs(ex); assertThat(modelAndView).isNull(); assertThat(attributes).doesNotContainKey("exception"); assertThat(attributes).containsEntry("message", "Test"); @@ -102,8 +101,6 @@ void servletErrorWithMessage() { Map attributes = this.errorAttributes.getErrorAttributes(this.webRequest, ErrorAttributeOptions.of(Include.MESSAGE)); assertThat(this.errorAttributes.getError(this.webRequest)).isSameAs(ex); - assertThat(this.webRequest.getAttribute(ErrorAttributes.ERROR_ATTRIBUTE, WebRequest.SCOPE_REQUEST)) - .isSameAs(ex); assertThat(attributes).doesNotContainKey("exception"); assertThat(attributes).containsEntry("message", "Test"); } @@ -115,8 +112,6 @@ void servletErrorWithoutMessage() { Map attributes = this.errorAttributes.getErrorAttributes(this.webRequest, ErrorAttributeOptions.defaults()); assertThat(this.errorAttributes.getError(this.webRequest)).isSameAs(ex); - assertThat(this.webRequest.getAttribute(ErrorAttributes.ERROR_ATTRIBUTE, WebRequest.SCOPE_REQUEST)) - .isSameAs(ex); assertThat(attributes).doesNotContainKey("exception"); assertThat(attributes).doesNotContainKey("message"); } @@ -166,8 +161,6 @@ void unwrapServletException() { Map attributes = this.errorAttributes.getErrorAttributes(this.webRequest, ErrorAttributeOptions.of(Include.MESSAGE)); assertThat(this.errorAttributes.getError(this.webRequest)).isSameAs(wrapped); - assertThat(this.webRequest.getAttribute(ErrorAttributes.ERROR_ATTRIBUTE, WebRequest.SCOPE_REQUEST)) - .isSameAs(wrapped); assertThat(attributes).doesNotContainKey("exception"); assertThat(attributes).containsEntry("message", "Test"); } @@ -179,8 +172,6 @@ void getError() { Map attributes = this.errorAttributes.getErrorAttributes(this.webRequest, ErrorAttributeOptions.of(Include.MESSAGE)); assertThat(this.errorAttributes.getError(this.webRequest)).isSameAs(error); - assertThat(this.webRequest.getAttribute(ErrorAttributes.ERROR_ATTRIBUTE, WebRequest.SCOPE_REQUEST)) - .isSameAs(error); assertThat(attributes).doesNotContainKey("exception"); assertThat(attributes).containsEntry("message", "Test error"); } @@ -259,13 +250,29 @@ void withoutStackTraceAttribute() { } @Test - void path() { + void shouldIncludePathByDefault() { this.request.setAttribute("jakarta.servlet.error.request_uri", "path"); Map attributes = this.errorAttributes.getErrorAttributes(this.webRequest, ErrorAttributeOptions.defaults()); assertThat(attributes).containsEntry("path", "path"); } + @Test + void shouldIncludePath() { + this.request.setAttribute("jakarta.servlet.error.request_uri", "path"); + Map attributes = this.errorAttributes.getErrorAttributes(this.webRequest, + ErrorAttributeOptions.of(Include.PATH)); + assertThat(attributes).containsEntry("path", "path"); + } + + @Test + void shouldExcludePath() { + this.request.setAttribute("jakarta.servlet.error.request_uri", "path"); + Map attributes = this.errorAttributes.getErrorAttributes(this.webRequest, + ErrorAttributeOptions.of()); + assertThat(attributes).doesNotContainEntry("path", "path"); + } + @Test void whenGetMessageIsOverriddenThenMessageAttributeContainsValueReturnedFromIt() { Map attributes = new DefaultErrorAttributes() { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java index 0e96fed0206b..24d0a06879b3 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java @@ -30,7 +30,6 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.KeyStore; -import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; @@ -99,9 +98,9 @@ import org.apache.jasper.servlet.JspServlet; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.awaitility.Awaitility; -import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.http2.client.HTTP2Client; -import org.eclipse.jetty.http2.client.http.HttpClientTransportOverHTTP2; +import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; @@ -135,14 +134,12 @@ import org.springframework.boot.web.server.Shutdown; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.Ssl.ClientAuth; -import org.springframework.boot.web.server.SslStoreProvider; import org.springframework.boot.web.server.WebServer; import org.springframework.boot.web.server.WebServerException; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.ServletContextInitializer; import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.boot.web.servlet.server.Session.SessionTrackingMode; -import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpMethod; @@ -162,11 +159,10 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; /** * Base for testing classes that extends {@link AbstractServletWebServerFactory}. @@ -177,7 +173,6 @@ * @author Raja Kolli * @author Scott Frederick */ -@SuppressWarnings("removal") @ExtendWith(OutputCaptureExtension.class) @DirtiesUrlFactories public abstract class AbstractServletWebServerFactoryTests { @@ -197,6 +192,12 @@ void tearDown() { if (this.webServer != null) { try { this.webServer.stop(); + try { + this.webServer.destroy(); + } + catch (Exception ex) { + // Ignore + } } catch (Exception ex) { // Ignore @@ -204,10 +205,6 @@ void tearDown() { } } - protected boolean isCookieCommentSupported() { - return true; - } - @Test void startServlet() throws Exception { AbstractServletWebServerFactory factory = getFactory(); @@ -237,6 +234,19 @@ void stopCalledTwice() { this.webServer.stop(); } + @Test + protected void restartAfterStop() throws IOException, URISyntaxException { + AbstractServletWebServerFactory factory = getFactory(); + this.webServer = factory.getWebServer(exampleServletRegistration()); + this.webServer.start(); + assertThat(getResponse(getLocalUrl("/hello"))).isEqualTo("Hello World"); + int port = this.webServer.getPort(); + this.webServer.stop(); + assertThatIOException().isThrownBy(() -> getResponse(getLocalUrl(port, "/hello"))); + this.webServer.start(); + assertThat(getResponse(getLocalUrl("/hello"))).isEqualTo("Hello World"); + } + @Test void emptyServerWhenPortIsMinusOne() { AbstractServletWebServerFactory factory = getFactory(); @@ -299,7 +309,7 @@ void portIsMinusOneWhenConnectionIsClosed() { this.webServer = factory.getWebServer(); this.webServer.start(); assertThat(this.webServer.getPort()).isGreaterThan(0); - this.webServer.stop(); + this.webServer.destroy(); assertThat(this.webServer.getPort()).isEqualTo(-1); } @@ -667,33 +677,6 @@ void sslWantsClientAuthenticationSucceedsWithoutClientCertificate() throws Excep assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test"); } - @Test - @Deprecated(since = "3.1.0", forRemoval = true) - void sslWithCustomSslStoreProvider() throws Exception { - AbstractServletWebServerFactory factory = getFactory(); - addTestTxtFile(factory); - Ssl ssl = new Ssl(); - ssl.setClientAuth(ClientAuth.NEED); - ssl.setKeyPassword("password"); - factory.setSsl(ssl); - SslStoreProvider sslStoreProvider = mock(SslStoreProvider.class); - given(sslStoreProvider.getKeyStore()).willReturn(loadStore()); - given(sslStoreProvider.getTrustStore()).willReturn(loadStore()); - factory.setSslStoreProvider(sslStoreProvider); - this.webServer = factory.getWebServer(); - this.webServer.start(); - KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - loadStore(keyStore, new FileSystemResource("src/test/resources/test.jks")); - SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory( - new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy()) - .loadKeyMaterial(keyStore, "password".toCharArray()) - .build()); - HttpComponentsClientHttpRequestFactory requestFactory = createHttpComponentsRequestFactory(socketFactory); - assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test"); - then(sslStoreProvider).should(atLeastOnce()).getKeyStore(); - then(sslStoreProvider).should(atLeastOnce()).getTrustStore(); - } - @Test void disableJspServletRegistration() throws Exception { AbstractServletWebServerFactory factory = getFactory(); @@ -774,7 +757,7 @@ private JksSslStoreDetails getJksStoreDetails(String location) { return new JksSslStoreDetails(getStoreType(location), null, location, "secret"); } - private SslBundle createPemSslBundle(String cert, String privateKey) { + protected SslBundle createPemSslBundle(String cert, String privateKey) { PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(cert).withPrivateKey(privateKey); PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(cert); SslStoreBundle stores = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); @@ -792,14 +775,13 @@ protected void testRestrictedSSLProtocolsAndCipherSuites(String[] protocols, Str assertThat(getResponse(getLocalUrl("https", "/hello"), requestFactory)).contains("scheme=https"); } - private HttpComponentsClientHttpRequestFactory createHttpComponentsRequestFactory( + protected HttpComponentsClientHttpRequestFactory createHttpComponentsRequestFactory( SSLConnectionSocketFactory socketFactory) { PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() .setSSLSocketFactory(socketFactory) .build(); HttpClient httpClient = this.httpClientBuilder.get().setConnectionManager(connectionManager).build(); - HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); - return requestFactory; + return new HttpComponentsClientHttpRequestFactory(httpClient); } private String getStoreType(String keyStore) { @@ -819,7 +801,7 @@ void persistSession() throws Exception { this.webServer.start(); String s1 = getResponse(getLocalUrl("/session")); String s2 = getResponse(getLocalUrl("/session")); - this.webServer.stop(); + this.webServer.destroy(); this.webServer = factory.getWebServer(sessionServletRegistration()); this.webServer.start(); String s3 = getResponse(getLocalUrl("/session")); @@ -838,7 +820,7 @@ void persistSessionInSpecificSessionStoreDir() throws Exception { this.webServer = factory.getWebServer(sessionServletRegistration()); this.webServer.start(); getResponse(getLocalUrl("/session")); - this.webServer.stop(); + this.webServer.destroy(); File[] dirContents = sessionStoreDir.listFiles((dir, name) -> !(".".equals(name) || "..".equals(name))); assertThat(dirContents).isNotEmpty(); } @@ -871,13 +853,11 @@ void getValidSessionStoreWhenSessionStoreReferencesFile() throws Exception { } @Test - @SuppressWarnings("removal") void sessionCookieConfiguration() { AbstractServletWebServerFactory factory = getFactory(); factory.getSession().getCookie().setName("testname"); factory.getSession().getCookie().setDomain("testdomain"); factory.getSession().getCookie().setPath("/testpath"); - factory.getSession().getCookie().setComment("testcomment"); factory.getSession().getCookie().setHttpOnly(true); factory.getSession().getCookie().setSecure(true); factory.getSession().getCookie().setMaxAge(Duration.ofSeconds(60)); @@ -887,9 +867,6 @@ void sessionCookieConfiguration() { assertThat(sessionCookieConfig.getName()).isEqualTo("testname"); assertThat(sessionCookieConfig.getDomain()).isEqualTo("testdomain"); assertThat(sessionCookieConfig.getPath()).isEqualTo("/testpath"); - if (isCookieCommentSupported()) { - assertThat(sessionCookieConfig.getComment()).isEqualTo("testcomment"); - } assertThat(sessionCookieConfig.isHttpOnly()).isTrue(); assertThat(sessionCookieConfig.isSecure()).isTrue(); assertThat(sessionCookieConfig.getMaxAge()).isEqualTo(60); @@ -937,6 +914,7 @@ void cookieSameSiteSuppliers() throws Exception { this.webServer = factory.getWebServer(); this.webServer.start(); ClientHttpResponse clientResponse = getClientResponse(getLocalUrl("/")); + assertThat(clientResponse.getStatusCode()).isEqualTo(HttpStatus.OK); List setCookieHeaders = clientResponse.getHeaders().get("Set-Cookie"); assertThat(setCookieHeaders).satisfiesExactlyInAnyOrder( (header) -> assertThat(header).contains("JSESSIONID").doesNotContain("SameSite"), @@ -947,7 +925,7 @@ void cookieSameSiteSuppliers() throws Exception { } @Test - void sslSessionTracking() { + protected void sslSessionTracking() { AbstractServletWebServerFactory factory = getFactory(); Ssl ssl = new Ssl(); ssl.setEnabled(true); @@ -1007,14 +985,12 @@ void compressionWithoutContentSizeHeader() throws Exception { void mimeMappingsAreCorrectlyConfigured() { AbstractServletWebServerFactory factory = getFactory(); this.webServer = factory.getWebServer(); - Map configuredMimeMappings = getActualMimeMappings(); + Collection configuredMimeMappings = getActualMimeMappings().entrySet() + .stream() + .map((entry) -> new MimeMappings.Mapping(entry.getKey(), entry.getValue())) + .toList(); Collection expectedMimeMappings = MimeMappings.DEFAULT.getAll(); - configuredMimeMappings - .forEach((key, value) -> assertThat(expectedMimeMappings).contains(new MimeMappings.Mapping(key, value))); - for (MimeMappings.Mapping mapping : expectedMimeMappings) { - assertThat(configuredMimeMappings).containsEntry(mapping.getExtension(), mapping.getMimeType()); - } - assertThat(configuredMimeMappings).hasSameSizeAs(expectedMimeMappings); + assertThat(configuredMimeMappings).containsExactlyInAnyOrderElementsOf(expectedMimeMappings); } @Test @@ -1144,7 +1120,6 @@ public void destroy() { } @Test - @SuppressWarnings("removal") void sessionConfiguration() { AbstractServletWebServerFactory factory = getFactory(); factory.getSession().setTimeout(Duration.ofSeconds(123)); @@ -1152,7 +1127,6 @@ void sessionConfiguration() { factory.getSession().getCookie().setName("testname"); factory.getSession().getCookie().setDomain("testdomain"); factory.getSession().getCookie().setPath("/testpath"); - factory.getSession().getCookie().setComment("testcomment"); factory.getSession().getCookie().setHttpOnly(true); factory.getSession().getCookie().setSecure(true); factory.getSession().getCookie().setMaxAge(Duration.ofMinutes(1)); @@ -1164,20 +1138,26 @@ void sessionConfiguration() { assertThat(servletContext.getSessionCookieConfig().getName()).isEqualTo("testname"); assertThat(servletContext.getSessionCookieConfig().getDomain()).isEqualTo("testdomain"); assertThat(servletContext.getSessionCookieConfig().getPath()).isEqualTo("/testpath"); - if (isCookieCommentSupported()) { - assertThat(servletContext.getSessionCookieConfig().getComment()).isEqualTo("testcomment"); - } assertThat(servletContext.getSessionCookieConfig().isHttpOnly()).isTrue(); assertThat(servletContext.getSessionCookieConfig().isSecure()).isTrue(); assertThat(servletContext.getSessionCookieConfig().getMaxAge()).isEqualTo(60); } @Test - void servletContextListenerContextDestroyedIsCalledWhenContainerIsStopped() throws Exception { + protected void servletContextListenerContextDestroyedIsNotCalledWhenContainerIsStopped() throws Exception { ServletContextListener listener = mock(ServletContextListener.class); this.webServer = getFactory().getWebServer((servletContext) -> servletContext.addListener(listener)); this.webServer.start(); this.webServer.stop(); + then(listener).should(times(0)).contextDestroyed(any(ServletContextEvent.class)); + } + + @Test + void servletContextListenerContextDestroyedIsCalledWhenContainerIsDestroyed() throws Exception { + ServletContextListener listener = mock(ServletContextListener.class); + this.webServer = getFactory().getWebServer((servletContext) -> servletContext.addListener(listener)); + this.webServer.start(); + this.webServer.destroy(); then(listener).should().contextDestroyed(any(ServletContextEvent.class)); } @@ -1322,6 +1302,35 @@ void whenARequestIsActiveAfterGracefulShutdownEndsThenStopWillComplete() throws } } + @Test + void startedLogMessageWithSinglePort() { + AbstractServletWebServerFactory factory = getFactory(); + this.webServer = factory.getWebServer(); + this.webServer.start(); + assertThat(startedLogMessage()).matches("(Jetty|Tomcat|Undertow) started on port " + this.webServer.getPort() + + " \\(http(/1.1)?\\) with context path '/'"); + } + + @Test + void startedLogMessageWithSinglePortAndContextPath() { + AbstractServletWebServerFactory factory = getFactory(); + factory.setContextPath("/test"); + this.webServer = factory.getWebServer(); + this.webServer.start(); + assertThat(startedLogMessage()).matches("(Jetty|Tomcat|Undertow) started on port " + this.webServer.getPort() + + " \\(http(/1.1)?\\) with context path '/test'"); + } + + @Test + void startedLogMessageWithMultiplePorts() { + AbstractServletWebServerFactory factory = getFactory(); + addConnector(0, factory); + this.webServer = factory.getWebServer(); + this.webServer.start(); + assertThat(startedLogMessage()).matches("(Jetty|Tomcat|Undertow) started on ports " + this.webServer.getPort() + + " \\(http(/1.1)?\\), [0-9]+ \\(http(/1.1)?\\) with context path '/'"); + } + protected Future initiateGetRequest(int port, String path) { return initiateGetRequest(HttpClients.createMinimal(), port, path); } @@ -1415,7 +1424,7 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) throws protected abstract Charset getCharset(Locale locale); - private void addTestTxtFile(AbstractServletWebServerFactory factory) throws IOException { + protected void addTestTxtFile(AbstractServletWebServerFactory factory) throws IOException { FileCopyUtils.copy("test", new FileWriter(new File(this.tempDir, "test.txt"))); factory.setDocumentRoot(this.tempDir); factory.setRegisterDefaultServlet(true); @@ -1556,13 +1565,6 @@ protected final void doWithBlockedPort(BlockedPortAction action) throws Exceptio } } - private KeyStore loadStore() throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { - KeyStore keyStore = KeyStore.getInstance("JKS"); - Resource resource = new ClassPathResource("test.jks"); - loadStore(keyStore, resource); - return keyStore; - } - private void loadStore(KeyStore keyStore, Resource resource) throws IOException, NoSuchAlgorithmException, CertificateException { try (InputStream stream = resource.getInputStream()) { @@ -1570,6 +1572,8 @@ private void loadStore(KeyStore keyStore, Resource resource) } } + protected abstract String startedLogMessage(); + private final class TestGzipInputStreamFactory implements InputStreamFactory { private final AtomicBoolean requested = new AtomicBoolean(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/SessionTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/SessionTests.java new file mode 100644 index 000000000000..75b8b9d44260 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/SessionTests.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-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.boot.web.servlet.server; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Session}. + * + * @author Phillip Webb + */ +class SessionTests { + + @Test // gh-38589 + void getCookieIsBinaryBackCompatible() throws Exception { + Class returnType = Session.class.getDeclaredMethod("getCookie").getReturnType(); + assertThat(returnType.getName()).isEqualTo("org.springframework.boot.web.servlet.server.Session$Cookie"); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/StaticResourceJarsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/StaticResourceJarsTests.java index 231a20a20517..050cbb447728 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/StaticResourceJarsTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/StaticResourceJarsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -95,7 +95,7 @@ void ignoreWildcardUrls() throws Exception { void doesNotCloseJarFromCachedConnection() throws Exception { File jarFile = createResourcesJar("test-resources.jar"); TrackedURLStreamHandler handler = new TrackedURLStreamHandler(true); - URL url = new URL("jar", null, 0, jarFile.toURI().toURL().toString() + "!/", handler); + URL url = new URL("jar", null, 0, jarFile.toURI().toURL() + "!/", handler); try { new StaticResourceJars().getUrlsFrom(url); assertThatNoException() @@ -110,7 +110,7 @@ void doesNotCloseJarFromCachedConnection() throws Exception { void closesJarFromNonCachedConnection() throws Exception { File jarFile = createResourcesJar("test-resources.jar"); TrackedURLStreamHandler handler = new TrackedURLStreamHandler(false); - URL url = new URL("jar", null, 0, jarFile.toURI().toURL().toString() + "!/", handler); + URL url = new URL("jar", null, 0, jarFile.toURI().toURL() + "!/", handler); new StaticResourceJars().getUrlsFrom(url); assertThatIllegalStateException() .isThrownBy(() -> ((JarURLConnection) handler.getConnection()).getJarFile().getComment()) diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderOkHttp3IntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderJettyClientIntegrationTests.java similarity index 67% rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderOkHttp3IntegrationTests.java rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderJettyClientIntegrationTests.java index e48feb9be032..ce64fddef180 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderOkHttp3IntegrationTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderJettyClientIntegrationTests.java @@ -18,12 +18,12 @@ import java.time.Duration; -import okhttp3.OkHttpClient; +import org.eclipse.jetty.client.HttpClient; import org.junit.jupiter.api.Test; import org.springframework.boot.testsupport.classpath.ClassPathExclusions; import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; +import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.ws.transport.WebServiceMessageSender; import org.springframework.ws.transport.http.ClientHttpRequestMessageSender; @@ -32,19 +32,19 @@ /** * Tests for {@link HttpWebServiceMessageSenderBuilder} when Http Components is not - * available. + * available and, therefore, Jetty's client is used instead. * * @author Stephane Nicoll */ @ClassPathExclusions("httpclient5-*.jar") -class HttpWebServiceMessageSenderBuilderOkHttp3IntegrationTests { +class HttpWebServiceMessageSenderBuilderJettyClientIntegrationTests { private final HttpWebServiceMessageSenderBuilder builder = new HttpWebServiceMessageSenderBuilder(); @Test - void buildUseOkHttp3ByDefault() { + void buildUseJettyClientIfHttpComponentsIsNotAvailable() { WebServiceMessageSender messageSender = this.builder.build(); - assertOkHttp3RequestFactory(messageSender); + assertJettyClientHttpRequestFactory(messageSender); } @Test @@ -52,19 +52,19 @@ void buildWithCustomTimeouts() { WebServiceMessageSender messageSender = this.builder.setConnectTimeout(Duration.ofSeconds(5)) .setReadTimeout(Duration.ofSeconds(2)) .build(); - OkHttp3ClientHttpRequestFactory factory = assertOkHttp3RequestFactory(messageSender); - OkHttpClient client = (OkHttpClient) ReflectionTestUtils.getField(factory, "client"); + JettyClientHttpRequestFactory factory = assertJettyClientHttpRequestFactory(messageSender); + HttpClient client = (HttpClient) ReflectionTestUtils.getField(factory, "httpClient"); assertThat(client).isNotNull(); - assertThat(client.connectTimeoutMillis()).isEqualTo(5000); - assertThat(client.readTimeoutMillis()).isEqualTo(2000); + assertThat(client.getConnectTimeout()).isEqualTo(5000); + assertThat(factory).hasFieldOrPropertyWithValue("readTimeout", 2000L); } - private OkHttp3ClientHttpRequestFactory assertOkHttp3RequestFactory(WebServiceMessageSender messageSender) { + private JettyClientHttpRequestFactory assertJettyClientHttpRequestFactory(WebServiceMessageSender messageSender) { assertThat(messageSender).isInstanceOf(ClientHttpRequestMessageSender.class); ClientHttpRequestMessageSender sender = (ClientHttpRequestMessageSender) messageSender; ClientHttpRequestFactory requestFactory = sender.getRequestFactory(); - assertThat(requestFactory).isInstanceOf(OkHttp3ClientHttpRequestFactory.class); - return (OkHttp3ClientHttpRequestFactory) requestFactory; + assertThat(requestFactory).isInstanceOf(JettyClientHttpRequestFactory.class); + return (JettyClientHttpRequestFactory) requestFactory; } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderSimpleIntegrationTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderSimpleIntegrationTests.java index 2c3cb5374861..6c3a0b2ef18e 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderSimpleIntegrationTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/webservices/client/HttpWebServiceMessageSenderBuilderSimpleIntegrationTests.java @@ -34,7 +34,7 @@ * * @author Stephane Nicoll */ -@ClassPathExclusions({ "httpclient5-*.jar", "okhttp*.jar" }) +@ClassPathExclusions({ "httpclient5-*.jar", "jetty-client-*.jar", "okhttp*.jar" }) class HttpWebServiceMessageSenderBuilderSimpleIntegrationTests { private final HttpWebServiceMessageSenderBuilder builder = new HttpWebServiceMessageSenderBuilder(); diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.crt new file mode 100644 index 000000000000..b9343e01ef3f --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.crt @@ -0,0 +1,19 @@ +-----BEGIN TRUSTED CERTIFICATE----- +MIIDIDCCAgsCFH3lh1RXOEy2ESqUPyzb+9zxMYUnMA0GCSqGSIb3DQEBCwUAME8x +CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl +ZmF1bHQgQ29tcGFueSBMdGQxCzAJBgNVBAMMAkNBMCAXDTIzMTAwNTA3MjU1M1oY +DzIxMjMwOTExMDcyNTUzWjBPMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVs +dCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMQswCQYDVQQDDAJD +QTCCAR4wDQYJKoZIhvcNAQEBBQADggELADCCAQYCgf4NNpc+6B3qvwKcRYgoXmJ4 +3wyWktBK7BdShz/YnW1OlFZ+R845ZiDw0KdzElZWkYqn+BYJus6lPIS5dfLcrGSf +a1e8IK02RpBiY/WJvupetnSk8gKA7emF94NlV4gXr4ICJAhXvXUFyBLpdEUE/lcg +lgCbVJzs5jWUnffEF9mrClzzo0+iXw34zwmyYyBTFmlOEr+QUEdAb6Lr/klpTVit +as2Ddg1QT4EaSIdTEpkVRZp2dyYVdqSxpaBq21xg0viDHsYQrP96IfacmUB7kFFn +HsnptDHFvJj2WSQDX+PRS7tLl4mmfizZg80eGfLD22ShNspRSGnbJc0OzegPiwID +AQABMA0GCSqGSIb3DQEBCwUAA4H/AAnC+FQqdeJaG5I7R+pNjgKplL2UsxW983kA +CVVkv/Dt0+4rbPC67o9/8Tr+g4eo/wUntMNo2ghF3oBItGr7pJE16zPiLwIvha9c +8BDhCEZWyhz3vkamZUi19lOnkm3zTmmDE/nX4WYH6CL4UWjxvniZYwW8AdVSnFXY +ncriuvfliLa3dw1SJ7FtxdcBn4yfzrZWcY+psYNHpftLGYRmQF/VCDSB9EAIEggr +yBcP749u2y8s44WvKAnnwfLcALIrylY25zN0pao/l2X8HI6qHUeA/QbbEBpDoQvR +du/rgaHCVvFFxATefhBJ0CUA1Nn5nrGwyRTKnZWtR080qwUp +-----END TRUSTED CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.pem b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.pem new file mode 100644 index 000000000000..c5102f84da50 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/ssl/pem/ca.pem @@ -0,0 +1,27 @@ +-----BEGIN PRIVATE KEY----- +MIIEqQIBADANBgkqhkiG9w0BAQEFAASCBJMwggSPAgEAAoH+DTaXPugd6r8CnEWI +KF5ieN8MlpLQSuwXUoc/2J1tTpRWfkfOOWYg8NCncxJWVpGKp/gWCbrOpTyEuXXy +3Kxkn2tXvCCtNkaQYmP1ib7qXrZ0pPICgO3phfeDZVeIF6+CAiQIV711BcgS6XRF +BP5XIJYAm1Sc7OY1lJ33xBfZqwpc86NPol8N+M8JsmMgUxZpThK/kFBHQG+i6/5J +aU1YrWrNg3YNUE+BGkiHUxKZFUWadncmFXaksaWgattcYNL4gx7GEKz/eiH2nJlA +e5BRZx7J6bQxxbyY9lkkA1/j0Uu7S5eJpn4s2YPNHhnyw9tkoTbKUUhp2yXNDs3o +D4sCAwEAAQKB/goGHht1EC0kFyDihvbJE79Kx0v7uNT94nuTa1Yzp9bzJeLLKqHU +3qySPlZH1QP7icr/pAhhlZ85GB9yYXoTtopSbs6jo4QHaEWcO4vyL+8GT9tKVafl +1UDyktXw36fIV8Kz/zhA3GQ0clR1Bl9RbFumMHOmbx4xTvieFnbG+TQ2THfFccGS +jCO6+dab6daXs8sBt0rGMh72utIISVsFJc7v3B8BpaNOI4iBMciRSyZeE4Vw/lRg +e3iErAVUmUjBrUK/wBy/l9cbbpkp+rvhQpmTIPtKd5f29AQNL7p6V+2+yRb2woRk +0i1HwOHGOhiCTxXZB9/nZykaT/T2+J9BAn8+DEWCRcfifyNEyuE54G6BvLvgGTgs ++kXWS7p0+wO9CFBDZARu/MXFEfcWt4ZTIj8HtMiKhxNbC1LiGtQnJoLV6AM75E5Q +toh/xyYOnHbhnbhsSNcpJk5iIdqQE6hWh+rYXFr1aJFMRZaWRkcUG8iIxWQQjRvw +qxLm9GQtEhF7An82hAlPCDs+6kT1otBEN8vGaW8qkxWYJf6kSd/I0/TEKRYpIwBa +Ist2BN5GrJTitKhzQIq2ZyT2byHxS0VIvInZJ6sFC+V6fHYpzWbS3zkBy2zswfAZ +UYrdjLVv16qZYsdjUnhkyUaBbBXnrTPlPzxXvgTeqJeJ5tbR6wgeqPUxAn8lcQxE +t00N/UBQE8jjPu4QNc59RVqjsYaQ8POcAZjY6fpdIC6Ytsm0yMl8mNRiuCimws28 +4hOo/eVO8XeSBGgxIidJbdRgWjV2PbtWV85ZCO6v0Sic+TOVfe5AwMv1I2FwnBJ7 +QlVjXB6podDkbnuNJOfkIPJ6QRFP8qu8ksmfAn8mttuZeYIBawLv4eC/IVSgIc3l +UTC7rPfKGgBHMWaYS4lGS2n7mMwektR7IiJVYPBjcIlRgaw5KbDUF50rS2Elissj +uVANDQgpJYoI5KcqRBmlhRCKGmNgdIWA2Ip5hTGNskp3YIymamif71t0SNUEhpgU +u2tqbjlON/e7NkdxAn8VdVYq+4sAWRdU4VJqqyf8dyBx68sysvY6HYlKS2bpfu3C +J3gbPximDZhzMvKx2/CAzMbAT3anyr/DiUImk+QdWSmht+1SLH7A14MDjzQ0D5xt +GgPqWn7PtcJojFMjc/o5/fKgFf4CYkJhv2KycX9UeldBxpqNpNigzFWBLdtu +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt new file mode 100644 index 000000000000..dd4be7410d6e --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG +A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG +A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8 +XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw +FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD +QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc +QhqKXcO7xH7f2tD5hE2izcUB +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key new file mode 100644 index 000000000000..712fa35133c4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/1.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt new file mode 100644 index 000000000000..7c13395e0a54 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG +A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG +A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D +43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw +FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD +QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV ++xZ+KWv26pLJR46vk8Kc6ZIO +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key new file mode 100644 index 000000000000..9917897564bf --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/netty/2.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt new file mode 100644 index 000000000000..dd4be7410d6e --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLjCB4aADAgECAhQ25wrNnapZEkFc8kgf5NDHXKxnTzAFBgMrZXAwDDEKMAgG +A1UEAwwBMTAgFw0yMzEwMTAwODU1MTJaGA8yMTIzMDkxNjA4NTUxMlowDDEKMAgG +A1UEAwwBMTAqMAUGAytlcAMhAOyxNxHzcNj7xTkcjVLI09sYUGUGIvdV5s0YWXT8 +XAiwo1MwUTAdBgNVHQ4EFgQUmm23oLIu5MgdBb/snZSuE+MrRZ0wHwYDVR0jBBgw +FoAUmm23oLIu5MgdBb/snZSuE+MrRZ0wDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD +QQA2KMpIyySC8u4onW2MVW1iK2dJJZbMRaNMLlQuE+ZIHQLwflYW4sH/Pp76pboc +QhqKXcO7xH7f2tD5hE2izcUB +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key new file mode 100644 index 000000000000..712fa35133c4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/1.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJb1A+i5bmilBD9mUbhk1oFVI6FAZQGnhduv7xV6WWEc +-----END PRIVATE KEY----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt new file mode 100644 index 000000000000..7c13395e0a54 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLjCB4aADAgECAhR4TMDk3qg5sKREp16lEHR3bV3M9zAFBgMrZXAwDDEKMAgG +A1UEAwwBMjAgFw0yMzEwMTAwODU1MjBaGA8yMTIzMDkxNjA4NTUyMFowDDEKMAgG +A1UEAwwBMjAqMAUGAytlcAMhADPft6hzyCjHCe5wSprChuuO/CuPIJ2t+l4roS1D +43/wo1MwUTAdBgNVHQ4EFgQUfrRibAWml4Ous4kpnBIggM2xnLcwHwYDVR0jBBgw +FoAUfrRibAWml4Ous4kpnBIggM2xnLcwDwYDVR0TAQH/BAUwAwEB/zAFBgMrZXAD +QQC/MOclal2Cp0B3kmaLbK0M8mapclIOJa78hzBkqPA3URClAF2GmF187wHqi7qV ++xZ+KWv26pLJR46vk8Kc6ZIO +-----END CERTIFICATE----- diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key new file mode 100644 index 000000000000..9917897564bf --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/embedded/tomcat/2.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICxhres2Z2lICm7/isnm+2iNR12GmgG7KK86BNDZDeIF +-----END PRIVATE KEY----- diff --git a/spring-boot-system-tests/spring-boot-image-tests/build.gradle b/spring-boot-system-tests/spring-boot-image-tests/build.gradle index 9de3b9c9fbfe..e4236f1b82fc 100644 --- a/spring-boot-system-tests/spring-boot-image-tests/build.gradle +++ b/spring-boot-system-tests/spring-boot-image-tests/build.gradle @@ -19,6 +19,11 @@ configurations { if (dependency.requested.group.startsWith("com.fasterxml.jackson")) { dependency.useVersion("2.14.2") } + // Downgrade Spring Framework as Gradle cannot cope with 6.1.0-M1's + // multi-version jar files with bytecode in META-INF/versions/21 + if (dependency.requested.group.equals("org.springframework")) { + dependency.useVersion("6.0.10") + } } } } diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/java/org/springframework/boot/image/paketo/PaketoBuilderTests.java b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/java/org/springframework/boot/image/paketo/PaketoBuilderTests.java index 478be5eb1bc8..f8ba6573ae9e 100644 --- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/java/org/springframework/boot/image/paketo/PaketoBuilderTests.java +++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/java/org/springframework/boot/image/paketo/PaketoBuilderTests.java @@ -94,9 +94,10 @@ void executableJarApp() throws Exception { .contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica", "paketo-buildpacks/executable-jar", "paketo-buildpacks/dist-zip", "paketo-buildpacks/spring-boot"); - metadata.processOfType("web").containsExactly("java", "org.springframework.boot.loader.JarLauncher"); + metadata.processOfType("web") + .containsExactly("java", "org.springframework.boot.loader.launch.JarLauncher"); metadata.processOfType("executable-jar") - .containsExactly("java", "org.springframework.boot.loader.JarLauncher"); + .containsExactly("java", "org.springframework.boot.loader.launch.JarLauncher"); }); assertImageHasJvmSbomLayer(imageReference, config); assertImageHasDependenciesSbomLayer(imageReference, config, "executable-jar"); @@ -239,9 +240,10 @@ void executableWarApp() throws Exception { .contains("paketo-buildpacks/ca-certificates", "paketo-buildpacks/bellsoft-liberica", "paketo-buildpacks/executable-jar", "paketo-buildpacks/dist-zip", "paketo-buildpacks/spring-boot"); - metadata.processOfType("web").containsExactly("java", "org.springframework.boot.loader.WarLauncher"); + metadata.processOfType("web") + .containsExactly("java", "org.springframework.boot.loader.launch.WarLauncher"); metadata.processOfType("executable-jar") - .containsExactly("java", "org.springframework.boot.loader.WarLauncher"); + .containsExactly("java", "org.springframework.boot.loader.launch.WarLauncher"); }); assertImageHasJvmSbomLayer(imageReference, config); assertImageHasDependenciesSbomLayer(imageReference, config, "executable-jar"); @@ -301,6 +303,7 @@ void plainWarApp() throws Exception { @EnabledForJreRange(max = JRE.JAVA_17) void nativeApp() throws Exception { this.gradleBuild.expectDeprecationMessages("uses or overrides a deprecated API"); + this.gradleBuild.expectDeprecationMessages("has been deprecated and marked for removal"); // these deprecations are transitive from the Native Build Tools Gradle plugin this.gradleBuild .expectDeprecationMessages("has been deprecated. This is scheduled to be removed in Gradle 9.0"); @@ -473,11 +476,9 @@ private String javaMajorVersion() { if (javaVersion.startsWith("1.")) { return javaVersion.substring(2, 3); } - else { - int firstDotIndex = javaVersion.indexOf("."); - if (firstDotIndex != -1) { - return javaVersion.substring(0, firstDotIndex); - } + int firstDotIndex = javaVersion.indexOf("."); + if (firstDotIndex != -1) { + return javaVersion.substring(0, firstDotIndex); } return javaVersion; } diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-bootDistZipJarApp.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-bootDistZipJarApp.gradle index 3fa25b5ed25c..97b32d1ffa78 100644 --- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-bootDistZipJarApp.gradle +++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-bootDistZipJarApp.gradle @@ -37,7 +37,6 @@ application { } bootBuildImage { - builder = "paketobuildpacks/builder-jammy-base:latest" archiveFile = bootDistZip.archiveFile environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion()] } \ No newline at end of file diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-executableWarApp.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-executableWarApp.gradle index 7e31c33f9c99..291f22d1a021 100644 --- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-executableWarApp.gradle +++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-executableWarApp.gradle @@ -32,7 +32,3 @@ bootWar { ) } } - -bootBuildImage { - builder = "paketobuildpacks/builder-jammy-base:latest" -} diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-nativeApp.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-nativeApp.gradle index 130cc125266e..3d56d66fdc6f 100644 --- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-nativeApp.gradle +++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-nativeApp.gradle @@ -34,6 +34,5 @@ bootJar { } bootBuildImage { - builder = "paketobuildpacks/builder-jammy-base:latest" environment = ['BP_NATIVE_IMAGE': 'true'] } diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainDistZipJarApp.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainDistZipJarApp.gradle index ffffef7d4d7e..91a0707f0f81 100644 --- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainDistZipJarApp.gradle +++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainDistZipJarApp.gradle @@ -37,7 +37,6 @@ application { } bootBuildImage { - builder = "paketobuildpacks/builder-jammy-base:latest" archiveFile = distZip.archiveFile environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion()] } \ No newline at end of file diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainWarApp.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainWarApp.gradle index ffa9a9a0966d..ec964e92520f 100644 --- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainWarApp.gradle +++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests-plainWarApp.gradle @@ -30,7 +30,6 @@ war { } bootBuildImage { - builder = "paketobuildpacks/builder-jammy-base:latest" archiveFile = war.archiveFile environment = ['BP_JVM_VERSION': java.targetCompatibility.getMajorVersion(), 'BP_TOMCAT_VERSION': '10.*'] } diff --git a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests.gradle b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests.gradle index 8f0b7daf86cb..71fe32355b08 100644 --- a/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests.gradle +++ b/spring-boot-system-tests/spring-boot-image-tests/src/systemTest/resources/org/springframework/boot/image/paketo/PaketoBuilderTests.gradle @@ -29,8 +29,4 @@ bootJar { 'Implementation-Title': "Paketo Test" ) } -} - -bootBuildImage { - builder = "paketobuildpacks/builder-jammy-base:latest" -} +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/build.gradle new file mode 100644 index 000000000000..d05a3d6c9e09 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" + id "org.springframework.boot.integration-test" +} + +description = "Spring Boot Classic Loader Integration Tests" + +configurations { + app +} + +dependencies { + app project(path: ":spring-boot-project:spring-boot-dependencies", configuration: "mavenRepository") + app project(path: ":spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin", configuration: "mavenRepository") + app project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter-web", configuration: "mavenRepository") + + intTestImplementation(enforcedPlatform(project(":spring-boot-project:spring-boot-parent"))) + intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) + intTestImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) + intTestImplementation("org.testcontainers:junit-jupiter") + intTestImplementation("org.testcontainers:testcontainers") +} + +task syncMavenRepository(type: Sync) { + from configurations.app + into "${buildDir}/int-test-maven-repository" +} + +task syncAppSource(type: org.springframework.boot.build.SyncAppSource) { + sourceDirectory = file("spring-boot-loader-classic-tests-app") + destinationDirectory = file("${buildDir}/spring-boot-loader-classic-tests-app") +} + +task buildApp(type: GradleBuild) { + dependsOn syncAppSource, syncMavenRepository + dir = "${buildDir}/spring-boot-loader-classic-tests-app" + startParameter.buildCacheEnabled = false + tasks = ["build"] +} + +intTest { + dependsOn buildApp +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/build.gradle new file mode 100644 index 000000000000..16f7a57ebe55 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/build.gradle @@ -0,0 +1,22 @@ +plugins { + id "java" + id "org.springframework.boot" +} + +apply plugin: "io.spring.dependency-management" + +repositories { + maven { url "file:${rootDir}/../int-test-maven-repository"} + mavenCentral() + maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.webjars:jquery:3.5.0") +} + +bootJar { + loaderImplementation = org.springframework.boot.loader.tools.LoaderImplementation.CLASSIC +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/settings.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/settings.gradle new file mode 100644 index 000000000000..06d9554ad0d6 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + maven { url "file:${rootDir}/../int-test-maven-repository"} + mavenCentral() + maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "org.springframework.boot") { + useModule "org.springframework.boot:spring-boot-gradle-plugin:${requested.version}" + } + } + } +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java new file mode 100644 index 000000000000..0c9d429350d8 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/spring-boot-loader-classic-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-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.boot.loaderapp; + +import java.io.File; +import java.net.JarURLConnection; +import java.net.URL; +import java.util.Arrays; + +import jakarta.servlet.ServletContext; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.util.FileCopyUtils; + +@SpringBootApplication +public class LoaderTestApplication { + + @Bean + public CommandLineRunner commandLineRunner(ServletContext servletContext) { + return (args) -> { + File temp = new File(System.getProperty("java.io.tmpdir")); + URL resourceUrl = servletContext.getResource("webjars/jquery/3.5.0/jquery.js"); + JarURLConnection connection = (JarURLConnection) resourceUrl.openConnection(); + String jarName = connection.getJarFile().getName(); + System.out.println(">>>>> jar file " + jarName); + if(jarName.contains(temp.getAbsolutePath())) { + System.out.println(">>>>> jar written to temp"); + } + byte[] resourceContent = FileCopyUtils.copyToByteArray(resourceUrl.openStream()); + URL directUrl = new URL(resourceUrl.toExternalForm()); + byte[] directContent = FileCopyUtils.copyToByteArray(directUrl.openStream()); + String message = (!Arrays.equals(resourceContent, directContent)) ? "NO MATCH" + : directContent.length + " BYTES"; + System.out.println(">>>>> " + message + " from " + resourceUrl); + }; + } + + public static void main(String[] args) { + SpringApplication.run(LoaderTestApplication.class, args).close(); + } + +} diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java new file mode 100644 index 000000000000..b11478b61c7f --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2012-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.boot.loader; + +import java.io.File; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.ToStringConsumer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import org.springframework.boot.system.JavaVersion; +import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; +import org.springframework.util.Assert; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests loader that supports uber jars. + * + * @author Phillip Webb + * @author Moritz Halbritter + */ +@DisabledIfDockerUnavailable +class LoaderIntegrationTests { + + private final ToStringConsumer output = new ToStringConsumer(); + + @ParameterizedTest + @MethodSource("javaRuntimes") + void readUrlsWithoutWarning(JavaRuntime javaRuntime) { + try (GenericContainer container = createContainer(javaRuntime)) { + container.start(); + System.out.println(this.output.toUtf8String()); + assertThat(this.output.toUtf8String()).contains(">>>>> 287649 BYTES from") + .doesNotContain("WARNING:") + .doesNotContain("illegal") + .doesNotContain("jar written to temp"); + } + } + + private GenericContainer createContainer(JavaRuntime javaRuntime) { + return javaRuntime.getContainer() + .withLogConsumer(this.output) + .withCopyFileToContainer(MountableFile.forHostPath(findApplication().toPath()), "/app.jar") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofMinutes(5))) + .withCommand("java", "-jar", "app.jar"); + } + + private File findApplication() { + String name = String.format("build/%1$s/build/libs/%1$s.jar", "spring-boot-loader-classic-tests-app"); + File jar = new File(name); + Assert.state(jar.isFile(), () -> "Could not find " + name + ". Have you built it?"); + return jar; + } + + static Stream javaRuntimes() { + List javaRuntimes = new ArrayList<>(); + javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.SEVENTEEN)); + javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.TWENTY_ONE)); + javaRuntimes.add(JavaRuntime.oracleJdk17()); + return javaRuntimes.stream().filter(JavaRuntime::isCompatible); + } + + static final class JavaRuntime { + + private final String name; + + private final JavaVersion version; + + private final Supplier> container; + + private JavaRuntime(String name, JavaVersion version, Supplier> container) { + this.name = name; + this.version = version; + this.container = container; + } + + private boolean isCompatible() { + return this.version.isEqualOrNewerThan(JavaVersion.getJavaVersion()); + } + + GenericContainer getContainer() { + return this.container.get(); + } + + @Override + public String toString() { + return this.name; + } + + static JavaRuntime openJdk(JavaVersion version) { + String imageVersion = version.toString(); + DockerImageName image = DockerImageName.parse("bellsoft/liberica-openjdk-debian:" + imageVersion); + return new JavaRuntime("OpenJDK " + imageVersion, version, () -> new GenericContainer<>(image)); + } + + static JavaRuntime oracleJdk17() { + String arch = System.getProperty("os.arch"); + String dockerFile = ("aarch64".equals(arch)) ? "Dockerfile-aarch64" : "Dockerfile"; + ImageFromDockerfile image = new ImageFromDockerfile("spring-boot-loader/oracle-jdk-17") + .withFileFromFile("Dockerfile", new File("src/intTest/resources/conf/oracle-jdk-17/" + dockerFile)); + return new JavaRuntime("Oracle JDK 17", JavaVersion.SEVENTEEN, () -> new GenericContainer<>(image)); + } + + } + +} diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile new file mode 100644 index 000000000000..98adf761db4c --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile @@ -0,0 +1,8 @@ +FROM ubuntu:jammy-20231211.1 +RUN apt-get update && \ + apt-get install -y software-properties-common curl && \ + mkdir -p /opt/oraclejdk && \ + cd /opt/oraclejdk && \ + curl -L https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz | tar zx --strip-components=1 +ENV JAVA_HOME /opt/oraclejdk +ENV PATH $JAVA_HOME/bin:$PATH diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64 b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64 new file mode 100644 index 000000000000..f463770a3305 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64 @@ -0,0 +1,8 @@ +FROM ubuntu:jammy-20231211.1 +RUN apt-get update && \ + apt-get install -y software-properties-common curl && \ + mkdir -p /opt/oraclejdk && \ + cd /opt/oraclejdk && \ + curl -L https://download.oracle.com/java/17/archive/jdk-17.0.8_linux-aarch64_bin.tar.gz | tar zx --strip-components=1 +ENV JAVA_HOME /opt/oraclejdk +ENV PATH $JAVA_HOME/bin:$PATH diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc new file mode 100644 index 000000000000..28704af225f5 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc @@ -0,0 +1,5 @@ +This folder contains a Dockerfile that will create an Oracle JDK instance for use in integration tests. +The resulting Docker image should not be published. + +Oracle JDK is subject to the https://www.oracle.com/downloads/licenses/no-fee-license.html["Oracle No-Fee Terms and Conditions" License (NFTC)] license. +We are specifically using the unmodified JDK for the purposes of developing and testing. diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/logback.xml b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/logback.xml new file mode 100644 index 000000000000..b8a41480d7d6 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-classic-tests/src/intTest/resources/logback.xml @@ -0,0 +1,4 @@ + + + + diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle index d81cf660aefc..7f5f04e380e9 100644 --- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle @@ -18,6 +18,8 @@ dependencies { app project(path: ":spring-boot-project:spring-boot-dependencies", configuration: "mavenRepository") app project(path: ":spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin", configuration: "mavenRepository") app project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter-web", configuration: "mavenRepository") + app project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter", configuration: "mavenRepository") + app("org.bouncycastle:bcprov-jdk18on:1.76") intTestImplementation(enforcedPlatform(project(":spring-boot-project:spring-boot-parent"))) intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) @@ -43,6 +45,18 @@ task buildApp(type: GradleBuild) { tasks = ["build"] } +task syncSignedJarAppSource(type: org.springframework.boot.build.SyncAppSource) { + sourceDirectory = file("spring-boot-loader-tests-signed-jar") + destinationDirectory = file("${buildDir}/spring-boot-loader-tests-signed-jar") +} + +task buildSignedJarApp(type: GradleBuild) { + dependsOn syncSignedJarAppSource, syncMavenRepository + dir = "${buildDir}/spring-boot-loader-tests-signed-jar" + startParameter.buildCacheEnabled = false + tasks = ["build"] +} + task downloadJdk(type: Download) { def destFolder = new File(project.gradle.gradleUserHomeDir, "caches/springboot/downloads/jdk/oracle") destFolder.mkdirs() @@ -65,5 +79,5 @@ processIntTestResources { } intTest { - dependsOn buildApp + dependsOn buildApp, buildSignedJarApp } diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle index 37596c620634..8f8cf37e3aae 100644 --- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle @@ -1,6 +1,8 @@ plugins { id "java" id "org.springframework.boot" +// id 'org.springframework.boot' version '3.1.4' +// id 'io.spring.dependency-management' version '1.1.3' } apply plugin: "io.spring.dependency-management" diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java index 81b7c41cbd3a..245b471b7900 100644 --- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -17,8 +17,13 @@ package org.springframework.boot.loaderapp; import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; import java.net.JarURLConnection; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Arrays; import jakarta.servlet.ServletContext; @@ -27,6 +32,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; import org.springframework.util.FileCopyUtils; @SpringBootApplication @@ -49,11 +56,22 @@ public CommandLineRunner commandLineRunner(ServletContext servletContext) { String message = (!Arrays.equals(resourceContent, directContent)) ? "NO MATCH" : directContent.length + " BYTES"; System.out.println(">>>>> " + message + " from " + resourceUrl); + testGh7161(); }; } + private void testGh7161() { + try { + Resource resource = new ClassPathResource("gh-7161"); + Path path = Paths.get(resource.getURI()); + System.out.println(">>>>> gh-7161 " + Files.list(path).toList()); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + public static void main(String[] args) { - SpringApplication.run(LoaderTestApplication.class, args).stop(); + SpringApplication.run(LoaderTestApplication.class, args).close(); } } diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/resources/gh-7161/example.txt b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/resources/gh-7161/example.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/build.gradle new file mode 100644 index 000000000000..7ca8a2712496 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/build.gradle @@ -0,0 +1,30 @@ +import org.springframework.boot.gradle.tasks.bundling.BootJar + +plugins { + id "java" + id "org.springframework.boot" +} + +apply plugin: "io.spring.dependency-management" + +repositories { + maven { url "file:${rootDir}/../int-test-maven-repository"} + mavenCentral() + maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.bouncycastle:bcprov-jdk18on:1.76") +} + +tasks.register("bootJarUnpack", BootJar.class) { + mainClass = "org.springframework.boot.loaderapp.LoaderSignedJarTestApplication" + classpath = bootJar.classpath + requiresUnpack '**/bcprov-jdk18on-*.jar' + archiveClassifier.set("unpack") + targetJavaVersion = targetCompatibility +} + +build.dependsOn bootJarUnpack \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/settings.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/settings.gradle new file mode 100644 index 000000000000..06d9554ad0d6 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + maven { url "file:${rootDir}/../int-test-maven-repository"} + mavenCentral() + maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "org.springframework.boot") { + useModule "org.springframework.boot:spring-boot-gradle-plugin:${requested.version}" + } + } + } +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java new file mode 100644 index 000000000000..627a6c3996d3 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-signed-jar/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-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.boot.loaderapp; + +import java.security.Security; +import javax.crypto.Cipher; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LoaderSignedJarTestApplication { + + public static void main(String[] args) throws Exception { + Security.addProvider(new BouncyCastleProvider()); + Cipher.getInstance("AES/CBC/PKCS5Padding","BC"); + System.out.println("Legion of the Bouncy Castle"); + SpringApplication.run(LoaderSignedJarTestApplication.class, args); + } + +} diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java index b3b7bd2c09f4..84b46b8f9a53 100644 --- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java @@ -42,6 +42,7 @@ * Integration tests loader that supports fat jars. * * @author Phillip Webb + * @author Moritz Halbritter */ @DisabledIfDockerUnavailable class LoaderIntegrationTests { @@ -50,37 +51,66 @@ class LoaderIntegrationTests { @ParameterizedTest @MethodSource("javaRuntimes") - void readUrlsWithoutWarning(JavaRuntime javaRuntime) { - try (GenericContainer container = createContainer(javaRuntime)) { + void runJar(JavaRuntime javaRuntime) { + try (GenericContainer container = createContainer(javaRuntime, "spring-boot-loader-tests-app", null)) { container.start(); System.out.println(this.output.toUtf8String()); assertThat(this.output.toUtf8String()).contains(">>>>> 287649 BYTES from") + .contains(">>>>> gh-7161 [/gh-7161/example.txt]") .doesNotContain("WARNING:") .doesNotContain("illegal") .doesNotContain("jar written to temp"); } } - private GenericContainer createContainer(JavaRuntime javaRuntime) { + @ParameterizedTest + @MethodSource("javaRuntimes") + void runSignedJar(JavaRuntime javaRuntime) { + try (GenericContainer container = createContainer(javaRuntime, "spring-boot-loader-tests-signed-jar", + null)) { + container.start(); + System.out.println(this.output.toUtf8String()); + assertThat(this.output.toUtf8String()).contains("Legion of the Bouncy Castle"); + } + } + + @ParameterizedTest + @MethodSource("javaRuntimes") + void runSignedJarWhenUnpack(JavaRuntime javaRuntime) { + try (GenericContainer container = createContainer(javaRuntime, "spring-boot-loader-tests-signed-jar", + "unpack")) { + container.start(); + System.out.println(this.output.toUtf8String()); + assertThat(this.output.toUtf8String()).contains("Legion of the Bouncy Castle"); + } + } + + private GenericContainer createContainer(JavaRuntime javaRuntime, String name, String classifier) { return javaRuntime.getContainer() .withLogConsumer(this.output) - .withCopyFileToContainer(MountableFile.forHostPath(findApplication().toPath()), "/app.jar") + .withCopyFileToContainer(findApplication(name, classifier), "/app.jar") .withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofMinutes(5))) .withCommand("java", "-jar", "app.jar"); } - private File findApplication() { - String name = String.format("build/%1$s/build/libs/%1$s.jar", "spring-boot-loader-tests-app"); - File jar = new File(name); - Assert.state(jar.isFile(), () -> "Could not find " + name + ". Have you built it?"); + private MountableFile findApplication(String name, String classifier) { + return MountableFile.forHostPath(findJarFile(name, classifier).toPath()); + } + + private File findJarFile(String name, String classifier) { + classifier = (classifier != null) ? "-" + classifier : ""; + String path = String.format("build/%1$s/build/libs/%1$s%2$s.jar", name, classifier); + File jar = new File(path); + Assert.state(jar.isFile(), () -> "Could not find " + path + ". Have you built it?"); return jar; } static Stream javaRuntimes() { List javaRuntimes = new ArrayList<>(); javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.SEVENTEEN)); - javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.TWENTY_ONE)); + javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.TWENTY)); javaRuntimes.add(JavaRuntime.oracleJdk17()); + javaRuntimes.add(JavaRuntime.openJdkEarlyAccess(JavaVersion.TWENTY_ONE)); return javaRuntimes.stream().filter(JavaRuntime::isCompatible); } @@ -111,6 +141,13 @@ public String toString() { return this.name; } + static JavaRuntime openJdkEarlyAccess(JavaVersion version) { + String imageVersion = version.toString(); + DockerImageName image = DockerImageName.parse("openjdk:%s-ea-jdk".formatted(imageVersion)); + return new JavaRuntime("OpenJDK Early Access " + imageVersion, version, + () -> new GenericContainer<>(image)); + } + static JavaRuntime openJdk(JavaVersion version) { String imageVersion = version.toString(); DockerImageName image = DockerImageName.parse("bellsoft/liberica-openjdk-debian:" + imageVersion); diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/spring-boot-server-tests-app/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/spring-boot-server-tests-app/build.gradle index b72b482864ed..bd73d368e598 100644 --- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/spring-boot-server-tests-app/build.gradle +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/spring-boot-server-tests-app/build.gradle @@ -12,13 +12,13 @@ apply plugin: "io.spring.dependency-management" repositories { maven { url "file:${rootDir}/../test-repository"} mavenCentral() - maven { + maven { url "https://repo.spring.io/milestone" content { excludeGroup "org.springframework.boot" } } - maven { + maven { url "https://repo.spring.io/snapshot" content { excludeGroup "org.springframework.boot" @@ -41,15 +41,7 @@ configurations { } } -dependencyManagement { - jetty { - dependencies { - dependency "jakarta.servlet:jakarta.servlet-api:5.0.0" - } - } -} - -tasks.register("resourcesJar", Jar) { jar -> +tasks.register("resourcesJar", Jar) { jar -> def nested = project.resources.text.fromString("nested") from(nested) { into "META-INF/resources/" @@ -66,7 +58,7 @@ tasks.register("resourcesJar", Jar) { jar -> } dependencies { - compileOnly("org.eclipse.jetty:jetty-server") + compileOnly("org.eclipse.jetty.ee10:jetty-ee10-servlet") compileOnly("org.springframework:spring-web") implementation("org.springframework.boot:spring-boot-starter") @@ -84,7 +76,7 @@ def boolean isWindows() { } ["jetty", "tomcat", "undertow"].each { webServer -> - def configurer = { task -> + def configurer = { task -> task.dependsOn resourcesJar task.mainClass = "com.example.ResourceHandlingApplication" task.classpath = configurations.getByName(webServer) diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/EmbeddedServerContainerInvocationContextProvider.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/EmbeddedServerContainerInvocationContextProvider.java index 010f9e07f916..4ed867e3ee03 100644 --- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/EmbeddedServerContainerInvocationContextProvider.java +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/EmbeddedServerContainerInvocationContextProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,10 +155,7 @@ public boolean supportsParameter(ParameterContext parameterContext, ExtensionCon if (parameterContext.getParameter().getType().equals(AbstractApplicationLauncher.class)) { return true; } - if (parameterContext.getParameter().getType().equals(RestTemplate.class)) { - return true; - } - return false; + return parameterContext.getParameter().getType().equals(RestTemplate.class); } @Override diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java index dc8fb37a0cb9..b066ac9be081 100644 --- a/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-server-tests/src/intTest/java/org/springframework/boot/context/embedded/ExplodedApplicationLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-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. @@ -55,8 +55,8 @@ protected String getDescription(String packaging) { @Override protected List getArguments(File archive, File serverPortFile) { - String mainClass = (archive.getName().endsWith(".war") ? "org.springframework.boot.loader.WarLauncher" - : "org.springframework.boot.loader.JarLauncher"); + String mainClass = (archive.getName().endsWith(".war") ? "org.springframework.boot.loader.launch.WarLauncher" + : "org.springframework.boot.loader.launch.JarLauncher"); try { explodeArchive(archive); return Arrays.asList("-cp", this.exploded.getAbsolutePath(), mainClass, serverPortFile.getAbsolutePath()); diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/build.gradle index 5ad092f62cb1..963f63814ac7 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/build.gradle @@ -8,6 +8,7 @@ description = "Spring Boot Actuator ActiveMQ smoke test" dependencies { implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-activemq")) + testImplementation("org.awaitility:awaitility") testImplementation("org.testcontainers:junit-jupiter") testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) testImplementation(project(":spring-boot-project:spring-boot-testcontainers")) diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/src/test/java/smoketest/activemq/SampleActiveMqTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/src/test/java/smoketest/activemq/SampleActiveMqTests.java index 7637a28f8869..9d68eda78d2b 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/src/test/java/smoketest/activemq/SampleActiveMqTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-activemq/src/test/java/smoketest/activemq/SampleActiveMqTests.java @@ -16,6 +16,9 @@ package smoketest.activemq; +import java.time.Duration; + +import org.awaitility.Awaitility; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.testcontainers.junit.jupiter.Container; @@ -25,9 +28,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testsupport.testcontainers.ActiveMQContainer; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; import static org.assertj.core.api.Assertions.assertThat; @@ -43,21 +45,16 @@ class SampleActiveMqTests { @Container + @ServiceConnection private static final ActiveMQContainer container = new ActiveMQContainer(); - @DynamicPropertySource - static void activeMqProperties(DynamicPropertyRegistry registry) { - registry.add("spring.activemq.broker-url", container::getBrokerUrl); - } - @Autowired private Producer producer; @Test - void sendSimpleMessage(CapturedOutput output) throws InterruptedException { + void sendSimpleMessage(CapturedOutput output) { this.producer.send("Test message"); - Thread.sleep(1000L); - assertThat(output).contains("Test message"); + Awaitility.waitAtMost(Duration.ofMinutes(1)).untilAsserted(() -> assertThat(output).contains("Test message")); } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/application.properties index 2583f7fcefd9..d622bb1f7ca4 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/application.properties +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/application.properties @@ -1,3 +1,4 @@ +spring.application.name=sample spring.security.user.name=user spring.security.user.password=password management.endpoint.shutdown.enabled=true diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/log4j2.xml b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/log4j2.xml index 5320cd61c746..1c84d286b093 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/log4j2.xml +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-log4j2/src/main/resources/log4j2.xml @@ -2,7 +2,7 @@ ???? - %clr{%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}{faint} %clr{%5p} %clr{${sys:PID}}{magenta} %clr{---}{faint} %clr{[%15.15t]}{faint} %clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n%xwEx + %clr{%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}{faint} %clr{%5p} %clr{${sys:PID}}{magenta} %clr{---}{faint} %clr{${sys:LOGGED_APPLICATION_NAME:-}[%15.15t]}{faint} %clr{%-40.40c{1.}}{cyan} %clr{:}{faint} %m%n%xwEx diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/build.gradle index b616d3a9697a..c5157df03e02 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/build.gradle @@ -11,6 +11,7 @@ dependencies { implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-security")) implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web")) implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-validation")) + implementation("io.micrometer:micrometer-tracing-bridge-brave") runtimeOnly("com.h2database:h2") diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties index 81cc777bfc89..2c35d22ff033 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator/src/main/resources/application.properties @@ -1,3 +1,4 @@ +spring.application.name=sample service.name=Phil spring.security.user.name=user diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/SampleAmqpSimpleApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/SampleAmqpSimpleApplication.java index 410385cb72e6..356ce74c9d7f 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/SampleAmqpSimpleApplication.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/main/java/smoketest/amqp/SampleAmqpSimpleApplication.java @@ -16,7 +16,8 @@ package smoketest.amqp; -import java.util.Date; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.amqp.core.Queue; import org.springframework.amqp.rabbit.annotation.RabbitHandler; @@ -31,6 +32,8 @@ @RabbitListener(queues = "foo") public class SampleAmqpSimpleApplication { + private static final Log logger = LogFactory.getLog(SampleAmqpSimpleApplication.class); + @Bean public Sender mySender() { return new Sender(); @@ -43,7 +46,7 @@ public Queue fooQueue() { @RabbitHandler public void process(@Payload String foo) { - System.out.println(new Date() + ": " + foo); + logger.info(foo); } @Bean diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationSslTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationSslTests.java new file mode 100644 index 000000000000..0ccd87eee4b8 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SampleAmqpSimpleApplicationSslTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-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 smoketest.amqp; + +import java.time.Duration; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Smoke tests for RabbitMQ with SSL using an SSL bundle for SSL configuration. + * + * @author Scott Frederick + */ +@SpringBootTest(properties = { "spring.rabbitmq.ssl.bundle=client", + "spring.ssl.bundle.pem.client.keystore.certificate=classpath:ssl/test-client.crt", + "spring.ssl.bundle.pem.client.keystore.private-key=classpath:ssl/test-client.key", + "spring.ssl.bundle.pem.client.truststore.certificate=classpath:ssl/test-ca.crt" }) +@Testcontainers(disabledWithoutDocker = true) +@ExtendWith(OutputCaptureExtension.class) +class SampleAmqpSimpleApplicationSslTests { + + @Container + static final SecureRabbitMqContainer rabbit = new SecureRabbitMqContainer(); + + @DynamicPropertySource + static void secureRabbitMqProperties(DynamicPropertyRegistry registry) { + registry.add("spring.rabbitmq.host", rabbit::getHost); + registry.add("spring.rabbitmq.port", rabbit::getAmqpsPort); + registry.add("spring.rabbitmq.username", rabbit::getAdminUsername); + registry.add("spring.rabbitmq.password", rabbit::getAdminPassword); + } + + @Autowired + private Sender sender; + + @Test + void sendSimpleMessage(CapturedOutput output) { + this.sender.send("Test message"); + Awaitility.waitAtMost(Duration.ofMinutes(1)).untilAsserted(() -> assertThat(output).contains("Test message")); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SecureRabbitMqContainer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SecureRabbitMqContainer.java new file mode 100644 index 000000000000..1a8c8203cd06 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/java/smoketest/amqp/SecureRabbitMqContainer.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-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 smoketest.amqp; + +import java.time.Duration; + +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.utility.MountableFile; + +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; + +/** + * A {@link RabbitMQContainer} for RabbitMQ with SSL configuration. + * + * @author Scott Frederick + */ +class SecureRabbitMqContainer extends RabbitMQContainer { + + SecureRabbitMqContainer() { + super(DockerImageNames.rabbit()); + withStartupTimeout(Duration.ofMinutes(4)); + } + + @Override + public void configure() { + withCopyFileToContainer(MountableFile.forClasspathResource("/ssl/rabbitmq.conf"), + "/etc/rabbitmq/rabbitmq.conf"); + withCopyFileToContainer(MountableFile.forClasspathResource("/ssl/test-server.crt"), + "/etc/rabbitmq/server_cert.pem"); + withCopyFileToContainer(MountableFile.forClasspathResource("/ssl/test-server.key"), + "/etc/rabbitmq/server_key.pem"); + withCopyFileToContainer(MountableFile.forClasspathResource("/ssl/test-ca.crt"), "/etc/rabbitmq/ca_cert.pem"); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/rabbitmq.conf b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/rabbitmq.conf new file mode 100644 index 000000000000..3bcc1648bfc2 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/rabbitmq.conf @@ -0,0 +1,7 @@ +listeners.tcp = none +listeners.ssl.default = 5671 +ssl_options.certfile = /etc/rabbitmq/server_cert.pem +ssl_options.keyfile = /etc/rabbitmq/server_key.pem +ssl_options.cacertfile = /etc/rabbitmq/ca_cert.pem +ssl_options.verify = verify_peer +ssl_options.fail_if_no_peer_cert = true \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.crt b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.crt new file mode 100644 index 000000000000..c528ec820c91 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.crt @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFhjCCA26gAwIBAgIUERZP46qinK0dKmJzlCsoD/k1nWYwDQYJKoZIhvcNAQEL +BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm +aWNhdGUgQXV0aG9yaXR5MB4XDTIzMDUwMTIwNDkxMFoXDTMzMDQyODIwNDkxMFow +OzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlmaWNh +dGUgQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApWYo +UQjDY98oVOO5HOjheWeBN+C6gozg4aPY0VdRDTKmZ5SzNjuYtX6jsd8e5UF+ceeL +Aw9E3FAKG80F/81c6mtFhFUNUaBCbK2/+igs+Ae6r42i6iLImvgYLbZ0rGpPwszT +KGlwyobsI8n1bRFrVRdtGWVfn3Dfc5k/+dnZ03kOpViv/gd/xNWMcMOlj64F1s8L +6Nx9bfeJvOcsX+5qMiy/B6dZS0lkvXZISJbFhvX/+5Tb/vkP41AnrYff8hO8OBs+ +G2srr2xNAIcgNBSjedDVUaRO+a2WHdX/1fHOlNqz335XMo79FOqRWDCZET3YW36A +hqiSPPiDq8AA7hmVxnq7vxWo/qclaqVuk5Dxp+ZD7d8deSGehTPajeCZCDtNhw6C +jtlU8v/LdwMRhqZp5/fjDlOEkutFh6B/aMjq3ZPYQad4MtQixDifgEs4iwnIMoVS +Wqpn24qn0qddfP0Y00U1F79UuJ2cJpyqdjtMRvbdNv6udWhD0rtrjdLvGFDOryzD +W7xQD2NLWW0IC9YNuXR0FzrJFFqWBW+lfF1u1PdW7ITFtUhj8RcIZZgUS/w1Yh8/ +d6ja18UROEgiJ/Isgvl8sNTe2oNQK9HM6XtyEif5G5J7cv5FAH3si98My5h+rKq9 +AMGfQLtDOM+Ivg7D63iiuxB57Rq91xCsKCC2QNECAwEAAaOBgTB/MB0GA1UdDgQW +BBQuNq1dmybivJy6XnHIFBYqEfqtMDAfBgNVHSMEGDAWgBQuNq1dmybivJy6XnHI +FBYqEfqtMDAPBgNVHRMBAf8EBTADAQH/MCwGA1UdEQQlMCOCC2V4YW1wbGUuY29t +gglsb2NhbGhvc3SCCTEyNy4wLjAuMTANBgkqhkiG9w0BAQsFAAOCAgEAJFpeqQB9 +pJLn4idrp7M1lSCrBJu2tu2UsOVcKTGZ3uqgKgKa+bn0kw9yN1CMISLSOV9B1Zt5 +/+2U24/9iC1nGIzEpk2uQ2GwGaxFsy38gP6FF+UFltEvLbhfHWZ/j8gWQhTRQ/sT +TMd0L0CysmDswoEHcuNgdX+V4WVchPqdHTxp5qLM3GRas5JCuNcVi+vFEWCQsYRh +iTpsCEVfRsVJKUvPKVLR8PSEjSt8S+SQjIuTVWSmdG358uRVxpBzAzMwz9sQw4G6 +Rv3S4LaQpWXUyHVYM1OxQz0fhEug5qgSR75GTFwG1oVd5rdk7iK/J3WbRJZ9FcKx +ipZ3jdl5mmI6p87OjgQVtUInv8KK88AhJmypBXaHE64nn8+YUsh/ud6+Vr8vyMPK +TZJivCtVKoX+nd3Zb3qX2YGORKQmn4GPX551FCk1CFOa+qlGfXtfqV2Z9LEQmqx3 +ygqVnmSf34oTz04sSMdK7m3ULqLyv3RFJJ4F+VsHHAEdJYO+v/GdGz/0FA7ZZ4t+ +7r1qY7uK4NSMRBn+DGlUL9oVp26uss/Qvi1WTI0g9W1YImxYSlaR0tm9jZQckirm +KMLMDyGJFvHqR8LRa3DU6L5pU99LxZSHRxBAY6oexKSYWt7BSE1kwaL3Exjg/RG/ +ap5/GNJS1STNnbgq5TtWUbvZcXuhuBe8ClI= +-----END CERTIFICATE----- diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.key b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.key new file mode 100644 index 000000000000..54a007ea2120 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-ca.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKAIBAAKCAgEApWYoUQjDY98oVOO5HOjheWeBN+C6gozg4aPY0VdRDTKmZ5Sz +NjuYtX6jsd8e5UF+ceeLAw9E3FAKG80F/81c6mtFhFUNUaBCbK2/+igs+Ae6r42i +6iLImvgYLbZ0rGpPwszTKGlwyobsI8n1bRFrVRdtGWVfn3Dfc5k/+dnZ03kOpViv +/gd/xNWMcMOlj64F1s8L6Nx9bfeJvOcsX+5qMiy/B6dZS0lkvXZISJbFhvX/+5Tb +/vkP41AnrYff8hO8OBs+G2srr2xNAIcgNBSjedDVUaRO+a2WHdX/1fHOlNqz335X +Mo79FOqRWDCZET3YW36AhqiSPPiDq8AA7hmVxnq7vxWo/qclaqVuk5Dxp+ZD7d8d +eSGehTPajeCZCDtNhw6CjtlU8v/LdwMRhqZp5/fjDlOEkutFh6B/aMjq3ZPYQad4 +MtQixDifgEs4iwnIMoVSWqpn24qn0qddfP0Y00U1F79UuJ2cJpyqdjtMRvbdNv6u +dWhD0rtrjdLvGFDOryzDW7xQD2NLWW0IC9YNuXR0FzrJFFqWBW+lfF1u1PdW7ITF +tUhj8RcIZZgUS/w1Yh8/d6ja18UROEgiJ/Isgvl8sNTe2oNQK9HM6XtyEif5G5J7 +cv5FAH3si98My5h+rKq9AMGfQLtDOM+Ivg7D63iiuxB57Rq91xCsKCC2QNECAwEA +AQKCAgEAn3AdtxeyeiiZEVO/ku2uxEARYRMB120ELp6qGAqKuCU2Ia1HICVM7M/Z +7lG9z5NV12kzKMzkPVfulqQJf2+wfMzRY2I1h5Tr0yWeZP+rcaDJxgbLn9XN+Qzl +CdPTHo0QvCCEAHW7448yPMGnEu9yvsDpS0zcY68Dx8RX1nq5LtCIXL1kUYVbFhwg +2GbQxvMi79IAkgVR59px7SYPMZ56wkk+EJuySQ/Dy5skzMyCNroWe6cgduYR+ba/ +uNi8+PcrPg6MzRN/Ngg5JiQb1/h5Kak0qRGxi59YkQRELTF+SSGVuQBp//O0ZSBE +4XVfaC5szK3iKWyAI8QP8VUR0HPbWr8dum6HQn/tpbQ1AcX9ObWnUz6TgaoHax0w +3VrnHnsr1kKmTHtqbB0uEeB7/vc6D3IWNIaPnoFT01snyGYDIaWcRLhPWCp/Z3QG +e1tCEVNqxzb5mtsFri1rVSXsOT8169il1V3qP8Wu9M0C/pXM+9XEdZd6ZecgU+SS +MEBAl+qYTBfGS7lJDIjqS0V6/NMNBa0bW2Gg35PruriPMgDhoXiYp3NgN0cuf4KQ +KEinRSwvb2iqfzCevY7D2JRJcTcZ97a518lDd4URIZ+W7o7+8UBObcuns55kBCy1 +NbjkZe2yGBGOODa1gXPaAgG1IBLDmnVPSKPyuHLiS0X+KmC4IAECggEBANCdYFW3 +Nw93w4Olh8tOJA4z9BTsQi64V+q/WOIz5l9aBHXVdyiG7gqFWiK7XsofPvXzU8XA +jP5y4XArO28Bwn3Ipa7YpoOs4J9KF8Il9dDUfUPTcNKkogEGnH8QHVPXUX28othW +NZ9urvP+rSYjM4CUQtGG/RiiGPHssHgQoPvgPm4mrmMgKSm3mKdm5xkIYITccGag +3tmO35cPzBBVap1tDmJ3F8dCMW8OsTKv6ECIjuMSYDbpmSNkxPxBK5YiIEJ8jjdU +5+7Bf3PLIoQNd+LWoSRzHm114QGFoTLq2wPE9TFoc9j+svZBAmDkCzTE9+KwIL+G +6dPcvvtT+NiTFgECggEBAMr32v6NgL8aGKK8nBiyibInUjKl0iCE1FcwGR6NOkK0 +3nJKhXiOWkBM3yeK/rq7HXfds6+pfi3w4VCmHXvF4IY5IIu8P4d0g/sMrFexwq2x +Qs400aomAVtlTQ46iL2vw5XOwMTw1SXvaNX/AgR0b9qiI1UfFZeox9UiHR+KdWPV +rKYDbHIHOk4Nxe950cK08KOReV3kO15RvBf6bdUAJwGWIdKLUr0y858s4H5GUZK8 +qKuC/toCE7Emy0k+q+NV/CApchhzQ5gwhVdc8qdhKlJtZDouopAOjOOq6l9C3GFT +qX7CVJppe7YbURni4Y7dXZzi2hn8wb7nSxmQq95FStECggEAY6/gefVMHVsYlY8D +HfagKh1PdLQVSCgU8vsu6SDt5ACrAvfXsgkQNPzWPqSUvjdCKdt125iQh4K0EZrH +EtufaeX4rl2e7GsvB08rnT3wgjMYDNI8Jpw/Qgg7vkggC5FnwpLiqkg/5YjJl5TK +ft/xW279owxDY4MKMojtJuKjWtkkXBSl3n5ezS2Lh+sXYZHsNXD1UUVsWD/6vj/x +Ppjikomrhwfr1+7cmnpF2LfQXw4iYYXFblggMpaTvwsRXfO+wKaueuha0G+sjNO0 +EbAx6ravWDCeiKX8uHJ3vlIWCG4U0OBeA4JqWFxmW5B9fmDlJ3EMpRk+IVxp8sWE +s1FOAQKCAQB4UlSloLcZEtxV5N/YmEaesUa+NaUKmBPVF/NcNDa8gsJ4GItlO2Zv +ReLoazK0+eXvQCOcWCswCuNXTxKdZGHE0CrmC5PRthXjhtDIL94L39CNs6wzZNJb +HwN+Et8rK/4TWfzXAzoogfOxILpOb8Q7ZPDzLjk7rdfBFrcTEp6ir3Ho/JCWTIiY +6vtTCvF5rpAVN1EugvVa5bNOt6vSoIN/IkQsr2E+Pe1EiHMRCJilF2gaPM7d6GtK +EohihF+bpkaPvmIf8ny4xNLXRoenCCfxs12+TBUctzN4Z8MG8/j3TYRmW8eRvkST +YUBDy0cRzVMIhUbsLvWgOTdBEY2Bd6xxAoIBACQhVhwLXDUSGe96p8QCPQ2SMo8/ +lU4oPQ8MIc/gYEJUUYvJfkvCy0fnot9P/ZPppksJPQidqZDhDmzbPxuaIwiel6RU +KTEwRbg7M8YtCngAGjUSxTWZp1sklFFXxbtDW438QzLAtMvGCZ1l0QEd6ajG1BHi +fm96oJqaKEhcg4tthz3NyXihvQ7/ZrLpvcyR25Dzjlx3X6/0DTT4hdUiQOW5a3Uo +/YjAC2J8MeKJK6UYW2spcmQ5NmVhG/+8UoGN94DWRWpgl2dtB2HGssLPmB27TOdQ +wezcsubDEHZCtTc2y22l/MMwCwLZu5GBUNUy4EzDjPxoC7FtHSdsJ9sUdsg= +-----END RSA PRIVATE KEY----- diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.crt b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.crt new file mode 100644 index 000000000000..40a184bdf322 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEGjCCAgKgAwIBAgIUCNvMLf/1EZcO6R9L/PQVWN8agbkwDQYJKoZIhvcNAQEL +BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm +aWNhdGUgQXV0aG9yaXR5MB4XDTIzMDUwMTIwNDkxMFoXDTI0MDQzMDIwNDkxMFow +LzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDESMBAGA1UEAwwJbG9jYWxob3N0 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzP5NGFAhk6hAVr3YshRJ +YGxS2IGphFaq/c99QZQ62JbcSwceFo0p8Px9JiaT38n7NejEy6t6U0PQP2B9r3pJ +p0RwplvITLd1lp96DdMQeGXKa2rqJ62u9//u/XxFboVU6QYC90Pnqi6sRWejKEI8 +Yowg6erjNMCQiIAKqhWPfdsJOxf79102gdahuTT8A89p551u7a84oTRtX4fLksP2 +x0BVFb0/Dirz5ngwm6YHpN+8z7BYIyj4dLzzFjaqU1gptxtGygap1GtD1X9fJ61l +k6K8vMww4+/zYOoGratUTNeKHOvvXf9SnjoqyMTvJFyTX+5snkyL81q3+XgXJOYL +ZQIDAQABoyIwIDALBgNVHQ8EBAMCBaAwEQYJYIZIAYb4QgEBBAQDAgeAMA0GCSqG +SIb3DQEBCwUAA4ICAQAq5Em7EVkGhPgIMDmxhm398Kv8OivFxX6x5aGnJ+m8+mZV ++wrkjRvpqN/+CtTsid2q4+qYdlov8hJ2oxwVhfnrF5b7Xj7caC2FJifPXPiaMogT +5VI4uCABBuVQR0kDtnPF8bRiTWCKC3DC84GqMp0cUs3Qyf1dLcjhcc9dSROn00y8 +/qmIz8roJ2esnqG12rTGdIAaWSgBCMKFjrV8YmxLf+z72VHSx6uC5CARG+UYa5Mu +vga0Q77QmwSstKBvGUBtvzQoML3/UFCikdfOxDgvJbr8Q0yEEw8hK7vGZLaj00zB +U4B5+DfV285RW09ihp2YMxuz3mL2tM5++RYJphB9/VTN3/f+geKt2pPA3Rkk11Ug +LP3NdpT5ZnQL9ehtmIExk2NVBi+RmGCcP7KcMtlq44FdyRF7p6qdg/Eq5n/sOMxQ +DnamgWDQltm6cuZ49haCXLZIbfqM2cHARIw/Sv3Dgd9SSDL2pooWI2U82fQ9A71q +u/hUlNDZm0v51IfgzJcbAtlAYd2OVlgCkkkFtbgdOaQUShIkcCKcpxtgQzpynNMO +DJoO41VXpMzBN7/ppVi0JrF7RkaXGeoNsqfvcmjQEuXUOluge2q8kHDf7gEUddKa +ijPHtkFQF2ujCGr/AVYjCMSlOk5WhRh8ZVxN0KbiWZJUN8akX4gU4KIpTe1big== +-----END CERTIFICATE----- diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.key b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.key new file mode 100644 index 000000000000..a31717ac4d53 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-client.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAzP5NGFAhk6hAVr3YshRJYGxS2IGphFaq/c99QZQ62JbcSwce +Fo0p8Px9JiaT38n7NejEy6t6U0PQP2B9r3pJp0RwplvITLd1lp96DdMQeGXKa2rq +J62u9//u/XxFboVU6QYC90Pnqi6sRWejKEI8Yowg6erjNMCQiIAKqhWPfdsJOxf7 +9102gdahuTT8A89p551u7a84oTRtX4fLksP2x0BVFb0/Dirz5ngwm6YHpN+8z7BY +Iyj4dLzzFjaqU1gptxtGygap1GtD1X9fJ61lk6K8vMww4+/zYOoGratUTNeKHOvv +Xf9SnjoqyMTvJFyTX+5snkyL81q3+XgXJOYLZQIDAQABAoIBAFNG/Arkgr95mqmi +dmXh1+1UFFPgWP1qOAzkPf5mOYHDx7qzKYX/0woTiMP26BwB8gv0g/45q3goFHGq +wWSISWOqahkrMDP6U8rc/rifBhHjSFhbFsUHygz17CEOWyaLA/OmfY32CCcazuFj +OOUiA2YFh1mAEs1bbVwGqE5wc9qsZtBlJxudSWtSZoJuFECDNqLfQXkJ39KnKhp4 +D337nOR/xww81202mlfF/vvhRMfUIUS2Ij9USndp9huBHFSxf1mYjD1ljjx6U7el +new8TPf76J7nuy/6SxZ9wF6P2dk/eQcN5AnIcDGq0WzS3VcJc/KG/+maflCvH0dB +SLfx4AECgYEA7e+5/UhWZ62BfF1/Nat95+t+bh8UYN8gPEUos7oS/cUrme7YAPQT +MTWNulpmgGCRDxeXU9XBaPGyF7cU5bx28sK64ZUe8D1ySgGpVeSEQtjCLFEf6eat +801TQVNaH2WlDZTm+Onfr7ppFN1pLrBY+83m9TDJd6v4qHsvtNkcx38CgYEA3I5U +OvvoTEj8+Xc0U296NU+aWJLNrkDH6lFtdXsLyoumxh0DDbKSw8ia28Z5+8tz0mdB +33sIsnnsQ+83YoiXyopM9GFZdZH3luKrXgOGH8QFygJI8xGqqcLjeWNkW0b0KCkv +AoiedqOOmCdRMUfy3v5irH+4O90ZmW6VxNKbfxsCgYEAtjjFOQwAWHCR3TwBo4nN +6CL7dbzJr5LSLjZNAK/9wWoShVZdCQXj+OjpvRFktOa/0U4g7+yhrgyEdxMYpwUa +F7s4wnCg/B4i/Difhg93l3ZH5wbOKSUojU/n9fyu5aLDsE4cQf9i90MNHRSgbEhU +Law4OAmAEe2bhvSoyZkJKGMCgYBgW25BNr0OVvTuqD2cFh/2Goj8GWbysiqlHF4N +7WwBWXHLK/Ghklq8XnAJhHTWpNQ9IA+Pa1kpYErwgxpXWgW23yUvvzguPU9GBFGK +CVAXoLRGxSjJyPYepJ5s8hduKVmSEiwPl1Bj1KD/qG24cg6RjeHeKw56WOZOOhoE +m16D8QKBgBHXU31OJ2KMDnwjsMW2SlpYKoIQlJyTg3qvN7gu+ZGo5B7hviqh5wN1 +y577N/NT9No8qGNEGTZl35hkyw8DmB4RAZp7G1qbVCGszUBt/vS6Guv82/EgMVo2 +ZgiQBkI1kEOtj5LMVBfOKTRBEpyAm5fSZ+eQtSIc5LCbQ8aEvio4 +-----END RSA PRIVATE KEY----- diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.crt b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.crt new file mode 100644 index 000000000000..06c8906b9157 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEGjCCAgKgAwIBAgIUCNvMLf/1EZcO6R9L/PQVWN8agbgwDQYJKoZIhvcNAQEL +BQAwOzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDEeMBwGA1UEAwwVQ2VydGlm +aWNhdGUgQXV0aG9yaXR5MB4XDTIzMDUwMTIwNDkxMFoXDTI0MDQzMDIwNDkxMFow +LzEZMBcGA1UECgwQU3ByaW5nIEJvb3QgVGVzdDESMBAGA1UEAwwJbG9jYWxob3N0 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqgji6StU0UkWfYmZumQO +L7SnFg7/xBM5ubMtXJsBOS0RaRWJ0WwIsQ2ksDOf2ybDyXiePplbtR/4GsnXPyNp +H1vgY/Mt/PeiP/lHw9dDTdSx6YMMxGVoILsHkblaeHwh8yVGCg2gdoRROscgjS+e +j7gTr4H2UBlepHsjZBKc+hamDrIC3vK42iQUyzbClJ8lpY+KbL4R4KhsuhTb2jRL +wye3m2w+YU1jvE+IioQfozlZTAw0SX7whcCw0B3hLVQg6hsdSeSYkCUZiZY72ySR +fI+mDcnJVcetH2ShK1zVFBpDs9qkJSA9YumO1ZKVDdDseeuHHsEUG2/pszQ2cHH6 +EwIDAQABoyIwIDALBgNVHQ8EBAMCBaAwEQYJYIZIAYb4QgEBBAQDAgZAMA0GCSqG +SIb3DQEBCwUAA4ICAQACFGGWNTEDCvkfEuZZT84zT8JQ9O5wDzgYDX/xRSXbB1Jv +fd9QQfwlVFXg3jewIgWZG0TgQt/7yF6RYOtU+GRP6meJhSm9/11KnYYLNlHQU1QE +7imreHAnsJiueHXPmpe9EL4jv2mQt7GSccABMf1pfBQ+C0dETnUoH68oO3LttU16 +f43H1royvOm3G6LnJb83rLYVe07P1PTjk/37gaFCf54J1eDfqntVDiSq8H6fV+nL +9ZvsVuC4BcREnB3oY7vsJFBhGeK/3+QFX4Zr3DTwLxiWe2pqSQfUbn4+d6+uwIY7 +pixgNorpebKQn0vX/G4llVjOmBNjlgSzDyVTYObBz316GojF7yRk3oBbxK//3w/t +XVhLwrPpqB5Jehh2HsKKZrdfnjB1Gn+pDpSEMVDrCbWxzAJz4WOu2ihCYYsF3Gts +lzI1ZzD+UpFyeHG/1wQHzyQwADBiaYfh1oAnpNcOvJhT1S6IVGImcOBNa8u14aVG +NjvnJWVn3v3dcvAVO1ZUwX9TdHP11oIpn7fGYZzSxCDrhGaFeW0tscxddHRrXdwk +IHyHZ3o2RgivhaSc4C04nuZEX00ohTgtKo2rpK1SP+gn64Yh+u+O6AH8r+q7cZy2 +gZNscwHAmkEalP78D5vnOFRUYEVrNc/X2f+rwFoQD7B8GNGa/visAkD7myg7JQ== +-----END CERTIFICATE----- diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.key b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.key new file mode 100644 index 000000000000..8dcb542a2ebf --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-amqp/src/test/resources/ssl/test-server.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAqgji6StU0UkWfYmZumQOL7SnFg7/xBM5ubMtXJsBOS0RaRWJ +0WwIsQ2ksDOf2ybDyXiePplbtR/4GsnXPyNpH1vgY/Mt/PeiP/lHw9dDTdSx6YMM +xGVoILsHkblaeHwh8yVGCg2gdoRROscgjS+ej7gTr4H2UBlepHsjZBKc+hamDrIC +3vK42iQUyzbClJ8lpY+KbL4R4KhsuhTb2jRLwye3m2w+YU1jvE+IioQfozlZTAw0 +SX7whcCw0B3hLVQg6hsdSeSYkCUZiZY72ySRfI+mDcnJVcetH2ShK1zVFBpDs9qk +JSA9YumO1ZKVDdDseeuHHsEUG2/pszQ2cHH6EwIDAQABAoIBAQCLTuiJ3OSK63Sv +udLncR5mW34hhnxqas36pSBfJOflrlT7YZgeqoKcfO8XJdSsup/iKx6Lbx5B0UV2 +vTPLGPoBpUa83PoqrcCS5Wu0umL8G20AQkxthB/B4TocXF4RJLK0AS/XAL8dGt9q +Zsb2pbMlUM1gF/x0N7Tg0bp3PQC7rAgYe7JFvArxRrmDP38FE9Cg5EIAVMN8Fw2b +dxKZxJ+mqj1t1bU4/bsrYBs9QpNrBjQc0KTFOamwkvWI7FhHXQtIZfJvvBj8mN7z +He7B5j/JcfGC5LN1UpL4tziOrKwMGGIvpAnpbVEv29SWxOG5Vbccb4ghBN+VJqSH +6WON791hAoGBAN7Q5nuCk+L/8BQh29WHZuP6dbLyMMjWMyuDm2xEYD0fjjacvU7r +KIQDcQY3E7bXu6OXKQmxARFY7HuZUyGg8R4QBeAEVfDPjRKzGZgA1+gF325eQwAQ +giXqg0paE2ePfbawi21NfQPCMMhb4n3QzpYd4eEsFFwMvt4oZCPkHubJAoGBAMNb +pGajPKW19dFWP5OsKc1U6itej78RQRjO7zpQ3JWvNuMa/SZzEa2blFuks585u6M2 +XdVPhhspc0TwS+asizNEMDYaPpAjmg9X9LY87hcYTC0FXT0Axx+7A/JtmMAVF3Pn +4lvhfdB5XSV5jo/BtUJ3vDx5FSFIHQbbj1agGpv7AoGAdv6pmJyLzldRJ+9NMCQ3 +1tkTspWVaCy89yg6AQAjRYFsuc3LbDI6WQZdfiw74xIjq6I20G4vW8xZv0iLFRKW +sq9r889c9lZhyPLNYFhS9h7szEybC5XFa+pqY3Lnmg8P3Fk8nQsdELzMwLQRqY+y +RImA8HhSBzbnWE3J7UEPH8ECgYAXyNGEOX2Jw1SRTwnghcZ1HFCCRToFDim5xn/z +vqKMis+I6OFHTB0r4NQ4MB46VYIVxem4rbzrE6nYC9WB2SH9dODVxW42iE8abR/7 +DAIEx82Gca+/XJfhshgx7Mv7HtZDI0k43IQ/3HbNuDX2JKRX2lINnsRG0AvQqOyT +pFx4/wKBgQCXU0LGSCgNwuqdhXHoaFEzAzzspDjCI+9KDuchkvoYWfCWElX035O9 +TbEybMjCuv08eAqeJv++a1jnTmJwf+w+WhBG+DpYcro1JXmo8Lu9KAbiq0lJGQP6 +tX9gr0XY3IC+L5ndOANuFH6mjGlnp7Z+J8i7HFFoSa+MI2JkoQ5yVA== +-----END RSA PRIVATE KEY----- diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.gradle index 7ff1740185fb..7f90497a4ff2 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.gradle @@ -30,7 +30,7 @@ dependencies { antDependencies "org.apache.ant:ant-launcher:1.10.7" antDependencies "org.apache.ant:ant:1.10.7" - testRepository(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-loader", configuration: "mavenRepository")) + testRepository(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-loader-classic", configuration: "mavenRepository")) testRepository(project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter", configuration: "mavenRepository")) testImplementation(project(path: ":spring-boot-project:spring-boot-tools:spring-boot-loader-tools")) diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.xml b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.xml index 418a7501f05f..a03067231cef 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.xml +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/build.xml @@ -65,9 +65,9 @@ - + - + diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/ivy.xml b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/ivy.xml index 192d5281fcda..2ecb5cc31a2b 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/ivy.xml +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-ant/ivy.xml @@ -7,6 +7,6 @@ - + diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationReactiveSslTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationReactiveSslTests.java index 2362aa4e55d7..c8bfd7bdadb9 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationReactiveSslTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationReactiveSslTests.java @@ -80,7 +80,7 @@ static class KeyspaceTestConfiguration { CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) { try (CqlSession session = cqlSessionBuilder.build()) { session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test" - + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); + + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); } return cqlSessionBuilder.withKeyspace("boot_test").build(); } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationSslTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationSslTests.java index 46dd49437a7f..48080633ba60 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationSslTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-cassandra/src/test/java/smoketest/data/cassandra/SampleCassandraApplicationSslTests.java @@ -80,7 +80,7 @@ static class KeyspaceTestConfiguration { CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder) { try (CqlSession session = cqlSessionBuilder.build()) { session.execute("CREATE KEYSPACE IF NOT EXISTS boot_test" - + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); + + " WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); } return cqlSessionBuilder.withKeyspace("boot_test").build(); } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/build.gradle index e66d4db03d24..fba550646efa 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/build.gradle @@ -13,9 +13,9 @@ dependencies { testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) testImplementation(project(":spring-boot-project:spring-boot-testcontainers")) - testImplementation("com.squareup.okhttp3:okhttp") testImplementation("io.projectreactor:reactor-core") testImplementation("io.projectreactor:reactor-test") + testImplementation("org.apache.httpcomponents.client5:httpclient5") testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.platform:junit-platform-engine") testImplementation("org.junit.platform:junit-platform-launcher") diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/src/test/java/smoketest/data/couchbase/SecureCouchbaseContainer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/src/test/java/smoketest/data/couchbase/SecureCouchbaseContainer.java index 1b40442edc74..70141b45403f 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/src/test/java/smoketest/data/couchbase/SecureCouchbaseContainer.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-couchbase/src/test/java/smoketest/data/couchbase/SecureCouchbaseContainer.java @@ -17,13 +17,14 @@ package smoketest.data.couchbase; import java.time.Duration; +import java.util.Base64; import com.github.dockerjava.api.command.InspectContainerResponse; -import okhttp3.Credentials; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; import org.testcontainers.couchbase.CouchbaseContainer; import org.testcontainers.utility.MountableFile; @@ -33,6 +34,7 @@ * A {@link CouchbaseContainer} for Couchbase with SSL configuration. * * @author Scott Frederick + * @author Stephane Nicoll */ public class SecureCouchbaseContainer extends CouchbaseContainer { @@ -69,20 +71,26 @@ protected void containerIsStarting(InspectContainerResponse containerInfo) { } private void doHttpRequest(String path) { - Response response; - try { + HttpResponse response = post(path); + if (response.getCode() != 200) { + throw new IllegalStateException("Error calling Couchbase HTTP endpoint: " + response); + } + } + + private HttpResponse post(String path) { + try (CloseableHttpClient httpclient = HttpClients.createDefault()) { + String basicAuth = "Basic " + + Base64.getEncoder().encodeToString("%s:%s".formatted(ADMIN_USER, ADMIN_PASSWORD).getBytes()); String url = "http://%s:%d/%s".formatted(getHost(), getMappedPort(MANAGEMENT_PORT), path); - Request.Builder requestBuilder = new Request.Builder().url(url) - .header("Authorization", Credentials.basic(ADMIN_USER, ADMIN_PASSWORD)) - .post(RequestBody.create("".getBytes())); - response = new OkHttpClient().newCall(requestBuilder.build()).execute(); + ClassicHttpRequest httpPost = ClassicRequestBuilder.post(url) + .addHeader("Authorization", basicAuth) + .setEntity("") + .build(); + return httpclient.execute(httpPost, (response) -> response); } catch (Exception ex) { throw new IllegalStateException("Error calling Couchbase HTTP endpoint", ex); } - if (!response.isSuccessful()) { - throw new IllegalStateException("Error calling Couchbase HTTP endpoint: " + response); - } } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-r2dbc-liquibase/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-r2dbc-liquibase/build.gradle index 376dda13dd7a..bd8a4c685dad 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-r2dbc-liquibase/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-data-r2dbc-liquibase/build.gradle @@ -9,7 +9,6 @@ dependencies { implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-data-r2dbc")) runtimeOnly("org.liquibase:liquibase-core") { - exclude group: "javax.activation", module: "javax.activation-api" exclude group: "javax.xml.bind", module: "jaxb-api" } runtimeOnly("org.postgresql:postgresql") diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/build.gradle index 140f0a3c9a0c..d740445850ed 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/build.gradle @@ -11,10 +11,6 @@ configurations { } } -configurations.all { - resolutionStrategy.force("jakarta.servlet:jakarta.servlet-api:5.0.0") -} - dependencies { compileOnly(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jetty")) @@ -22,10 +18,7 @@ dependencies { exclude module: "spring-boot-starter-tomcat" } - providedRuntime("org.eclipse.jetty:apache-jsp") { - exclude group: "org.eclipse.jetty.toolchain", module: "jetty-jakarta-servlet-api" - exclude group: "org.eclipse.jetty.toolchain", module: "jetty-schemas" - } + providedRuntime("org.eclipse.jetty.ee10:jetty-ee10-apache-jsp") runtimeOnly("org.glassfish.web:jakarta.servlet.jsp.jstl") diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/src/main/resources/application.properties index f18efd166420..b3b89e953ed8 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/src/main/resources/application.properties +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-jsp/src/main/resources/application.properties @@ -1,3 +1,4 @@ +application.message: Hello Spring Boot +server.servlet.jsp.class-name=org.eclipse.jetty.ee10.jsp.JettyJspServlet spring.mvc.view.prefix: /WEB-INF/jsp/ spring.mvc.view.suffix: .jsp -application.message: Hello Spring Boot diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-ssl/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-ssl/build.gradle index 8333e2118933..72c93ab6c314 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-ssl/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty-ssl/build.gradle @@ -5,10 +5,6 @@ plugins { description = "Spring Boot Jetty SSL smoke test" -configurations.all { - resolutionStrategy.force("jakarta.servlet:jakarta.servlet-api:5.0.0") -} - dependencies { implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jetty")) implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web")) { diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/build.gradle index 94173aa1ba18..b204e4a35e31 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/build.gradle @@ -5,10 +5,6 @@ plugins { description = "Spring Boot Jetty smoke test" -configurations.all { - resolutionStrategy.force("jakarta.servlet:jakarta.servlet-api:5.0.0") -} - dependencies { implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web")) { exclude module: "spring-boot-starter-tomcat" diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/main/resources/application.properties index 877098676857..0bb34b330311 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/main/resources/application.properties +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/main/resources/application.properties @@ -2,4 +2,4 @@ server.compression.enabled: true server.compression.min-response-size: 1 server.max-http-request-header-size=1000 server.jetty.threads.acceptors=2 -server.jetty.max-http-response-header-size=1000 +server.jetty.max-http-response-header-size=4096 diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/test/java/smoketest/jetty/SampleJettyApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/test/java/smoketest/jetty/SampleJettyApplicationTests.java index d1c970ad5707..0c9a5eef7169 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/test/java/smoketest/jetty/SampleJettyApplicationTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-jetty/src/test/java/smoketest/jetty/SampleJettyApplicationTests.java @@ -16,10 +16,6 @@ package smoketest.jetty; -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; -import java.util.zip.GZIPInputStream; - import org.junit.jupiter.api.Test; import smoketest.jetty.util.StringUtil; @@ -33,7 +29,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.util.StreamUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -46,7 +41,7 @@ * @author Michael Weidmann * @author Moritz Halbritter */ -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = "logging.level.org.eclipse:trace") class SampleJettyApplicationTests { @Autowired @@ -63,16 +58,13 @@ void testHome() { } @Test - void testCompression() throws Exception { - HttpHeaders requestHeaders = new HttpHeaders(); - requestHeaders.set("Accept-Encoding", "gzip"); - HttpEntity requestEntity = new HttpEntity<>(requestHeaders); - ResponseEntity entity = this.restTemplate.exchange("/", HttpMethod.GET, requestEntity, byte[].class); + void testCompression() { + // Jetty HttpClient sends Accept-Encoding: gzip by default + ResponseEntity entity = this.restTemplate.getForEntity("/", String.class); assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(entity.getBody()).isNotNull(); - try (GZIPInputStream inflater = new GZIPInputStream(new ByteArrayInputStream(entity.getBody()))) { - assertThat(StreamUtils.copyToString(inflater, StandardCharsets.UTF_8)).isEqualTo("Hello World"); - } + assertThat(entity.getBody()).isEqualTo("Hello World"); + // Jetty HttpClient decodes gzip responses automatically and removes the + // Content-Encoding header. We have to assume that the response was gzipped. } @Test diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/build.gradle index 33a82ff144bd..24d8f33d79d1 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/build.gradle @@ -10,6 +10,11 @@ dependencies { implementation("org.springframework.kafka:spring-kafka") testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) + testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) testImplementation("org.awaitility:awaitility") - testImplementation("org.springframework.kafka:spring-kafka-test") + testImplementation("org.springframework.kafka:spring-kafka-test") { + exclude group: "commons-logging", module: "commons-logging" + } + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:kafka") } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/Consumer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/Consumer.java index 4beb1a980ff1..c388e2ac70ff 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/Consumer.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/Consumer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-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. @@ -23,7 +23,7 @@ import org.springframework.stereotype.Component; @Component -class Consumer { +public class Consumer { private final List messages = new CopyOnWriteArrayList<>(); @@ -33,7 +33,7 @@ void processMessage(SampleMessage message) { System.out.println("Received sample message [" + message + "]"); } - List getMessages() { + public List getMessages() { return this.messages; } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/ssl/SampleKafkaSslApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/ssl/SampleKafkaSslApplication.java new file mode 100644 index 000000000000..30b8c8ad2e30 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/main/java/smoketest/kafka/ssl/SampleKafkaSslApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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 smoketest.kafka.ssl; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleKafkaSslApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleKafkaSslApplication.class, args); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/java/smoketest/kafka/ssl/SampleKafkaSslApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/java/smoketest/kafka/ssl/SampleKafkaSslApplicationTests.java new file mode 100644 index 000000000000..433c9e0a0f9b --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/java/smoketest/kafka/ssl/SampleKafkaSslApplicationTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-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 smoketest.kafka.ssl; + +import java.time.Duration; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; +import smoketest.kafka.Consumer; +import smoketest.kafka.Producer; +import smoketest.kafka.SampleMessage; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.not; + +/** + * Smoke tests for Apache Kafka with SSL. + * + * @author Scott Frederick + * @author Eddú Meléndez + */ +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest(classes = { SampleKafkaSslApplication.class, Producer.class, Consumer.class }, + properties = { "spring.kafka.security.protocol=SSL", + "spring.kafka.properties.ssl.endpoint.identification.algorithm=", "spring.kafka.ssl.bundle=client", + "spring.ssl.bundle.jks.client.keystore.location=classpath:ssl/test-client.p12", + "spring.ssl.bundle.jks.client.keystore.password=password", + "spring.ssl.bundle.jks.client.truststore.location=classpath:ssl/test-ca.p12", + "spring.ssl.bundle.jks.client.truststore.password=password" }) +class SampleKafkaSslApplicationTests { + + @Container + public static KafkaContainer kafka = new KafkaContainer(DockerImageNames.kafka()) + .withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SSL,BROKER:PLAINTEXT") + .withEnv("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true") + .withEnv("KAFKA_SSL_CLIENT_AUTH", "required") + .withEnv("KAFKA_SSL_KEYSTORE_LOCATION", "/etc/kafka/secrets/certs/test-server.p12") + .withEnv("KAFKA_SSL_KEYSTORE_PASSWORD", "password") + .withEnv("KAFKA_SSL_KEY_PASSWORD", "password") + .withEnv("KAFKA_SSL_TRUSTSTORE_LOCATION", "/etc/kafka/secrets/certs/test-ca.p12") + .withEnv("KAFKA_SSL_TRUSTSTORE_PASSWORD", "password") + .withEnv("KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM", "") + .withCopyFileToContainer(MountableFile.forClasspathResource("ssl/test-server.p12"), + "/etc/kafka/secrets/certs/test-server.p12") + .withCopyFileToContainer(MountableFile.forClasspathResource("ssl/credentials"), + "/etc/kafka/secrets/certs/credentials") + .withCopyFileToContainer(MountableFile.forClasspathResource("ssl/test-ca.p12"), + "/etc/kafka/secrets/certs/test-ca.p12"); + + @DynamicPropertySource + static void kafkaProperties(DynamicPropertyRegistry registry) { + registry.add("spring.kafka.bootstrap-servers", + () -> String.format("%s:%s", kafka.getHost(), kafka.getMappedPort(9093))); + } + + @Autowired + private Producer producer; + + @Autowired + private Consumer consumer; + + @Test + void testVanillaExchange() { + this.producer.send(new SampleMessage(1, "A simple test message")); + + Awaitility.waitAtMost(Duration.ofSeconds(30)).until(this.consumer::getMessages, not(empty())); + assertThat(this.consumer.getMessages()).extracting("message").containsOnly("A simple test message"); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/credentials b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/credentials new file mode 100644 index 000000000000..7aa311adf93f --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/credentials @@ -0,0 +1 @@ +password \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-ca.p12 b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-ca.p12 new file mode 100644 index 000000000000..fd0a5d99b0c0 Binary files /dev/null and b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-ca.p12 differ diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-client.p12 b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-client.p12 new file mode 100644 index 000000000000..d2fd1d0f3228 Binary files /dev/null and b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-client.p12 differ diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-server.p12 b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-server.p12 new file mode 100644 index 000000000000..5f1bd89eccfc Binary files /dev/null and b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-kafka/src/test/resources/ssl/test-server.p12 differ diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/build.gradle index 1126e59d0b42..292368536ed1 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/build.gradle @@ -11,7 +11,6 @@ dependencies { implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web")) implementation("jakarta.xml.bind:jakarta.xml.bind-api") implementation("org.liquibase:liquibase-core") { - exclude group: "javax.activation", module: "javax.activation-api" exclude group: "javax.xml.bind", module: "jaxb-api" } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/src/test/java/smoketest/liquibase/SampleLiquibaseApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/src/test/java/smoketest/liquibase/SampleLiquibaseApplicationTests.java index abcf860f8428..72038526d3c2 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/src/test/java/smoketest/liquibase/SampleLiquibaseApplicationTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-liquibase/src/test/java/smoketest/liquibase/SampleLiquibaseApplicationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,9 +69,7 @@ private boolean serverNotRunning(IllegalStateException ex) { }; if (nested.contains(ConnectException.class)) { Throwable root = nested.getRootCause(); - if (root.getMessage().contains("Connection refused")) { - return true; - } + return root.getMessage().contains("Connection refused"); } return false; } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java index 467868aa3ebb..b0695ea68892 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-oauth2-authorization-server/src/test/java/smoketest/oauth2/server/SampleOAuth2AuthorizationServerApplicationTests.java @@ -63,14 +63,14 @@ void openidConfigurationShouldAllowAccess() { assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); OidcProviderConfiguration config = OidcProviderConfiguration.withClaims(entity.getBody()).build(); - assertThat(config.getIssuer().toString()).isEqualTo("https://provider.com"); - assertThat(config.getAuthorizationEndpoint().toString()).isEqualTo("https://provider.com/authorize"); - assertThat(config.getTokenEndpoint().toString()).isEqualTo("https://provider.com/token"); - assertThat(config.getJwkSetUrl().toString()).isEqualTo("https://provider.com/jwks"); - assertThat(config.getTokenRevocationEndpoint().toString()).isEqualTo("https://provider.com/revoke"); - assertThat(config.getEndSessionEndpoint().toString()).isEqualTo("https://provider.com/logout"); - assertThat(config.getTokenIntrospectionEndpoint().toString()).isEqualTo("https://provider.com/introspect"); - assertThat(config.getUserInfoEndpoint().toString()).isEqualTo("https://provider.com/user"); + assertThat(config.getIssuer()).hasToString("https://provider.com"); + assertThat(config.getAuthorizationEndpoint()).hasToString("https://provider.com/authorize"); + assertThat(config.getTokenEndpoint()).hasToString("https://provider.com/token"); + assertThat(config.getJwkSetUrl()).hasToString("https://provider.com/jwks"); + assertThat(config.getTokenRevocationEndpoint()).hasToString("https://provider.com/revoke"); + assertThat(config.getEndSessionEndpoint()).hasToString("https://provider.com/logout"); + assertThat(config.getTokenIntrospectionEndpoint()).hasToString("https://provider.com/introspect"); + assertThat(config.getUserInfoEndpoint()).hasToString("https://provider.com/user"); // OIDC Client Registration is disabled by default assertThat(config.getClientRegistrationEndpoint()).isNull(); } @@ -83,12 +83,12 @@ void authServerMetadataShouldAllowAccess() { OAuth2AuthorizationServerMetadata config = OAuth2AuthorizationServerMetadata.withClaims(entity.getBody()) .build(); - assertThat(config.getIssuer().toString()).isEqualTo("https://provider.com"); - assertThat(config.getAuthorizationEndpoint().toString()).isEqualTo("https://provider.com/authorize"); - assertThat(config.getTokenEndpoint().toString()).isEqualTo("https://provider.com/token"); - assertThat(config.getJwkSetUrl().toString()).isEqualTo("https://provider.com/jwks"); - assertThat(config.getTokenRevocationEndpoint().toString()).isEqualTo("https://provider.com/revoke"); - assertThat(config.getTokenIntrospectionEndpoint().toString()).isEqualTo("https://provider.com/introspect"); + assertThat(config.getIssuer()).hasToString("https://provider.com"); + assertThat(config.getAuthorizationEndpoint()).hasToString("https://provider.com/authorize"); + assertThat(config.getTokenEndpoint()).hasToString("https://provider.com/token"); + assertThat(config.getJwkSetUrl()).hasToString("https://provider.com/jwks"); + assertThat(config.getTokenRevocationEndpoint()).hasToString("https://provider.com/revoke"); + assertThat(config.getTokenIntrospectionEndpoint()).hasToString("https://provider.com/introspect"); // OIDC Client Registration is disabled by default assertThat(config.getClientRegistrationEndpoint()).isNull(); } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle new file mode 100644 index 000000000000..a0051d3f4ea1 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle @@ -0,0 +1,17 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" +} + +description = "Spring Boot Pulsar smoke test" + +dependencies { + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-pulsar")) + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-pulsar-reactive")) + testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) + testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) + testImplementation(project(":spring-boot-project:spring-boot-testcontainers")) + testImplementation("org.awaitility:awaitility") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:pulsar") +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ImperativeAppConfig.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ImperativeAppConfig.java new file mode 100644 index 000000000000..e7482711fbac --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ImperativeAppConfig.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-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 smoketest.pulsar; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.pulsar.core.PulsarTopic; + +@Configuration(proxyBeanMethods = false) +@Profile("smoketest.pulsar.imperative") +class ImperativeAppConfig { + + private static final Log logger = LogFactory.getLog(ImperativeAppConfig.class); + + private static final String TOPIC = "pulsar-smoke-test-topic"; + + @Bean + PulsarTopic pulsarTestTopic() { + return PulsarTopic.builder(TOPIC).numberOfPartitions(1).build(); + } + + @Bean + ApplicationRunner sendMessagesToPulsarTopic(PulsarTemplate template) { + return (args) -> { + for (int i = 0; i < 10; i++) { + template.send(TOPIC, new SampleMessage(i, "message:" + i)); + logger.info("++++++PRODUCE IMPERATIVE:(" + i + ")------"); + } + }; + } + + @PulsarListener(topics = TOPIC) + void consumeMessagesFromPulsarTopic(SampleMessage msg) { + logger.info("++++++CONSUME IMPERATIVE:(" + msg.id() + ")------"); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ReactiveAppConfig.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ReactiveAppConfig.java new file mode 100644 index 000000000000..844178bd44be --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/ReactiveAppConfig.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-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 smoketest.pulsar; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.pulsar.reactive.client.api.MessageSpec; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.pulsar.core.PulsarTopic; +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener; +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate; + +@Configuration(proxyBeanMethods = false) +@Profile("smoketest.pulsar.reactive") +class ReactiveAppConfig { + + private static final Log logger = LogFactory.getLog(ReactiveAppConfig.class); + + private static final String TOPIC = "pulsar-reactive-smoke-test-topic"; + + @Bean + PulsarTopic pulsarTestTopic() { + return PulsarTopic.builder(TOPIC).numberOfPartitions(1).build(); + } + + @Bean + ApplicationRunner sendMessagesToPulsarTopic(ReactivePulsarTemplate template) { + return (args) -> Flux.range(0, 10) + .map((i) -> new SampleMessage(i, "message:" + i)) + .map(MessageSpec::of) + .as((msgs) -> template.send(TOPIC, msgs)) + .doOnNext((sendResult) -> logger + .info("++++++PRODUCE REACTIVE:(" + sendResult.getMessageSpec().getValue().id() + ")------")) + .subscribe(); + } + + @ReactivePulsarListener(topics = TOPIC) + Mono consumeMessagesFromPulsarTopic(SampleMessage msg) { + logger.info("++++++CONSUME REACTIVE:(" + msg.id() + ")------"); + return Mono.empty(); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java new file mode 100644 index 000000000000..3887ce61f13a --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-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 smoketest.pulsar; + +record SampleMessage(Integer id, String content) { +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java new file mode 100644 index 000000000000..560967bb2d0d --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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 smoketest.pulsar; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SamplePulsarApplication { + + public static void main(String[] args) { + SpringApplication.run(SamplePulsarApplication.class, args); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties new file mode 100644 index 000000000000..b1ae3ec6f4ee --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.pulsar.consumer.subscription.initial-position=earliest diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java new file mode 100644 index 000000000000..c58c743cc8d9 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-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 smoketest.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.PulsarContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers(disabledWithoutDocker = true) +@ExtendWith(OutputCaptureExtension.class) +class SamplePulsarApplicationTests { + + @Container + @ServiceConnection + static final PulsarContainer container = new PulsarContainer(DockerImageNames.pulsar()).withStartupAttempts(2) + .withStartupTimeout(Duration.ofMinutes(3)); + + abstract class PulsarApplication { + + private final String type; + + PulsarApplication(String type) { + this.type = type; + } + + @Test + void appProducesAndConsumesMessages(CapturedOutput output) { + List expectedOutput = new ArrayList<>(); + IntStream.range(0, 10).forEachOrdered((i) -> { + expectedOutput.add("++++++PRODUCE %s:(%s)------".formatted(this.type, i)); + expectedOutput.add("++++++CONSUME %s:(%s)------".formatted(this.type, i)); + }); + Awaitility.waitAtMost(Duration.ofSeconds(30)) + .untilAsserted(() -> assertThat(output).contains(expectedOutput)); + } + + } + + @Nested + @SpringBootTest + @ActiveProfiles("smoketest.pulsar.imperative") + class ImperativePulsarApplication extends PulsarApplication { + + ImperativePulsarApplication() { + super("IMPERATIVE"); + } + + } + + @Nested + @SpringBootTest + @ActiveProfiles("smoketest.pulsar.reactive") + class ReactivePulsarApplication extends PulsarApplication { + + ReactivePulsarApplication() { + super("REACTIVE"); + } + + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/build.gradle index e09d6e6806b5..58e1f903b5c6 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/build.gradle @@ -5,10 +5,6 @@ plugins { description = "Spring Boot WebSocket Jetty smoke test" -configurations.all { - resolutionStrategy.force("jakarta.servlet:jakarta.servlet-api:5.0.0") -} - dependencies { implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jetty")) implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-websocket")) { diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/Location.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/Location.java index b7b02a7a1f78..563235703d60 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/Location.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/Location.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,10 +55,7 @@ public boolean equals(Object o) { if (this.x != location.x) { return false; } - if (this.y != location.y) { - return false; - } - return true; + return this.y == location.y; } @Override diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/Snake.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/Snake.java index 0206f06e94ee..2376d9028b3a 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/Snake.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/Snake.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,12 +136,12 @@ public void setDirection(Direction direction) { public String getLocationsJson() { synchronized (this.monitor) { StringBuilder sb = new StringBuilder(); - sb.append(String.format("{x: %d, y: %d}", Integer.valueOf(this.head.x), Integer.valueOf(this.head.y))); + sb.append(String.format("{x: %d, y: %d}", this.head.x, this.head.y)); for (Location location : this.tail) { sb.append(','); - sb.append(String.format("{x: %d, y: %d}", Integer.valueOf(location.x), Integer.valueOf(location.y))); + sb.append(String.format("{x: %d, y: %d}", location.x, location.y)); } - return String.format("{'id':%d,'body':[%s]}", Integer.valueOf(this.id), sb); + return String.format("{'id':%d,'body':[%s]}", this.id, sb); } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/SnakeTimer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/SnakeTimer.java index 8a50a492cefd..8620bb6d0236 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/SnakeTimer.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/SnakeTimer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 static void addSnake(Snake snake) { if (snakes.isEmpty()) { startTimer(); } - snakes.put(Integer.valueOf(snake.getId()), snake); + snakes.put(snake.getId(), snake); } } @@ -60,7 +60,7 @@ public static Collection getSnakes() { public static void removeSnake(Snake snake) { synchronized (MONITOR) { - snakes.remove(Integer.valueOf(snake.getId())); + snakes.remove(snake.getId()); if (snakes.isEmpty()) { stopTimer(); } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/SnakeWebSocketHandler.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/SnakeWebSocketHandler.java index dc5ba84e841f..625a5c7dfe6b 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/SnakeWebSocketHandler.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-jetty/src/main/java/smoketest/websocket/jetty/snake/SnakeWebSocketHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,7 +69,7 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio StringBuilder sb = new StringBuilder(); for (Iterator iterator = SnakeTimer.getSnakes().iterator(); iterator.hasNext();) { Snake snake = iterator.next(); - sb.append(String.format("{id: %d, color: '%s'}", Integer.valueOf(snake.getId()), snake.getHexColor())); + sb.append(String.format("{id: %d, color: '%s'}", snake.getId(), snake.getHexColor())); if (iterator.hasNext()) { sb.append(','); } @@ -80,24 +80,18 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); - if ("west".equals(payload)) { - this.snake.setDirection(Direction.WEST); - } - else if ("north".equals(payload)) { - this.snake.setDirection(Direction.NORTH); - } - else if ("east".equals(payload)) { - this.snake.setDirection(Direction.EAST); - } - else if ("south".equals(payload)) { - this.snake.setDirection(Direction.SOUTH); + switch (payload) { + case "west" -> this.snake.setDirection(Direction.WEST); + case "north" -> this.snake.setDirection(Direction.NORTH); + case "east" -> this.snake.setDirection(Direction.EAST); + case "south" -> this.snake.setDirection(Direction.SOUTH); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { SnakeTimer.removeSnake(this.snake); - SnakeTimer.broadcast(String.format("{'type': 'leave', 'id': %d}", Integer.valueOf(this.id))); + SnakeTimer.broadcast(String.format("{'type': 'leave', 'id': %d}", this.id)); } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/Location.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/Location.java index abbdc68232eb..62aca98c75b0 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/Location.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/Location.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,10 +55,7 @@ public boolean equals(Object o) { if (this.x != location.x) { return false; } - if (this.y != location.y) { - return false; - } - return true; + return this.y == location.y; } @Override diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/Snake.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/Snake.java index e69ff6918d90..e20044600cda 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/Snake.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/Snake.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,12 +136,12 @@ public void setDirection(Direction direction) { public String getLocationsJson() { synchronized (this.monitor) { StringBuilder sb = new StringBuilder(); - sb.append(String.format("{x: %d, y: %d}", Integer.valueOf(this.head.x), Integer.valueOf(this.head.y))); + sb.append(String.format("{x: %d, y: %d}", this.head.x, this.head.y)); for (Location location : this.tail) { sb.append(','); - sb.append(String.format("{x: %d, y: %d}", Integer.valueOf(location.x), Integer.valueOf(location.y))); + sb.append(String.format("{x: %d, y: %d}", location.x, location.y)); } - return String.format("{'id':%d,'body':[%s]}", Integer.valueOf(this.id), sb); + return String.format("{'id':%d,'body':[%s]}", this.id, sb); } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/SnakeTimer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/SnakeTimer.java index 0d95ec921de8..7eef15019034 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/SnakeTimer.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/SnakeTimer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 static void addSnake(Snake snake) { if (snakes.isEmpty()) { startTimer(); } - snakes.put(Integer.valueOf(snake.getId()), snake); + snakes.put(snake.getId(), snake); } } @@ -60,7 +60,7 @@ public static Collection getSnakes() { public static void removeSnake(Snake snake) { synchronized (MONITOR) { - snakes.remove(Integer.valueOf(snake.getId())); + snakes.remove(snake.getId()); if (snakes.isEmpty()) { stopTimer(); } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/SnakeWebSocketHandler.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/SnakeWebSocketHandler.java index e69a489bc3ab..2080c9d23a1f 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/SnakeWebSocketHandler.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-tomcat/src/main/java/smoketest/websocket/tomcat/snake/SnakeWebSocketHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,7 +69,7 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio StringBuilder sb = new StringBuilder(); for (Iterator iterator = SnakeTimer.getSnakes().iterator(); iterator.hasNext();) { Snake snake = iterator.next(); - sb.append(String.format("{id: %d, color: '%s'}", Integer.valueOf(snake.getId()), snake.getHexColor())); + sb.append(String.format("{id: %d, color: '%s'}", snake.getId(), snake.getHexColor())); if (iterator.hasNext()) { sb.append(','); } @@ -80,24 +80,18 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); - if ("west".equals(payload)) { - this.snake.setDirection(Direction.WEST); - } - else if ("north".equals(payload)) { - this.snake.setDirection(Direction.NORTH); - } - else if ("east".equals(payload)) { - this.snake.setDirection(Direction.EAST); - } - else if ("south".equals(payload)) { - this.snake.setDirection(Direction.SOUTH); + switch (payload) { + case "west" -> this.snake.setDirection(Direction.WEST); + case "north" -> this.snake.setDirection(Direction.NORTH); + case "east" -> this.snake.setDirection(Direction.EAST); + case "south" -> this.snake.setDirection(Direction.SOUTH); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { SnakeTimer.removeSnake(this.snake); - SnakeTimer.broadcast(String.format("{'type': 'leave', 'id': %d}", Integer.valueOf(this.id))); + SnakeTimer.broadcast(String.format("{'type': 'leave', 'id': %d}", this.id)); } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/Location.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/Location.java index d658ba1b6b40..d7b3e06c8ad6 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/Location.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/Location.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author 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,10 +55,7 @@ public boolean equals(Object o) { if (this.x != location.x) { return false; } - if (this.y != location.y) { - return false; - } - return true; + return this.y == location.y; } @Override diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/Snake.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/Snake.java index a9718ead230d..f0d2f297520a 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/Snake.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/Snake.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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,12 +136,12 @@ public void setDirection(Direction direction) { public String getLocationsJson() { synchronized (this.monitor) { StringBuilder sb = new StringBuilder(); - sb.append(String.format("{x: %d, y: %d}", Integer.valueOf(this.head.x), Integer.valueOf(this.head.y))); + sb.append(String.format("{x: %d, y: %d}", this.head.x, this.head.y)); for (Location location : this.tail) { sb.append(','); - sb.append(String.format("{x: %d, y: %d}", Integer.valueOf(location.x), Integer.valueOf(location.y))); + sb.append(String.format("{x: %d, y: %d}", location.x, location.y)); } - return String.format("{'id':%d,'body':[%s]}", Integer.valueOf(this.id), sb); + return String.format("{'id':%d,'body':[%s]}", this.id, sb); } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/SnakeTimer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/SnakeTimer.java index bb6314e0da48..d04b82c8e252 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/SnakeTimer.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/SnakeTimer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author 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 static void addSnake(Snake snake) { if (snakes.isEmpty()) { startTimer(); } - snakes.put(Integer.valueOf(snake.getId()), snake); + snakes.put(snake.getId(), snake); } } @@ -60,7 +60,7 @@ public static Collection getSnakes() { public static void removeSnake(Snake snake) { synchronized (MONITOR) { - snakes.remove(Integer.valueOf(snake.getId())); + snakes.remove(snake.getId()); if (snakes.isEmpty()) { stopTimer(); } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/SnakeWebSocketHandler.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/SnakeWebSocketHandler.java index da2a32658484..18b1216cb6af 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/SnakeWebSocketHandler.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-websocket-undertow/src/main/java/smoketest/websocket/undertow/snake/SnakeWebSocketHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,7 +69,7 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio StringBuilder sb = new StringBuilder(); for (Iterator iterator = SnakeTimer.getSnakes().iterator(); iterator.hasNext();) { Snake snake = iterator.next(); - sb.append(String.format("{id: %d, color: '%s'}", Integer.valueOf(snake.getId()), snake.getHexColor())); + sb.append(String.format("{id: %d, color: '%s'}", snake.getId(), snake.getHexColor())); if (iterator.hasNext()) { sb.append(','); } @@ -80,24 +80,18 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); - if ("west".equals(payload)) { - this.snake.setDirection(Direction.WEST); - } - else if ("north".equals(payload)) { - this.snake.setDirection(Direction.NORTH); - } - else if ("east".equals(payload)) { - this.snake.setDirection(Direction.EAST); - } - else if ("south".equals(payload)) { - this.snake.setDirection(Direction.SOUTH); + switch (payload) { + case "west" -> this.snake.setDirection(Direction.WEST); + case "north" -> this.snake.setDirection(Direction.NORTH); + case "east" -> this.snake.setDirection(Direction.EAST); + case "south" -> this.snake.setDirection(Direction.SOUTH); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { SnakeTimer.removeSnake(this.snake); - SnakeTimer.broadcast(String.format("{'type': 'leave', 'id': %d}", Integer.valueOf(this.id))); + SnakeTimer.broadcast(String.format("{'type': 'leave', 'id': %d}", this.id)); } } diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index 9b6bee414d63..fdec73a16394 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -33,6 +33,7 @@ + @@ -71,6 +72,7 @@ + @@ -79,4 +81,5 @@ + diff --git a/src/checkstyle/checkstyle.xml b/src/checkstyle/checkstyle.xml index c6d5f9765b6a..b42030407e23 100644 --- a/src/checkstyle/checkstyle.xml +++ b/src/checkstyle/checkstyle.xml @@ -24,6 +24,8 @@ + diff --git a/src/checkstyle/import-control.xml b/src/checkstyle/import-control.xml index 78d5bbabeab6..b3c993a4543f 100644 --- a/src/checkstyle/import-control.xml +++ b/src/checkstyle/import-control.xml @@ -1,6 +1,7 @@ +