diff --git a/initializr-actuator/src/main/java/io/spring/initializr/actuate/stat/ProjectRequestDocumentFactory.java b/initializr-actuator/src/main/java/io/spring/initializr/actuate/stat/ProjectRequestDocumentFactory.java
index 45dd3b0dd5..fa0a1a90cb 100644
--- a/initializr-actuator/src/main/java/io/spring/initializr/actuate/stat/ProjectRequestDocumentFactory.java
+++ b/initializr-actuator/src/main/java/io/spring/initializr/actuate/stat/ProjectRequestDocumentFactory.java
@@ -55,7 +55,7 @@ public ProjectRequestDocument createDocument(ProjectRequestEvent event) {
document.setGroupId(request.getGroupId());
document.setArtifactId(request.getArtifactId());
document.setPackageName(request.getPackageName());
- document.setVersion(determineVersionInformation(request));
+ document.setVersion(determineVersionInformation(request, metadata));
document.setClient(determineClientInformation(request));
document.setJavaVersion(request.getJavaVersion());
@@ -120,8 +120,13 @@ public ProjectRequestDocument createDocument(ProjectRequestEvent event) {
return null;
}
- private @Nullable VersionInformation determineVersionInformation(ProjectRequest request) {
- Version version = Version.safeParse(request.getBootVersion());
+ private @Nullable VersionInformation determineVersionInformation(ProjectRequest request,
+ InitializrMetadata metadata) {
+ String bootVersion = request.getBootVersion();
+ if (bootVersion == null) {
+ return null;
+ }
+ Version version = metadata.resolveBootVersion(bootVersion);
if (version != null && version.getMajor() != null) {
return new VersionInformation(version);
}
diff --git a/initializr-actuator/src/test/java/io/spring/initializr/actuate/stat/ProjectRequestDocumentFactoryTests.java b/initializr-actuator/src/test/java/io/spring/initializr/actuate/stat/ProjectRequestDocumentFactoryTests.java
index b66eacb027..6c83c7267f 100755
--- a/initializr-actuator/src/test/java/io/spring/initializr/actuate/stat/ProjectRequestDocumentFactoryTests.java
+++ b/initializr-actuator/src/test/java/io/spring/initializr/actuate/stat/ProjectRequestDocumentFactoryTests.java
@@ -196,6 +196,26 @@ void createDocumentInvalidJavaVersion() {
assertThat(errorState.getDependencies()).isNull();
}
+ @Test
+ void createDocumentWithWildcardPlatformVersionRecordsResolvedVersion() {
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion("2.4.x");
+ ProjectGeneratedEvent event = createProjectGeneratedEvent(request);
+ ProjectRequestDocument document = this.factory.createDocument(event);
+ ProjectRequestDocument.VersionInformation version = document.getVersion();
+ assertThat(version).isNotNull();
+ assertThat(version.getId()).isEqualTo("2.4.1");
+ }
+
+ @Test
+ void createDocumentWithWildcardPlatformVersionThatCannotBeResolved() {
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion("9.9.x");
+ ProjectGeneratedEvent event = createProjectGeneratedEvent(request);
+ ProjectRequestDocument document = this.factory.createDocument(event);
+ assertThat(document.getVersion()).isNull();
+ }
+
@Test
void createDocumentInvalidLanguage() {
ProjectRequest request = createProjectRequest();
diff --git a/initializr-docs/src/main/asciidoc/configuration-guide.adoc b/initializr-docs/src/main/asciidoc/configuration-guide.adoc
index b35fe86473..7e3d6d0ed3 100644
--- a/initializr-docs/src/main/asciidoc/configuration-guide.adoc
+++ b/initializr-docs/src/main/asciidoc/configuration-guide.adoc
@@ -907,6 +907,29 @@ $ http https://localhost:8080/starter.zip dependencies==web,devtools \
NOTE: `HTTPie` reads the same hint as the browser so it will store a `demo.zip` file in
the current directory, with the same differences discussed above.
+The `bootVersion` parameter also accepts an abbreviated form, where the minor and patch
+numbers are either omitted or specified as `x`. The service then selects the latest
+generally available version that matches, ignoring milestones and snapshots. `4` and
+`4.x.x` both resolve to the latest generally available `4.x` version, and `4.0` and `4.0.x`
+both resolve to the latest generally available `4.0` version:
+
+[source]
+----
+$ curl -G http://localhost:8080/starter.zip -d bootVersion=4.0.x -o my-project.zip
+----
+
+The same forms are supported by the `/dependencies` endpoint.
+
+Only the trailing numbers can be replaced by `x`, and an abbreviated form cannot be
+combined with a qualifier. Values such as `4.x.3`, `4.0.x-M1` or `4.x.x-SNAPSHOT` are
+therefore rejected. Any other value is used as is, so `4.0.2-M1` selects that exact
+version.
+
+The request is rejected with a `400` response if no available version matches the
+abbreviated form, or if the version it resolves to is outside the platform compatibility
+range that the service is configured with. In the latter case, the error message refers to
+the resolved version rather than the abbreviated form that was requested.
+
[[configuration-howto]]
diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/version/Version.java b/initializr-generator/src/main/java/io/spring/initializr/generator/version/Version.java
index 8771ef910d..799ffe935c 100644
--- a/initializr-generator/src/main/java/io/spring/initializr/generator/version/Version.java
+++ b/initializr-generator/src/main/java/io/spring/initializr/generator/version/Version.java
@@ -145,6 +145,15 @@ public Format getFormat() {
return this.format;
}
+ /**
+ * Specify if this version is a general availability (GA) release, that is a version
+ * with no qualifier or with the special {@code RELEASE} qualifier.
+ * @return {@code true} if this version is a GA release
+ */
+ public boolean isGeneralAvailability() {
+ return (this.qualifier == null) || VersionQualifierComparator.RELEASE.equals(this.qualifier.getId());
+ }
+
/**
* Parse the string representation of a {@link Version}. Throws an
* {@link InvalidVersionException} if the version could not be parsed.
diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/version/VersionParser.java b/initializr-generator/src/main/java/io/spring/initializr/generator/version/VersionParser.java
index b62f859159..f72c368af4 100644
--- a/initializr-generator/src/main/java/io/spring/initializr/generator/version/VersionParser.java
+++ b/initializr-generator/src/main/java/io/spring/initializr/generator/version/VersionParser.java
@@ -47,8 +47,16 @@ public class VersionParser {
*/
public static final VersionParser DEFAULT = new VersionParser(Collections.emptyList());
+ private static final String WILDCARD = "x";
+
+ // Numbers are limited to 9 digits so that they always fit in an int
private static final Pattern VERSION_REGEX = Pattern
- .compile("^(\\d+)\\.(\\d+|x)\\.(\\d+|x)(?:([.|-])([^0-9]+)(\\d+)?)?$");
+ .compile("^(\\d{1,9})\\.(\\d{1,9}|x)\\.(\\d{1,9}|x)(?:([.|-])([^0-9]+)(\\d{1,9})?)?$");
+
+ // Matches anything that starts with a number, so that an abbreviated form can be
+ // detected. The last group captures whatever follows the numbers, if anything
+ private static final Pattern LEADING_VERSION_REGEX = Pattern
+ .compile("^(\\d{1,9})(?:\\.(\\d{1,9}|x))?(?:\\.(\\d{1,9}|x))?(.*)$");
private static final Pattern RANGE_REGEX = Pattern.compile("([(\\[])(.*),(.*)([)\\]])");
@@ -77,12 +85,12 @@ public Version parse(String text) {
String minor = matcher.group(2);
String patch = matcher.group(3);
Qualifier qualifier = parseQualifier(matcher);
- if ("x".equals(minor) || "x".equals(patch)) {
- Integer minorInt = ("x".equals(minor) ? null : Integer.parseInt(minor));
+ if (WILDCARD.equals(minor) || WILDCARD.equals(patch)) {
+ Integer minorInt = (WILDCARD.equals(minor) ? null : Integer.parseInt(minor));
Version latest = findLatestVersion(major, minorInt, qualifier);
if (latest == null) {
- return new Version(major, ("x".equals(minor) ? 999 : Integer.parseInt(minor)),
- ("x".equals(patch) ? 999 : Integer.parseInt(patch)), qualifier);
+ return new Version(major, (WILDCARD.equals(minor) ? 999 : Integer.parseInt(minor)),
+ (WILDCARD.equals(patch) ? 999 : Integer.parseInt(patch)), qualifier);
}
return new Version(major, latest.getMinor(), latest.getPatch(), latest.getQualifier());
}
@@ -119,6 +127,56 @@ public Version parse(String text) {
}
}
+ /**
+ * Resolve the specified string representation of a {@link Version} against the
+ * configured "latest versions".
+ *
+ * The minor and patch numbers can be omitted or specified as {@code x}, in which case
+ * the latest matching general availability version is returned, for instance
+ * {@code 4}, {@code 4.x.x} or {@code 4.0.x}. A {@code x} in any other position, such
+ * as {@code 4.x.3} or {@code 4.0.x-M1}, cannot be resolved. Any other value is parsed
+ * as is, see {@link #safeParse(String)}.
+ *
+ * Contrary to {@link #parse(String)}, a wildcard that matches no configured version
+ * does not fall back to an arbitrary version number.
+ * @param text the version text
+ * @return a Version instance for the specified version text or {@code null} if the
+ * version is invalid, if it uses a wildcard that cannot be resolved or if no
+ * configured version matches
+ */
+ public @Nullable Version resolveLatest(String text) {
+ Assert.notNull(text, "Text must not be null");
+ Matcher matcher = LEADING_VERSION_REGEX.matcher(text.trim());
+ if (!matcher.matches()) {
+ // Does not even start with a number, there is nothing to resolve
+ return safeParse(text);
+ }
+ String minor = matcher.group(2);
+ String patch = matcher.group(3);
+ boolean qualified = StringUtils.hasText(matcher.group(4));
+ if (!qualified && (patch == null || WILDCARD.equals(patch))) {
+ // An abbreviated form, such as 4, 4.x, 4.0, 4.x.x or 4.0.x
+ Integer major = Integer.valueOf(matcher.group(1));
+ return findLatestGeneralAvailability(major,
+ (minor == null || WILDCARD.equals(minor)) ? null : Integer.valueOf(minor));
+ }
+ if (WILDCARD.equals(minor) || WILDCARD.equals(patch)) {
+ // A wildcard that is not trailing, such as 4.x.3, or one combined with a
+ // qualifier, such as 4.0.x-M1: no latest version to resolve it against
+ return null;
+ }
+ return safeParse(text);
+ }
+
+ private @Nullable Version findLatestGeneralAvailability(Integer major, @Nullable Integer minor) {
+ return this.latestVersions.stream()
+ .filter(Version::isGeneralAvailability)
+ .filter((candidate) -> major.equals(candidate.getMajor())
+ && (minor == null || minor.equals(candidate.getMinor())))
+ .max(Version::compareTo)
+ .orElse(null);
+ }
+
/**
* Parse the string representation of a {@link VersionRange}. Throws an
* {@link InvalidVersionException} if the range could not be parsed.
diff --git a/initializr-generator/src/test/java/io/spring/initializr/generator/version/VersionParserTests.java b/initializr-generator/src/test/java/io/spring/initializr/generator/version/VersionParserTests.java
index f5bf89a0c8..d723cc5f40 100755
--- a/initializr-generator/src/test/java/io/spring/initializr/generator/version/VersionParserTests.java
+++ b/initializr-generator/src/test/java/io/spring/initializr/generator/version/VersionParserTests.java
@@ -21,6 +21,8 @@
import java.util.List;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -127,9 +129,98 @@ void parseVariableVersionNoQualifierNoMatch() {
assertThat(this.parser.parse("1.2.x").toString()).isEqualTo("1.2.999");
}
+ @Test
+ void parseVersionWithLargestNumbersThatFitInAnInteger() {
+ assertThat(this.parser.parse("999999999.999999999.999999999.M999999999"))
+ .hasToString("999999999.999999999.999999999.M999999999");
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = { "99999999999999.0.0", "1.99999999999999.0", "1.0.99999999999999", "1.0.0.M99999999999999" })
+ void parseVersionWithNumberThatDoesNotFitInAnIntegerIsInvalid(String text) {
+ assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> this.parser.parse(text));
+ }
+
+ @Test
+ void resolveLatestWithExactVersionParsesIt() {
+ this.parser = createParser("4.0.1", "4.1.2");
+ assertThat(this.parser.resolveLatest("4.0.1")).hasToString("4.0.1");
+ }
+
+ @Test
+ void resolveLatestWithExactVersionThatIsNotConfiguredParsesIt() {
+ this.parser = createParser("4.0.1", "4.1.2");
+ assertThat(this.parser.resolveLatest("3.5.0")).hasToString("3.5.0");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "4", "4.x", "4.x.x" })
+ void resolveLatestWithMajorWildcardUsesLatestVersionOfThatMajor(String text) {
+ this.parser = createParser("3.5.9", "4.0.1", "4.1.2");
+ assertThat(this.parser.resolveLatest(text)).hasToString("4.1.2");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "4.0", "4.0.x" })
+ void resolveLatestWithMinorWildcardUsesLatestVersionOfThatMinor(String text) {
+ this.parser = createParser("4.0.1", "4.0.2", "4.1.2");
+ assertThat(this.parser.resolveLatest(text)).hasToString("4.0.2");
+ }
+
+ @Test
+ void resolveLatestWithWildcardIgnoresPreReleaseVersions() {
+ this.parser = createParser("4.0.1", "4.1.0-M2", "4.1.0-SNAPSHOT");
+ assertThat(this.parser.resolveLatest("4.x.x")).hasToString("4.0.1");
+ }
+
+ @Test
+ void resolveLatestWithWildcardConsidersReleaseQualifier() {
+ this.parser = createParser("1.5.21.RELEASE", "1.5.22.RELEASE");
+ assertThat(this.parser.resolveLatest("1.x.x")).hasToString("1.5.22.RELEASE");
+ }
+
+ @Test
+ void resolveLatestWithWildcardComparesPatchNumerically() {
+ this.parser = createParser("4.0.2", "4.0.10");
+ assertThat(this.parser.resolveLatest("4.0.x")).hasToString("4.0.10");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "5.x.x", "4.2.x" })
+ void resolveLatestWithWildcardWhenNoVersionMatchesReturnsNull(String text) {
+ this.parser = createParser("4.0.1", "4.1.2");
+ assertThat(this.parser.resolveLatest(text)).isNull();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "4.x.3", "4.0.x-M1", "4.x.x-SNAPSHOT", "4.x.3.RELEASE" })
+ void resolveLatestWithWildcardThatCannotBeResolvedReturnsNull(String text) {
+ this.parser = createParser("4.0.1", "4.0.2-M1");
+ assertThat(this.parser.resolveLatest(text)).isNull();
+ }
+
+ @Test
+ void resolveLatestWithWildcardAndSpaces() {
+ this.parser = createParser("4.0.1", "4.0.2");
+ assertThat(this.parser.resolveLatest(" 4.0.x ")).hasToString("4.0.2");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "", " ", "nope", "4.", "4.0.0.", "99999999999999", "4.99999999999999",
+ "99999999999999.0.0", "4.0.0.M99999999999999" })
+ void resolveLatestWithInvalidVersionReturnsNull(String text) {
+ this.parser = createParser("4.0.1");
+ assertThat(this.parser.resolveLatest(text)).isNull();
+ }
+
@Test
void invalidRange() {
assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> this.parser.parseRange("foo-bar"));
}
+ private VersionParser createParser(String... versions) {
+ return new VersionParser(Arrays.stream(versions).map(Version::parse).toList());
+ }
+
}
diff --git a/initializr-generator/src/test/java/io/spring/initializr/generator/version/VersionTests.java b/initializr-generator/src/test/java/io/spring/initializr/generator/version/VersionTests.java
index ed9330f226..e9c1ad041d 100755
--- a/initializr-generator/src/test/java/io/spring/initializr/generator/version/VersionTests.java
+++ b/initializr-generator/src/test/java/io/spring/initializr/generator/version/VersionTests.java
@@ -22,6 +22,8 @@
import io.spring.initializr.generator.version.Version.Format;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
@@ -212,6 +214,18 @@ void formatNoQualifierToV2() {
assertThat(version.format(Format.V2)).hasToString("1.2.0");
}
+ @ParameterizedTest
+ @ValueSource(strings = { "1.2.0", "1.2.0.RELEASE" })
+ void isGeneralAvailabilityWithGaVersion(String text) {
+ assertThat(Version.parse(text).isGeneralAvailability()).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "1.2.0-M1", "1.2.0.RC1", "1.2.0-SNAPSHOT", "1.2.0.BUILD-SNAPSHOT" })
+ void isGeneralAvailabilityWithPreReleaseVersion(String text) {
+ assertThat(Version.parse(text).isGeneralAvailability()).isFalse();
+ }
+
private Version parse(String text) {
return this.parser.parse(text);
}
diff --git a/initializr-metadata/src/main/java/io/spring/initializr/metadata/InitializrMetadata.java b/initializr-metadata/src/main/java/io/spring/initializr/metadata/InitializrMetadata.java
index 2ef5736ecd..18bd51a457 100644
--- a/initializr-metadata/src/main/java/io/spring/initializr/metadata/InitializrMetadata.java
+++ b/initializr-metadata/src/main/java/io/spring/initializr/metadata/InitializrMetadata.java
@@ -19,13 +19,16 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
+import io.spring.initializr.generator.version.InvalidVersionException;
import io.spring.initializr.generator.version.Version;
import io.spring.initializr.generator.version.VersionParser;
import io.spring.initializr.generator.version.VersionProperty;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
/**
* Meta-data used to generate a project.
@@ -207,7 +210,14 @@ public void validate() {
/**
* Update the available Spring Boot versions with the specified capabilities.
+ *
+ * Every version is parsed so that the compatibility ranges can be updated, and a
+ * version that cannot be parsed is therefore rejected. Contrary to
+ * {@link #resolveBootVersion(String)}, this fails fast: a compatibility range that
+ * uses a wildcard would otherwise be silently resolved against an incomplete list of
+ * versions.
* @param versionsMetadata the Spring Boot boot versions metadata to use
+ * @throws InvalidVersionException if a version cannot be parsed
*/
public void updateSpringBootVersions(List versionsMetadata) {
this.bootVersions.setContent(versionsMetadata);
@@ -221,6 +231,44 @@ public void updateSpringBootVersions(List versionsMetada
this.configuration.getEnv().updateCompatibilityRange(parser);
}
+ /**
+ * Resolve the specified platform version against the available
+ * {@link #getBootVersions() platform versions}. The minor and patch numbers can be
+ * omitted or specified as {@code x}, in which case the latest matching general
+ * availability version is returned, for instance {@code 4}, {@code 4.x.x} or
+ * {@code 4.0.x}. A {@code x} in any other position, such as {@code 4.x.3} or
+ * {@code 4.0.x-M1}, cannot be resolved. Any other value is parsed as is.
+ *
+ * Contrary to {@link #updateSpringBootVersions(List)}, an available version that
+ * cannot be parsed is ignored rather than rejected, as this runs for every request:
+ * one such version in the metadata should not fail them all.
+ * @param text the platform version to resolve
+ * @return the resolved platform version or {@code null} if the version is invalid, if
+ * it uses a wildcard that cannot be resolved or if no available version matches
+ * @see VersionParser#resolveLatest(String)
+ */
+ public @Nullable Version resolveBootVersion(String text) {
+ return createVersionParser().resolveLatest(text);
+ }
+
+ /**
+ * Create a {@link VersionParser} for the currently available
+ * {@link #getBootVersions() platform versions}, ignoring any version that cannot be
+ * parsed. The parser is not cached as the available versions can be updated at any
+ * time, see {@link #updateSpringBootVersions(List)}.
+ * @return a parser for the available platform versions
+ */
+ private VersionParser createVersionParser() {
+ List bootVersions = this.bootVersions.getContent()
+ .stream()
+ .map(DefaultMetadataElement::getId)
+ .filter(StringUtils::hasText)
+ .map(Version::safeParse)
+ .filter(Objects::nonNull)
+ .toList();
+ return new VersionParser(bootVersions);
+ }
+
/**
* Create an URL suitable to download Spring Boot cli for the specified version and
* extension.
diff --git a/initializr-metadata/src/test/java/io/spring/initializr/metadata/InitializrMetadataTests.java b/initializr-metadata/src/test/java/io/spring/initializr/metadata/InitializrMetadataTests.java
index 0704ddeb95..df7773687a 100755
--- a/initializr-metadata/src/test/java/io/spring/initializr/metadata/InitializrMetadataTests.java
+++ b/initializr-metadata/src/test/java/io/spring/initializr/metadata/InitializrMetadataTests.java
@@ -213,10 +213,43 @@ void stripInvalidCharsFromPackage() {
assertThat(metadata.getPackageName().getContent()).isEqualTo("org.ac_me.foo_bar");
}
+ @Test
+ void resolveBootVersionUsesUpdatedVersions() {
+ InitializrMetadata metadata = initializeMetadata("4.0.1");
+ assertThat(metadata.resolveBootVersion("4.x.x")).hasToString("4.0.1");
+ metadata.updateSpringBootVersions(List.of(DefaultMetadataElement.create("4.0.2", false)));
+ assertThat(metadata.resolveBootVersion("4.x.x")).hasToString("4.0.2");
+ }
+
+ @Test
+ void resolveBootVersionUsesVersionsThatAreSetWithoutUpdatingTheCompatibilityRanges() {
+ InitializrMetadata metadata = initializeMetadata();
+ metadata.getBootVersions()
+ .setContent(List.of(DefaultMetadataElement.create("4.0.1", false),
+ DefaultMetadataElement.create("4.0.2", true)));
+ assertThat(metadata.resolveBootVersion("4.x.x")).hasToString("4.0.2");
+ }
+
+ @Test
+ void resolveBootVersionIgnoresAvailableVersionsThatCannotBeParsed() {
+ InitializrMetadata metadata = initializeMetadata();
+ metadata.getBootVersions()
+ .setContent(List.of(DefaultMetadataElement.create("4.0.1", false),
+ DefaultMetadataElement.create("not-a-version", false)));
+ assertThat(metadata.resolveBootVersion("4.x.x")).hasToString("4.0.1");
+ }
+
private InitializrMetadata initializeMetadata() {
return new InitializrMetadata();
}
+ private InitializrMetadata initializeMetadata(String... bootVersions) {
+ InitializrMetadata metadata = initializeMetadata();
+ metadata.updateSpringBootVersions(
+ Arrays.stream(bootVersions).map((version) -> DefaultMetadataElement.create(version, false)).toList());
+ return metadata;
+ }
+
private void addTestDependencyGroup(InitializrMetadata metadata, Dependency... dependencies) {
DependencyGroup group = DependencyGroup.create("test");
for (Dependency dependency : dependencies) {
diff --git a/initializr-web/src/main/java/io/spring/initializr/web/controller/ProjectMetadataController.java b/initializr-web/src/main/java/io/spring/initializr/web/controller/ProjectMetadataController.java
index 4eceb5bf5a..9e9c0071e6 100644
--- a/initializr-web/src/main/java/io/spring/initializr/web/controller/ProjectMetadataController.java
+++ b/initializr-web/src/main/java/io/spring/initializr/web/controller/ProjectMetadataController.java
@@ -150,11 +150,10 @@ protected CacheControl determineCacheControlFor(InitializrMetadata metadata) {
protected ResponseEntity dependenciesFor(InitializrMetadataVersion metadataVersion,
@Nullable String bootVersion) {
InitializrMetadata metadata = this.metadataProvider.get();
- Version effectiveBootVersion = (bootVersion != null) ? Version.parse(bootVersion)
- : getDefaultBootVersion(metadata);
+ Version effectiveBootVersion = resolveBootVersion(metadata, bootVersion);
Platform platform = metadata.getConfiguration().getEnv().getPlatform();
if (!platform.isCompatibleVersion(effectiveBootVersion)) {
- throw new InvalidProjectRequestException("Invalid Spring Boot version '" + bootVersion
+ throw new InvalidProjectRequestException("Invalid Spring Boot version '" + effectiveBootVersion
+ "', Spring Boot compatibility range is " + platform.determineCompatibilityRangeRequirement());
}
DependencyMetadata dependencyMetadata = this.dependencyMetadataProvider.get(metadata, effectiveBootVersion);
@@ -166,6 +165,18 @@ protected ResponseEntity dependenciesFor(InitializrMetadataVersion metad
.body(content);
}
+ private Version resolveBootVersion(InitializrMetadata metadata, @Nullable String bootVersion) {
+ if (bootVersion == null) {
+ return getDefaultBootVersion(metadata);
+ }
+ Version effectiveBootVersion = metadata.resolveBootVersion(bootVersion);
+ if (effectiveBootVersion == null) {
+ throw new InvalidProjectRequestException(
+ "Invalid Spring Boot version '" + bootVersion + "' check project metadata");
+ }
+ return effectiveBootVersion;
+ }
+
private Version getDefaultBootVersion(InitializrMetadata metadata) {
DefaultMetadataElement element = metadata.getBootVersions().getDefault();
Assert.state(element != null, "'element' must not be null");
diff --git a/initializr-web/src/main/java/io/spring/initializr/web/project/DefaultProjectRequestToDescriptionConverter.java b/initializr-web/src/main/java/io/spring/initializr/web/project/DefaultProjectRequestToDescriptionConverter.java
index fb0025dfc0..7d27499024 100644
--- a/initializr-web/src/main/java/io/spring/initializr/web/project/DefaultProjectRequestToDescriptionConverter.java
+++ b/initializr-web/src/main/java/io/spring/initializr/web/project/DefaultProjectRequestToDescriptionConverter.java
@@ -81,8 +81,8 @@ public ProjectDescription convert(ProjectRequest request, InitializrMetadata met
* @param metadata the metadata instance to use to apply defaults if necessary
*/
public void convert(ProjectRequest request, MutableProjectDescription description, InitializrMetadata metadata) {
- validate(request, metadata);
- Version platformVersion = getPlatformVersion(request, metadata);
+ Version requestedVersion = validate(request, metadata);
+ Version platformVersion = this.platformVersionTransformer.transform(requestedVersion, metadata);
List resolvedDependencies = getResolvedDependencies(request, platformVersion, metadata);
validateDependencyRange(platformVersion, resolvedDependencies);
@@ -118,14 +118,15 @@ public void convert(ProjectRequest request, MutableProjectDescription descriptio
: value;
}
- private void validate(ProjectRequest request, InitializrMetadata metadata) {
+ private Version validate(ProjectRequest request, InitializrMetadata metadata) {
validateBaseDir(request);
- validatePlatformVersion(request, metadata);
+ Version platformVersion = validatePlatformVersion(request, metadata);
validateType(request.getType(), metadata);
validateLanguage(request.getLanguage(), metadata);
validateConfigurationFileFormat(request.getConfigurationFileFormat(), metadata);
validatePackaging(request.getPackaging(), metadata);
validateDependencies(request, metadata);
+ return platformVersion;
}
private void validateBaseDir(ProjectRequest request) {
@@ -139,13 +140,22 @@ private void validateBaseDir(ProjectRequest request) {
}
}
- private void validatePlatformVersion(ProjectRequest request, InitializrMetadata metadata) {
- Version platformVersion = Version.safeParse(request.getBootVersion());
+ private Version validatePlatformVersion(ProjectRequest request, InitializrMetadata metadata) {
+ String bootVersion = request.getBootVersion();
+ if (bootVersion == null) {
+ return getDefaultPlatformVersion(metadata);
+ }
+ Version platformVersion = metadata.resolveBootVersion(bootVersion);
+ if (platformVersion == null) {
+ throw new InvalidProjectRequestException(
+ "Invalid Spring Boot version '" + bootVersion + "' check project metadata");
+ }
Platform platform = metadata.getConfiguration().getEnv().getPlatform();
- if (platformVersion != null && !platform.isCompatibleVersion(platformVersion)) {
+ if (!platform.isCompatibleVersion(platformVersion)) {
throw new InvalidProjectRequestException("Invalid Spring Boot version '" + platformVersion
+ "', Spring Boot compatibility range is " + platform.determineCompatibilityRangeRequirement());
}
+ return platformVersion;
}
private void validateType(@Nullable String type, InitializrMetadata metadata) {
@@ -222,19 +232,12 @@ private BuildSystem getBuildSystem(ProjectRequest request, InitializrMetadata me
return BuildSystem.forIdAndDialect(id, dialect);
}
- private Version getPlatformVersion(ProjectRequest request, InitializrMetadata metadata) {
- String versionText = (request.getBootVersion() != null) ? request.getBootVersion()
- : getDefaultBootVersion(metadata);
- Version version = Version.parse(versionText);
- return this.platformVersionTransformer.transform(version, metadata);
- }
-
- private String getDefaultBootVersion(InitializrMetadata metadata) {
+ private Version getDefaultPlatformVersion(InitializrMetadata metadata) {
DefaultMetadataElement element = metadata.getBootVersions().getDefault();
Assert.state(element != null, "'element' must not be null");
String id = element.getId();
Assert.state(id != null, "'id' must not be null");
- return id;
+ return Version.parse(id);
}
private List getResolvedDependencies(ProjectRequest request, Version platformVersion,
diff --git a/initializr-web/src/test/java/io/spring/initializr/web/controller/PlatformVersionRangeIntegrationTests.java b/initializr-web/src/test/java/io/spring/initializr/web/controller/PlatformVersionRangeIntegrationTests.java
new file mode 100644
index 0000000000..492a06c07c
--- /dev/null
+++ b/initializr-web/src/test/java/io/spring/initializr/web/controller/PlatformVersionRangeIntegrationTests.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2012 - present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.spring.initializr.web.controller;
+
+import io.spring.initializr.web.AbstractInitializrControllerIntegrationTests;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.web.client.HttpClientErrorException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Integration tests for a service that restricts the platform versions it supports to a
+ * subset of the available versions.
+ *
+ * @author Moritz Halbritter
+ */
+@ActiveProfiles({ "test-default", "test-platform-range" })
+class PlatformVersionRangeIntegrationTests extends AbstractInitializrControllerIntegrationTests {
+
+ @Test
+ void generateProjectWithWildcardPlatformVersionOutOfRange() {
+ assertThatExceptionOfType(HttpClientErrorException.class)
+ .isThrownBy(() -> execute("/starter.tgz?bootVersion=2.3.x", byte[].class, null, (String[]) null))
+ .satisfies((ex) -> {
+ assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(ex.getResponseBodyAsString()).contains("Invalid Spring Boot version", "2.3.10.RELEASE",
+ "Spring Boot compatibility range is");
+ });
+ }
+
+ @Test
+ void getDependenciesWithWildcardPlatformVersionOutOfRange() {
+ assertThatExceptionOfType(HttpClientErrorException.class)
+ .isThrownBy(() -> execute("/dependencies?bootVersion=2.3.x", String.class, null,
+ "application/vnd.initializr.v2.1+json"))
+ .satisfies((ex) -> {
+ assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ // The resolved version is reported, not the wildcard that was requested
+ assertThat(ex.getResponseBodyAsString())
+ .contains("\"message\":\"Invalid Spring Boot version '2.3.10.RELEASE', "
+ + "Spring Boot compatibility range is >=2.4.0 and <2.5.0-SNAPSHOT\"");
+ });
+ }
+
+}
diff --git a/initializr-web/src/test/java/io/spring/initializr/web/controller/ProjectGenerationControllerIntegrationTests.java b/initializr-web/src/test/java/io/spring/initializr/web/controller/ProjectGenerationControllerIntegrationTests.java
index e6eee447b0..e73fb0a408 100755
--- a/initializr-web/src/test/java/io/spring/initializr/web/controller/ProjectGenerationControllerIntegrationTests.java
+++ b/initializr-web/src/test/java/io/spring/initializr/web/controller/ProjectGenerationControllerIntegrationTests.java
@@ -133,6 +133,26 @@ void dependencyInRange() {
assertThat(project).mavenBuild().hasDependenciesSize(3).hasDependency(biz);
}
+ @Test
+ void majorOnlyPlatformVersionIsResolved() {
+ ProjectStructure project = downloadTgz("/starter.tgz?bootVersion=2");
+ assertThat(project).mavenBuild().hasParent("org.springframework.boot", "spring-boot-starter-parent", "2.4.4");
+ }
+
+ @Test
+ void wildcardPlatformVersionIsResolved() {
+ ProjectStructure project = downloadTgz("/starter.tgz?bootVersion=2.3.x");
+ assertThat(project).mavenBuild()
+ .hasParent("org.springframework.boot", "spring-boot-starter-parent", "2.3.10.RELEASE");
+ }
+
+ @Test
+ void wildcardPlatformVersionThatDoesNotMatchIsRejected() {
+ assertThatExceptionOfType(HttpClientErrorException.class)
+ .isThrownBy(() -> execute("/starter.tgz?bootVersion=9.9.x", byte[].class, null, (String[]) null))
+ .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST));
+ }
+
@Test
void dependencyNotInRange() {
try {
diff --git a/initializr-web/src/test/java/io/spring/initializr/web/controller/ProjectMetadataControllerIntegrationTests.java b/initializr-web/src/test/java/io/spring/initializr/web/controller/ProjectMetadataControllerIntegrationTests.java
index fbd0bdb937..a50ba1d135 100644
--- a/initializr-web/src/test/java/io/spring/initializr/web/controller/ProjectMetadataControllerIntegrationTests.java
+++ b/initializr-web/src/test/java/io/spring/initializr/web/controller/ProjectMetadataControllerIntegrationTests.java
@@ -22,6 +22,8 @@
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.skyscreamer.jsonassert.JSONCompareMode;
import org.springframework.http.HttpHeaders;
@@ -31,6 +33,7 @@
import org.springframework.web.client.HttpClientErrorException;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Integration tests for {@link ProjectMetadataController}.
@@ -91,6 +94,32 @@ void metadataWithInvalidPlatformVersion() {
}
}
+ @Test
+ void dependenciesWithWildcardPlatformVersion() {
+ ResponseEntity response = execute("/dependencies?bootVersion=2.4.x", String.class, null,
+ "application/vnd.initializr.v2.1+json");
+ assertThat(response.getBody()).contains("\"bootVersion\":\"2.4.4\"");
+ }
+
+ @Test
+ void dependenciesWithMajorOnlyPlatformVersion() {
+ ResponseEntity response = execute("/dependencies?bootVersion=2", String.class, null,
+ "application/vnd.initializr.v2.1+json");
+ assertThat(response.getBody()).contains("\"bootVersion\":\"2.4.4\"");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "9.9.x", "2.x.3", "2.4.x-M1" })
+ void dependenciesWithPlatformVersionThatCannotBeResolved(String bootVersion) {
+ assertThatExceptionOfType(HttpClientErrorException.class)
+ .isThrownBy(() -> execute("/dependencies?bootVersion=" + bootVersion, String.class, null,
+ "application/vnd.initializr.v2.1+json"))
+ .satisfies((ex) -> {
+ assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(ex.getResponseBodyAsString()).contains(bootVersion);
+ });
+ }
+
@Test
void metadataWithCurrentAcceptHeader() {
getRequests().setFields("_links.maven-project", "dependencies.values[0]", "type.values[0]",
diff --git a/initializr-web/src/test/java/io/spring/initializr/web/project/DefaultProjectRequestToDescriptionConverterTests.java b/initializr-web/src/test/java/io/spring/initializr/web/project/DefaultProjectRequestToDescriptionConverterTests.java
index f935e568c3..b9dd440f01 100644
--- a/initializr-web/src/test/java/io/spring/initializr/web/project/DefaultProjectRequestToDescriptionConverterTests.java
+++ b/initializr-web/src/test/java/io/spring/initializr/web/project/DefaultProjectRequestToDescriptionConverterTests.java
@@ -27,6 +27,8 @@
import io.spring.initializr.metadata.InitializrMetadata;
import io.spring.initializr.metadata.Type;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -203,6 +205,73 @@ void convertWhenSpringBootVersionInvalidShouldThrowException() {
"Invalid Spring Boot version '1.5.9.RELEASE', Spring Boot compatibility range is >=2.0.0.RELEASE and <2.3.0.M1");
}
+ @ParameterizedTest
+ @ValueSource(strings = { "not-a-version", "", "99999999999999", "2.99999999999999" })
+ void convertWhenSpringBootVersionIsNotAVersionShouldThrowException(String bootVersion) {
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion(bootVersion);
+ assertThatExceptionOfType(InvalidProjectRequestException.class)
+ .isThrownBy(() -> this.converter.convert(request, this.metadata))
+ .withMessage("Invalid Spring Boot version '" + bootVersion + "' check project metadata");
+ }
+
+ @Test
+ void convertWhenSpringBootVersionIsNotSetShouldUseDefault() {
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion(null);
+ ProjectDescription description = this.converter.convert(request, this.metadata);
+ assertThat(description.getPlatformVersion()).isEqualTo(Version.parse("2.4.1"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "2", "2.x", "2.x.x", "2.4", "2.4.x" })
+ void convertShouldResolveWildcardSpringBootVersion(String bootVersion) {
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion(bootVersion);
+ ProjectDescription description = this.converter.convert(request, this.metadata);
+ assertThat(description.getPlatformVersion()).isEqualTo(Version.parse("2.4.1"));
+ }
+
+ @Test
+ void convertShouldResolveWildcardSpringBootVersionToVersionWithQualifier() {
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion("2.2.x");
+ ProjectDescription description = this.converter.convert(request, this.metadata);
+ assertThat(description.getPlatformVersion()).isEqualTo(Version.parse("2.2.17.RELEASE"));
+ }
+
+ @Test
+ void convertWhenWildcardSpringBootVersionDoesNotMatchShouldThrowException() {
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion("2.6.x");
+ assertThatExceptionOfType(InvalidProjectRequestException.class)
+ .isThrownBy(() -> this.converter.convert(request, this.metadata))
+ .withMessage("Invalid Spring Boot version '2.6.x' check project metadata");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "2.x.3", "2.4.x-M1" })
+ void convertWhenSpringBootVersionUsesUnsupportedWildcardShouldThrowException(String bootVersion) {
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion(bootVersion);
+ assertThatExceptionOfType(InvalidProjectRequestException.class)
+ .isThrownBy(() -> this.converter.convert(request, this.metadata))
+ .withMessage("Invalid Spring Boot version '" + bootVersion + "' check project metadata");
+ }
+
+ @Test
+ void convertWhenWildcardSpringBootVersionResolvesToIncompatibleVersionShouldThrowException() {
+ this.metadata = InitializrMetadataTestBuilder.withDefaults()
+ .setPlatformCompatibilityRange("[2.0.0.RELEASE,2.3.0.M1)")
+ .build();
+ ProjectRequest request = createProjectRequest();
+ request.setBootVersion("2.x.x");
+ assertThatExceptionOfType(InvalidProjectRequestException.class)
+ .isThrownBy(() -> this.converter.convert(request, this.metadata))
+ .withMessage(
+ "Invalid Spring Boot version '2.4.1', Spring Boot compatibility range is >=2.0.0.RELEASE and <2.3.0.M1");
+ }
+
@Test
void convertWhenPackagingIsInvalidShouldThrowException() {
ProjectRequest request = createProjectRequest();
diff --git a/initializr-web/src/test/resources/application-test-platform-range.yml b/initializr-web/src/test/resources/application-test-platform-range.yml
new file mode 100644
index 0000000000..b9cae29ac6
--- /dev/null
+++ b/initializr-web/src/test/resources/application-test-platform-range.yml
@@ -0,0 +1,4 @@
+initializr:
+ env:
+ platform:
+ compatibility-range: "[2.4.0,2.5.0-SNAPSHOT)"