From 2b71ba0ccdd917139be3086e5f6db8488f8492c6 Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Fri, 15 May 2026 20:58:45 +0900 Subject: [PATCH 1/2] Allow major and minor versions for bootVersion parameter See gh-1797 Signed-off-by: sijun-yang --- .../generator/version/VersionParser.java | 94 ++++++++++++++++--- .../generator/version/VersionParserTests.java | 62 ++++++++++++ .../metadata/InitializrMetadata.java | 14 +-- .../metadata/VersionCapability.java | 65 +++++++++++++ .../metadata/InitializrMetadataTests.java | 13 +++ .../metadata/VersionCapabilityTests.java | 85 +++++++++++++++++ .../controller/ProjectMetadataController.java | 2 +- ...tProjectRequestToDescriptionConverter.java | 4 +- ...ectMetadataControllerIntegrationTests.java | 7 ++ ...ectRequestToDescriptionConverterTests.java | 18 ++++ 10 files changed, 337 insertions(+), 27 deletions(-) create mode 100644 initializr-metadata/src/main/java/io/spring/initializr/metadata/VersionCapability.java create mode 100644 initializr-metadata/src/test/java/io/spring/initializr/metadata/VersionCapabilityTests.java 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..371583b697 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 @@ -18,6 +18,7 @@ import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -67,19 +68,14 @@ public VersionParser(List latestVersions) { * @see #safeParse(java.lang.String) */ public Version parse(String text) { - Assert.notNull(text, "Text must not be null"); - Matcher matcher = VERSION_REGEX.matcher(text.trim()); - if (!matcher.matches()) { - throw new InvalidVersionException("Could not determine version based on '" + text + "': version format " - + "is Major.Minor.Patch and an optional Qualifier " + "(e.g. 1.0.5.RELEASE)"); - } + Matcher matcher = getVersionMatcher(text); Integer major = Integer.valueOf(matcher.group(1)); 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)); - Version latest = findLatestVersion(major, minorInt, qualifier); + Version latest = findUniqueVersion(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); @@ -102,6 +98,37 @@ public Version parse(String text) { return null; } + /** + * Parse the string representation of a {@link Version}, resolving variable minor or + * patch values to the latest matching configured version. Throws an + * {@link InvalidVersionException} if the version could not be parsed or no latest + * version could be found. + * @param text the version text + * @return a Version instance for the specified version text + * @throws InvalidVersionException if the version text could not be parsed or resolved + * @see #parse(java.lang.String) + */ + public Version parseLatest(String text) { + Matcher matcher = getVersionMatcher(text); + String minor = matcher.group(2); + String patch = matcher.group(3); + if (!"x".equals(minor) && !"x".equals(patch)) { + return parse(text); + } + if ("x".equals(minor) && !"x".equals(patch)) { + throw new InvalidVersionException("Could not determine latest version based on '" + text + + "': wildcard minor requires wildcard patch"); + } + Integer major = Integer.valueOf(matcher.group(1)); + Integer minorInt = ("x".equals(minor) ? null : Integer.parseInt(minor)); + Qualifier qualifier = parseQualifier(matcher); + Version latest = findLatestVersion(major, minorInt, qualifier); + if (latest == null) { + throw new InvalidVersionException("Could not determine latest version based on '" + text + "'"); + } + return latest; + } + /** * Parse safely the specified string representation of a {@link Version}. *

@@ -119,6 +146,25 @@ public Version parse(String text) { } } + /** + * Parse safely the specified string representation of a {@link Version}, resolving + * variable minor or patch values to the latest matching configured version. + *

+ * Return {@code null} if the text represents an invalid version or cannot be + * resolved. + * @param text the version text + * @return a Version instance for the specified version text + * @see #parseLatest(java.lang.String) + */ + public @Nullable Version safeParseLatest(String text) { + try { + return parseLatest(text); + } + catch (InvalidVersionException ex) { + return null; + } + } + /** * Parse the string representation of a {@link VersionRange}. Throws an * {@link InvalidVersionException} if the range could not be parsed. @@ -141,21 +187,43 @@ public VersionRange parseRange(String text) { return new VersionRange(lowerVersion, lowerInclusive, higherVersion, higherInclusive); } - private @Nullable Version findLatestVersion(@Nullable Integer major, @Nullable Integer minor, - Version.@Nullable Qualifier qualifier) { - List matches = this.latestVersions.stream().filter((it) -> { - if (major != null && !major.equals(it.getMajor())) { + private Matcher getVersionMatcher(String text) { + Assert.notNull(text, "Text must not be null"); + Matcher matcher = VERSION_REGEX.matcher(text.trim()); + if (!matcher.matches()) { + throw new InvalidVersionException("Could not determine version based on '" + text + "': version format " + + "is Major.Minor.Patch and an optional Qualifier " + "(e.g. 1.0.5.RELEASE)"); + } + return matcher; + } + + private @Nullable Version findLatestVersion(Integer major, @Nullable Integer minor, @Nullable Qualifier qualifier) { + List matches = findMatchingVersions(major, minor, qualifier, true); + return matches.stream().max(Version::compareTo).orElse(null); + } + + private @Nullable Version findUniqueVersion(Integer major, @Nullable Integer minor, @Nullable Qualifier qualifier) { + List matches = findMatchingVersions(major, minor, qualifier, false); + return (matches.size() != 1) ? null : matches.get(0); + } + + private List findMatchingVersions(Integer major, @Nullable Integer minor, @Nullable Qualifier qualifier, + boolean exactQualifier) { + return this.latestVersions.stream().filter((it) -> { + if (!major.equals(it.getMajor())) { return false; } if (minor != null && !minor.equals(it.getMinor())) { return false; } - if (qualifier != null && !qualifier.equals(it.getQualifier())) { + if (exactQualifier && !Objects.equals(qualifier, it.getQualifier())) { + return false; + } + if (!exactQualifier && qualifier != null && !qualifier.equals(it.getQualifier())) { return false; } return true; }).toList(); - return (matches.size() != 1) ? null : matches.get(0); } } 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..a0f8060573 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 @@ -74,6 +74,11 @@ void safeParseInvalidVersion() { assertThat(this.parser.safeParse("foo")).isNull(); } + @Test + void safeParseLatestReturnsNullForInvalidVersion() { + assertThat(this.parser.safeParseLatest("foo")).isNull(); + } + @Test void parseVersionWithSpaces() { assertThat(this.parser.parse(" 1.2.0.RC3 ")).isLessThan(this.parser.parse("1.3.0.RELEASE")); @@ -127,6 +132,63 @@ void parseVariableVersionNoQualifierNoMatch() { assertThat(this.parser.parse("1.2.x").toString()).isEqualTo("1.2.999"); } + @Test + void parseLatestParsesExactVersion() { + assertThat(this.parser.parseLatest("1.2.0").toString()).isEqualTo("1.2.0"); + } + + @Test + void parseLatestResolvesPatchWildcardToLatestMatchingVersion() { + List currentVersions = Arrays.asList(this.parser.parse("1.3.8"), this.parser.parse("1.3.9"), + this.parser.parse("1.4.0")); + this.parser = new VersionParser(currentVersions); + assertThat(this.parser.parseLatest("1.3.x").toString()).isEqualTo("1.3.9"); + } + + @Test + void parseLatestResolvesMinorAndPatchWildcardsToLatestMatchingVersion() { + List currentVersions = Arrays.asList(this.parser.parse("1.3.8"), this.parser.parse("1.4.0")); + this.parser = new VersionParser(currentVersions); + assertThat(this.parser.parseLatest("1.x.x").toString()).isEqualTo("1.4.0"); + } + + @Test + void parseLatestResolvesPatchWildcardWithQualifier() { + List currentVersions = Arrays.asList(this.parser.parse("1.3.8.GA2"), this.parser.parse("1.3.9.GA2"), + this.parser.parse("1.4.0.GA2")); + this.parser = new VersionParser(currentVersions); + assertThat(this.parser.parseLatest("1.3.x.GA2").toString()).isEqualTo("1.3.9.GA2"); + } + + @Test + void parseLatestResolvesUnqualifiedWildcardVersion() { + List currentVersions = Arrays.asList(this.parser.parse("3.0.1"), this.parser.parse("3.0.2-SNAPSHOT"), + this.parser.parse("3.0.0-M1")); + this.parser = new VersionParser(currentVersions); + assertThat(this.parser.parseLatest("3.x.x").toString()).isEqualTo("3.0.1"); + } + + @Test + void parseLatestRejectsWildcardMinorWithSpecificPatch() { + assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> this.parser.parseLatest("5.x.3")) + .withMessage("Could not determine latest version based on '5.x.3': wildcard minor requires wildcard patch"); + } + + @Test + void parseLatestThrowsExceptionWhenNoVersionMatches() { + List currentVersions = Arrays.asList(this.parser.parse("1.3.8"), this.parser.parse("1.4.0")); + this.parser = new VersionParser(currentVersions); + assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> this.parser.parseLatest("2.x.x")) + .withMessage("Could not determine latest version based on '2.x.x'"); + } + + @Test + void safeParseLatestReturnsNullWhenNoVersionMatches() { + List currentVersions = Arrays.asList(this.parser.parse("1.3.8"), this.parser.parse("1.4.0")); + this.parser = new VersionParser(currentVersions); + assertThat(this.parser.safeParseLatest("2.x.x")).isNull(); + } + @Test void invalidRange() { assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> this.parser.parseRange("foo-bar")); 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..1fd350d3a7 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 @@ -20,13 +20,10 @@ import java.util.List; import java.util.Map; -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; - /** * Meta-data used to generate a project. * @@ -41,7 +38,7 @@ public class InitializrMetadata { private final TypeCapability types = new TypeCapability(); - private final SingleSelectCapability bootVersions = new SingleSelectCapability("bootVersion", "Spring Boot Version", + private final VersionCapability bootVersions = new VersionCapability("bootVersion", "Spring Boot Version", "spring boot version"); private final SingleSelectCapability packagings = new SingleSelectCapability("packaging", "Packaging", @@ -88,7 +85,7 @@ public TypeCapability getTypes() { return this.types; } - public SingleSelectCapability getBootVersions() { + public VersionCapability getBootVersions() { return this.bootVersions; } @@ -211,12 +208,7 @@ public void validate() { */ public void updateSpringBootVersions(List versionsMetadata) { this.bootVersions.setContent(versionsMetadata); - List bootVersions = this.bootVersions.getContent().stream().map((it) -> { - String id = it.getId(); - Assert.state(id != null, "'id' must not be null"); - return Version.parse(id); - }).toList(); - VersionParser parser = new VersionParser(bootVersions); + VersionParser parser = this.bootVersions.getVersionParser(); this.dependencies.updateCompatibilityRange(parser); this.configuration.getEnv().updateCompatibilityRange(parser); } diff --git a/initializr-metadata/src/main/java/io/spring/initializr/metadata/VersionCapability.java b/initializr-metadata/src/main/java/io/spring/initializr/metadata/VersionCapability.java new file mode 100644 index 0000000000..0efe25bf2b --- /dev/null +++ b/initializr-metadata/src/main/java/io/spring/initializr/metadata/VersionCapability.java @@ -0,0 +1,65 @@ +/* + * 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.metadata; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.spring.initializr.generator.version.Version; +import io.spring.initializr.generator.version.VersionParser; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; + +/** + * A single select capability whose values can be parsed as versions. + * + * @author Sijun Yang + */ +public class VersionCapability extends SingleSelectCapability { + + @JsonCreator + VersionCapability(@JsonProperty("id") String id) { + this(id, null, null); + } + + public VersionCapability(String id, @Nullable String title, @Nullable String description) { + super(id, title, description); + } + + public Version parseVersion(String text) { + return getVersionParser().parseLatest(text); + } + + public @Nullable Version safeParseVersion(@Nullable String text) { + if (text == null) { + return null; + } + return getVersionParser().safeParseLatest(text); + } + + protected VersionParser getVersionParser() { + List versions = getContent().stream().map((it) -> { + String id = it.getId(); + Assert.state(id != null, "'id' must not be null"); + return Version.parse(id); + }).toList(); + return new VersionParser(versions); + } + +} 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..592c2ee276 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 @@ -191,6 +191,19 @@ void updateSpringBootVersions() { .resolveKotlinVersion(Version.parse("1.3.7.BUILD-SNAPSHOT"))).isEqualTo("1.2"); } + @Test + void bootVersionsParseWildcardToLatestMatchingVersion() { + InitializrMetadata metadata = initializeMetadata(); + List bootVersions = Arrays.asList(DefaultMetadataElement.create("1.3.6", false), + DefaultMetadataElement.create("1.3.7", false), DefaultMetadataElement.create("1.4.0", false)); + metadata.updateSpringBootVersions(bootVersions); + assertThat(metadata.getBootVersions().parseVersion("1.3.x")).hasToString("1.3.7"); + + metadata.updateSpringBootVersions(List.of(DefaultMetadataElement.create("1.3.8", false))); + assertThat(metadata.getBootVersions().parseVersion("1.3.x")).hasToString("1.3.8"); + assertThat(metadata.getBootVersions().safeParseVersion("2.x.x")).isNull(); + } + @Test void invalidParentMissingVersion() { InitializrMetadata metadata = initializeMetadata(); diff --git a/initializr-metadata/src/test/java/io/spring/initializr/metadata/VersionCapabilityTests.java b/initializr-metadata/src/test/java/io/spring/initializr/metadata/VersionCapabilityTests.java new file mode 100644 index 0000000000..baee43160a --- /dev/null +++ b/initializr-metadata/src/test/java/io/spring/initializr/metadata/VersionCapabilityTests.java @@ -0,0 +1,85 @@ +/* + * 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.metadata; + +import java.util.Arrays; +import java.util.List; + +import io.spring.initializr.generator.version.InvalidVersionException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link VersionCapability}. + */ +class VersionCapabilityTests { + + @Test + void parseVersionResolvesWildcardToLatestMatchingVersion() { + VersionCapability capability = new VersionCapability("test", "test", "test"); + capability.setContent(Arrays.asList(DefaultMetadataElement.create("1.3.6", false), + DefaultMetadataElement.create("1.3.7", false), DefaultMetadataElement.create("1.4.0", false))); + assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.7"); + } + + @Test + void parseVersionThrowsExceptionWhenNoVersionMatches() { + VersionCapability capability = new VersionCapability("test", "test", "test"); + capability.setContent(Arrays.asList(DefaultMetadataElement.create("1.3.6", false), + DefaultMetadataElement.create("1.4.0", false))); + assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> capability.parseVersion("2.x.x")) + .withMessage("Could not determine latest version based on '2.x.x'"); + } + + @Test + void safeParseVersionReturnsNullWhenVersionCannotBeParsed() { + VersionCapability capability = new VersionCapability("test", "test", "test"); + capability.setContent(Arrays.asList(DefaultMetadataElement.create("1.3.6", false), + DefaultMetadataElement.create("1.4.0", false))); + assertThat(capability.safeParseVersion(null)).isNull(); + assertThat(capability.safeParseVersion("invalid")).isNull(); + assertThat(capability.safeParseVersion("2.x.x")).isNull(); + } + + @Test + void parseVersionUsesAddedContent() { + VersionCapability capability = new VersionCapability("test", "test", "test"); + capability.addContent(DefaultMetadataElement.create("1.3.7", false)); + assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.7"); + } + + @Test + void parseVersionUsesReplacedContent() { + VersionCapability capability = new VersionCapability("test", "test", "test"); + capability.setContent(List.of(DefaultMetadataElement.create("1.3.7", false))); + assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.7"); + + capability.setContent(List.of(DefaultMetadataElement.create("1.3.8", false))); + assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.8"); + } + + @Test + void parseVersionUsesMergedContent() { + VersionCapability capability = new VersionCapability("test", "test", "test"); + capability.setContent(List.of(DefaultMetadataElement.create("1.3.7", false))); + capability.merge(List.of(DefaultMetadataElement.create("1.3.8", false))); + assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.8"); + } + +} 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..c5d165f621 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,7 +150,7 @@ 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) + Version effectiveBootVersion = (bootVersion != null) ? metadata.getBootVersions().parseVersion(bootVersion) : getDefaultBootVersion(metadata); Platform platform = metadata.getConfiguration().getEnv().getPlatform(); if (!platform.isCompatibleVersion(effectiveBootVersion)) { 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..ed8b7f88ae 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 @@ -140,7 +140,7 @@ private void validateBaseDir(ProjectRequest request) { } private void validatePlatformVersion(ProjectRequest request, InitializrMetadata metadata) { - Version platformVersion = Version.safeParse(request.getBootVersion()); + Version platformVersion = metadata.getBootVersions().safeParseVersion(request.getBootVersion()); Platform platform = metadata.getConfiguration().getEnv().getPlatform(); if (platformVersion != null && !platform.isCompatibleVersion(platformVersion)) { throw new InvalidProjectRequestException("Invalid Spring Boot version '" + platformVersion @@ -225,7 +225,7 @@ private BuildSystem getBuildSystem(ProjectRequest request, InitializrMetadata me private Version getPlatformVersion(ProjectRequest request, InitializrMetadata metadata) { String versionText = (request.getBootVersion() != null) ? request.getBootVersion() : getDefaultBootVersion(metadata); - Version version = Version.parse(versionText); + Version version = metadata.getBootVersions().parseVersion(versionText); return this.platformVersionTransformer.transform(version, metadata); } 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..ffbf80c92a 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 @@ -91,6 +91,13 @@ void metadataWithInvalidPlatformVersion() { } } + @Test + void dependenciesWithWildcardBootVersion() { + 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 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..609f16ca6d 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 @@ -21,6 +21,7 @@ import io.spring.initializr.generator.buildsystem.gradle.GradleBuildSystem; import io.spring.initializr.generator.project.ProjectDescription; import io.spring.initializr.generator.test.InitializrMetadataTestBuilder; +import io.spring.initializr.generator.version.InvalidVersionException; import io.spring.initializr.generator.version.Version; import io.spring.initializr.generator.version.VersionRange; import io.spring.initializr.metadata.Dependency; @@ -203,6 +204,15 @@ void convertWhenSpringBootVersionInvalidShouldThrowException() { "Invalid Spring Boot version '1.5.9.RELEASE', Spring Boot compatibility range is >=2.0.0.RELEASE and <2.3.0.M1"); } + @Test + void convertWhenWildcardSpringBootVersionDoesNotMatchShouldThrowException() { + ProjectRequest request = createProjectRequest(); + request.setBootVersion("2.6.x"); + assertThatExceptionOfType(InvalidVersionException.class) + .isThrownBy(() -> this.converter.convert(request, this.metadata)) + .withMessage("Could not determine latest version based on '2.6.x'"); + } + @Test void convertWhenPackagingIsInvalidShouldThrowException() { ProjectRequest request = createProjectRequest(); @@ -315,6 +325,14 @@ void convertShouldSetPlatformVersionFromRequest() { assertThat(description.getPlatformVersion()).isEqualTo(Version.parse("2.0.3")); } + @Test + void convertShouldSetLatestMatchingPlatformVersionFromWildcardBootVersion() { + ProjectRequest request = createProjectRequest(); + request.setBootVersion("2.4.x"); + ProjectDescription description = this.converter.convert(request, this.metadata); + assertThat(description.getPlatformVersion()).isEqualTo(Version.parse("2.4.1")); + } + @Test void convertShouldSetLanguageForProjectDescriptionFromRequest() { ProjectRequest request = createProjectRequest(); From 30f04b6c4fe5f5e00071d45a7db3e9fa510bbb51 Mon Sep 17 00:00:00 2001 From: Moritz Halbritter Date: Fri, 14 Aug 2026 09:53:41 +0200 Subject: [PATCH 2/2] Polish "Allow major and minor versions for bootVersion parameter" Resolve the abbreviated forms in VersionParser.resolveLatest, next to parse, and accept `4` and `4.0` as well. The latest general availability version wins, so milestones and snapshots are ignored. parse is left alone on purpose. Its `x` support was added in gh-328 for compatibility ranges in the metadata, where an unresolved wildcard becomes a 999 sentinel so that a range keeps covering a whole line. That is a reasonable bound, but a fabricated coordinate for a request, and since Version.parse has no latest versions to resolve against it was the only possible outcome there: bootVersion=2.3.x used to generate a project with parent 2.3.999. resolveLatest returns null instead, so the request is rejected with a 400. When a resolved version falls outside the platform compatibility range, /dependencies now names that version in the error rather than the abbreviated form that was requested. Version numbers are now capped at nine digits, turning an oversized bootVersion into a 400 rather than a NumberFormatException. See gh-1797 --- .../stat/ProjectRequestDocumentFactory.java | 11 +- .../ProjectRequestDocumentFactoryTests.java | 20 +++ .../main/asciidoc/configuration-guide.adoc | 23 +++ .../initializr/generator/version/Version.java | 9 ++ .../generator/version/VersionParser.java | 144 ++++++++---------- .../generator/version/VersionParserTests.java | 107 ++++++++----- .../generator/version/VersionTests.java | 14 ++ .../metadata/InitializrMetadata.java | 62 +++++++- .../metadata/VersionCapability.java | 65 -------- .../metadata/InitializrMetadataTests.java | 46 ++++-- .../metadata/VersionCapabilityTests.java | 85 ----------- .../controller/ProjectMetadataController.java | 17 ++- ...tProjectRequestToDescriptionConverter.java | 35 +++-- .../PlatformVersionRangeIntegrationTests.java | 63 ++++++++ ...tGenerationControllerIntegrationTests.java | 20 +++ ...ectMetadataControllerIntegrationTests.java | 24 ++- ...ectRequestToDescriptionConverterTests.java | 73 +++++++-- .../application-test-platform-range.yml | 4 + 18 files changed, 506 insertions(+), 316 deletions(-) delete mode 100644 initializr-metadata/src/main/java/io/spring/initializr/metadata/VersionCapability.java delete mode 100644 initializr-metadata/src/test/java/io/spring/initializr/metadata/VersionCapabilityTests.java create mode 100644 initializr-web/src/test/java/io/spring/initializr/web/controller/PlatformVersionRangeIntegrationTests.java create mode 100644 initializr-web/src/test/resources/application-test-platform-range.yml 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 371583b697..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 @@ -18,7 +18,6 @@ import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -48,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("([(\\[])(.*),(.*)([)\\]])"); @@ -68,17 +75,22 @@ public VersionParser(List latestVersions) { * @see #safeParse(java.lang.String) */ public Version parse(String text) { - Matcher matcher = getVersionMatcher(text); + Assert.notNull(text, "Text must not be null"); + Matcher matcher = VERSION_REGEX.matcher(text.trim()); + if (!matcher.matches()) { + throw new InvalidVersionException("Could not determine version based on '" + text + "': version format " + + "is Major.Minor.Patch and an optional Qualifier " + "(e.g. 1.0.5.RELEASE)"); + } Integer major = Integer.valueOf(matcher.group(1)); 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)); - Version latest = findUniqueVersion(major, minorInt, qualifier); + 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()); } @@ -98,37 +110,6 @@ public Version parse(String text) { return null; } - /** - * Parse the string representation of a {@link Version}, resolving variable minor or - * patch values to the latest matching configured version. Throws an - * {@link InvalidVersionException} if the version could not be parsed or no latest - * version could be found. - * @param text the version text - * @return a Version instance for the specified version text - * @throws InvalidVersionException if the version text could not be parsed or resolved - * @see #parse(java.lang.String) - */ - public Version parseLatest(String text) { - Matcher matcher = getVersionMatcher(text); - String minor = matcher.group(2); - String patch = matcher.group(3); - if (!"x".equals(minor) && !"x".equals(patch)) { - return parse(text); - } - if ("x".equals(minor) && !"x".equals(patch)) { - throw new InvalidVersionException("Could not determine latest version based on '" + text - + "': wildcard minor requires wildcard patch"); - } - Integer major = Integer.valueOf(matcher.group(1)); - Integer minorInt = ("x".equals(minor) ? null : Integer.parseInt(minor)); - Qualifier qualifier = parseQualifier(matcher); - Version latest = findLatestVersion(major, minorInt, qualifier); - if (latest == null) { - throw new InvalidVersionException("Could not determine latest version based on '" + text + "'"); - } - return latest; - } - /** * Parse safely the specified string representation of a {@link Version}. *

@@ -147,22 +128,53 @@ public Version parseLatest(String text) { } /** - * Parse safely the specified string representation of a {@link Version}, resolving - * variable minor or patch values to the latest matching configured version. + * 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)}. *

- * Return {@code null} if the text represents an invalid version or cannot be - * resolved. + * 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 - * @see #parseLatest(java.lang.String) + * @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 safeParseLatest(String text) { - try { - return parseLatest(text); + 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); } - catch (InvalidVersionException ex) { + 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); } /** @@ -187,43 +199,21 @@ public VersionRange parseRange(String text) { return new VersionRange(lowerVersion, lowerInclusive, higherVersion, higherInclusive); } - private Matcher getVersionMatcher(String text) { - Assert.notNull(text, "Text must not be null"); - Matcher matcher = VERSION_REGEX.matcher(text.trim()); - if (!matcher.matches()) { - throw new InvalidVersionException("Could not determine version based on '" + text + "': version format " - + "is Major.Minor.Patch and an optional Qualifier " + "(e.g. 1.0.5.RELEASE)"); - } - return matcher; - } - - private @Nullable Version findLatestVersion(Integer major, @Nullable Integer minor, @Nullable Qualifier qualifier) { - List matches = findMatchingVersions(major, minor, qualifier, true); - return matches.stream().max(Version::compareTo).orElse(null); - } - - private @Nullable Version findUniqueVersion(Integer major, @Nullable Integer minor, @Nullable Qualifier qualifier) { - List matches = findMatchingVersions(major, minor, qualifier, false); - return (matches.size() != 1) ? null : matches.get(0); - } - - private List findMatchingVersions(Integer major, @Nullable Integer minor, @Nullable Qualifier qualifier, - boolean exactQualifier) { - return this.latestVersions.stream().filter((it) -> { - if (!major.equals(it.getMajor())) { + private @Nullable Version findLatestVersion(@Nullable Integer major, @Nullable Integer minor, + Version.@Nullable Qualifier qualifier) { + List matches = this.latestVersions.stream().filter((it) -> { + if (major != null && !major.equals(it.getMajor())) { return false; } if (minor != null && !minor.equals(it.getMinor())) { return false; } - if (exactQualifier && !Objects.equals(qualifier, it.getQualifier())) { - return false; - } - if (!exactQualifier && qualifier != null && !qualifier.equals(it.getQualifier())) { + if (qualifier != null && !qualifier.equals(it.getQualifier())) { return false; } return true; }).toList(); + return (matches.size() != 1) ? null : matches.get(0); } } 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 a0f8060573..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; @@ -74,11 +76,6 @@ void safeParseInvalidVersion() { assertThat(this.parser.safeParse("foo")).isNull(); } - @Test - void safeParseLatestReturnsNullForInvalidVersion() { - assertThat(this.parser.safeParseLatest("foo")).isNull(); - } - @Test void parseVersionWithSpaces() { assertThat(this.parser.parse(" 1.2.0.RC3 ")).isLessThan(this.parser.parse("1.3.0.RELEASE")); @@ -133,60 +130,88 @@ void parseVariableVersionNoQualifierNoMatch() { } @Test - void parseLatestParsesExactVersion() { - assertThat(this.parser.parseLatest("1.2.0").toString()).isEqualTo("1.2.0"); + void parseVersionWithLargestNumbersThatFitInAnInteger() { + assertThat(this.parser.parse("999999999.999999999.999999999.M999999999")) + .hasToString("999999999.999999999.999999999.M999999999"); } - @Test - void parseLatestResolvesPatchWildcardToLatestMatchingVersion() { - List currentVersions = Arrays.asList(this.parser.parse("1.3.8"), this.parser.parse("1.3.9"), - this.parser.parse("1.4.0")); - this.parser = new VersionParser(currentVersions); - assertThat(this.parser.parseLatest("1.3.x").toString()).isEqualTo("1.3.9"); + @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 parseLatestResolvesMinorAndPatchWildcardsToLatestMatchingVersion() { - List currentVersions = Arrays.asList(this.parser.parse("1.3.8"), this.parser.parse("1.4.0")); - this.parser = new VersionParser(currentVersions); - assertThat(this.parser.parseLatest("1.x.x").toString()).isEqualTo("1.4.0"); + void resolveLatestWithExactVersionParsesIt() { + this.parser = createParser("4.0.1", "4.1.2"); + assertThat(this.parser.resolveLatest("4.0.1")).hasToString("4.0.1"); } @Test - void parseLatestResolvesPatchWildcardWithQualifier() { - List currentVersions = Arrays.asList(this.parser.parse("1.3.8.GA2"), this.parser.parse("1.3.9.GA2"), - this.parser.parse("1.4.0.GA2")); - this.parser = new VersionParser(currentVersions); - assertThat(this.parser.parseLatest("1.3.x.GA2").toString()).isEqualTo("1.3.9.GA2"); + 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 parseLatestResolvesUnqualifiedWildcardVersion() { - List currentVersions = Arrays.asList(this.parser.parse("3.0.1"), this.parser.parse("3.0.2-SNAPSHOT"), - this.parser.parse("3.0.0-M1")); - this.parser = new VersionParser(currentVersions); - assertThat(this.parser.parseLatest("3.x.x").toString()).isEqualTo("3.0.1"); + 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 parseLatestRejectsWildcardMinorWithSpecificPatch() { - assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> this.parser.parseLatest("5.x.3")) - .withMessage("Could not determine latest version based on '5.x.3': wildcard minor requires wildcard patch"); + 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 parseLatestThrowsExceptionWhenNoVersionMatches() { - List currentVersions = Arrays.asList(this.parser.parse("1.3.8"), this.parser.parse("1.4.0")); - this.parser = new VersionParser(currentVersions); - assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> this.parser.parseLatest("2.x.x")) - .withMessage("Could not determine latest version based on '2.x.x'"); + 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 safeParseLatestReturnsNullWhenNoVersionMatches() { - List currentVersions = Arrays.asList(this.parser.parse("1.3.8"), this.parser.parse("1.4.0")); - this.parser = new VersionParser(currentVersions); - assertThat(this.parser.safeParseLatest("2.x.x")).isNull(); + 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 @@ -194,4 +219,8 @@ 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 1fd350d3a7..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,11 +19,17 @@ 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. * @@ -38,7 +44,7 @@ public class InitializrMetadata { private final TypeCapability types = new TypeCapability(); - private final VersionCapability bootVersions = new VersionCapability("bootVersion", "Spring Boot Version", + private final SingleSelectCapability bootVersions = new SingleSelectCapability("bootVersion", "Spring Boot Version", "spring boot version"); private final SingleSelectCapability packagings = new SingleSelectCapability("packaging", "Packaging", @@ -85,7 +91,7 @@ public TypeCapability getTypes() { return this.types; } - public VersionCapability getBootVersions() { + public SingleSelectCapability getBootVersions() { return this.bootVersions; } @@ -204,15 +210,65 @@ 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); - VersionParser parser = this.bootVersions.getVersionParser(); + List bootVersions = this.bootVersions.getContent().stream().map((it) -> { + String id = it.getId(); + Assert.state(id != null, "'id' must not be null"); + return Version.parse(id); + }).toList(); + VersionParser parser = new VersionParser(bootVersions); this.dependencies.updateCompatibilityRange(parser); 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/main/java/io/spring/initializr/metadata/VersionCapability.java b/initializr-metadata/src/main/java/io/spring/initializr/metadata/VersionCapability.java deleted file mode 100644 index 0efe25bf2b..0000000000 --- a/initializr-metadata/src/main/java/io/spring/initializr/metadata/VersionCapability.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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.metadata; - -import java.util.List; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import io.spring.initializr.generator.version.Version; -import io.spring.initializr.generator.version.VersionParser; -import org.jspecify.annotations.Nullable; - -import org.springframework.util.Assert; - -/** - * A single select capability whose values can be parsed as versions. - * - * @author Sijun Yang - */ -public class VersionCapability extends SingleSelectCapability { - - @JsonCreator - VersionCapability(@JsonProperty("id") String id) { - this(id, null, null); - } - - public VersionCapability(String id, @Nullable String title, @Nullable String description) { - super(id, title, description); - } - - public Version parseVersion(String text) { - return getVersionParser().parseLatest(text); - } - - public @Nullable Version safeParseVersion(@Nullable String text) { - if (text == null) { - return null; - } - return getVersionParser().safeParseLatest(text); - } - - protected VersionParser getVersionParser() { - List versions = getContent().stream().map((it) -> { - String id = it.getId(); - Assert.state(id != null, "'id' must not be null"); - return Version.parse(id); - }).toList(); - return new VersionParser(versions); - } - -} 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 592c2ee276..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 @@ -191,19 +191,6 @@ void updateSpringBootVersions() { .resolveKotlinVersion(Version.parse("1.3.7.BUILD-SNAPSHOT"))).isEqualTo("1.2"); } - @Test - void bootVersionsParseWildcardToLatestMatchingVersion() { - InitializrMetadata metadata = initializeMetadata(); - List bootVersions = Arrays.asList(DefaultMetadataElement.create("1.3.6", false), - DefaultMetadataElement.create("1.3.7", false), DefaultMetadataElement.create("1.4.0", false)); - metadata.updateSpringBootVersions(bootVersions); - assertThat(metadata.getBootVersions().parseVersion("1.3.x")).hasToString("1.3.7"); - - metadata.updateSpringBootVersions(List.of(DefaultMetadataElement.create("1.3.8", false))); - assertThat(metadata.getBootVersions().parseVersion("1.3.x")).hasToString("1.3.8"); - assertThat(metadata.getBootVersions().safeParseVersion("2.x.x")).isNull(); - } - @Test void invalidParentMissingVersion() { InitializrMetadata metadata = initializeMetadata(); @@ -226,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-metadata/src/test/java/io/spring/initializr/metadata/VersionCapabilityTests.java b/initializr-metadata/src/test/java/io/spring/initializr/metadata/VersionCapabilityTests.java deleted file mode 100644 index baee43160a..0000000000 --- a/initializr-metadata/src/test/java/io/spring/initializr/metadata/VersionCapabilityTests.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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.metadata; - -import java.util.Arrays; -import java.util.List; - -import io.spring.initializr.generator.version.InvalidVersionException; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -/** - * Tests for {@link VersionCapability}. - */ -class VersionCapabilityTests { - - @Test - void parseVersionResolvesWildcardToLatestMatchingVersion() { - VersionCapability capability = new VersionCapability("test", "test", "test"); - capability.setContent(Arrays.asList(DefaultMetadataElement.create("1.3.6", false), - DefaultMetadataElement.create("1.3.7", false), DefaultMetadataElement.create("1.4.0", false))); - assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.7"); - } - - @Test - void parseVersionThrowsExceptionWhenNoVersionMatches() { - VersionCapability capability = new VersionCapability("test", "test", "test"); - capability.setContent(Arrays.asList(DefaultMetadataElement.create("1.3.6", false), - DefaultMetadataElement.create("1.4.0", false))); - assertThatExceptionOfType(InvalidVersionException.class).isThrownBy(() -> capability.parseVersion("2.x.x")) - .withMessage("Could not determine latest version based on '2.x.x'"); - } - - @Test - void safeParseVersionReturnsNullWhenVersionCannotBeParsed() { - VersionCapability capability = new VersionCapability("test", "test", "test"); - capability.setContent(Arrays.asList(DefaultMetadataElement.create("1.3.6", false), - DefaultMetadataElement.create("1.4.0", false))); - assertThat(capability.safeParseVersion(null)).isNull(); - assertThat(capability.safeParseVersion("invalid")).isNull(); - assertThat(capability.safeParseVersion("2.x.x")).isNull(); - } - - @Test - void parseVersionUsesAddedContent() { - VersionCapability capability = new VersionCapability("test", "test", "test"); - capability.addContent(DefaultMetadataElement.create("1.3.7", false)); - assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.7"); - } - - @Test - void parseVersionUsesReplacedContent() { - VersionCapability capability = new VersionCapability("test", "test", "test"); - capability.setContent(List.of(DefaultMetadataElement.create("1.3.7", false))); - assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.7"); - - capability.setContent(List.of(DefaultMetadataElement.create("1.3.8", false))); - assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.8"); - } - - @Test - void parseVersionUsesMergedContent() { - VersionCapability capability = new VersionCapability("test", "test", "test"); - capability.setContent(List.of(DefaultMetadataElement.create("1.3.7", false))); - capability.merge(List.of(DefaultMetadataElement.create("1.3.8", false))); - assertThat(capability.parseVersion("1.3.x")).hasToString("1.3.8"); - } - -} 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 c5d165f621..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) ? metadata.getBootVersions().parseVersion(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 ed8b7f88ae..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 = metadata.getBootVersions().safeParseVersion(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 = metadata.getBootVersions().parseVersion(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 ffbf80c92a..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}. @@ -92,12 +95,31 @@ void metadataWithInvalidPlatformVersion() { } @Test - void dependenciesWithWildcardBootVersion() { + 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 609f16ca6d..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 @@ -21,13 +21,14 @@ import io.spring.initializr.generator.buildsystem.gradle.GradleBuildSystem; import io.spring.initializr.generator.project.ProjectDescription; import io.spring.initializr.generator.test.InitializrMetadataTestBuilder; -import io.spring.initializr.generator.version.InvalidVersionException; import io.spring.initializr.generator.version.Version; import io.spring.initializr.generator.version.VersionRange; import io.spring.initializr.metadata.Dependency; 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; @@ -204,13 +205,71 @@ 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(InvalidVersionException.class) + assertThatExceptionOfType(InvalidProjectRequestException.class) .isThrownBy(() -> this.converter.convert(request, this.metadata)) - .withMessage("Could not determine latest version based on '2.6.x'"); + .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 @@ -325,14 +384,6 @@ void convertShouldSetPlatformVersionFromRequest() { assertThat(description.getPlatformVersion()).isEqualTo(Version.parse("2.0.3")); } - @Test - void convertShouldSetLatestMatchingPlatformVersionFromWildcardBootVersion() { - ProjectRequest request = createProjectRequest(); - request.setBootVersion("2.4.x"); - ProjectDescription description = this.converter.convert(request, this.metadata); - assertThat(description.getPlatformVersion()).isEqualTo(Version.parse("2.4.1")); - } - @Test void convertShouldSetLanguageForProjectDescriptionFromRequest() { 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)"