Add version range support for dependencies

Previously, it was not possible to define the Spring Boot version that
a dependency requires. As a result, many new starters implemented as part
of Spring Boot 1.2 are not available.

Each dependency can now define a `versionRange` attribute that defines
the Spring Boot version range that it supports. The range is defined
either as a single version, in which case it defines that version and
any later versions or using brackets. A square bracket (`[` or `]`)
denotes an inclusive range while a round bracket (`(` or `)`) denotes an
exclusive range.

Bumped the current-metadata format to 2.1 to include this additional
`versionRange` attribute, that is

application/vnd.initializr.v2.1+json

Existing clients requesting v2 will not get any dependency that defines
a `versionRange` attribute.

Closes gh-62
This commit is contained in:
Stephane Nicoll 2015-01-27 17:54:30 +01:00
parent 8c0dc68a51
commit da2ced86f3
22 changed files with 840 additions and 52 deletions

View File

@ -7,6 +7,7 @@ order.
=== Release 1.0.0 (In progress)
* https://github.com/spring-io/initializr/issues/62[#62]: add version range support.
* https://github.com/spring-io/initializr/issues/75[#75]: migrate smoke tests to Geb.
* https://github.com/spring-io/initializr/issues/74[#74]: remove support for meta-data V1.
* https://github.com/spring-io/initializr/issues/71[#71]: update project layout for Groovy-based projects.

View File

@ -4,7 +4,7 @@ info:
version: 0.3.1
# remember to update static/install.sh as well:
spring-boot:
version: 1.2.0.RELEASE
version: 1.2.1.RELEASE
initializr:
dependencies:
@ -16,6 +16,14 @@ initializr:
- name: AOP
id: aop
description: Support for aspect-oriented programming including spring-aop and AspectJ
- name: Atomikos (JTA)
id: jta-atomikos
description: Support for JTA distributed transactions via Atomikos
versionRange: 1.2.0.M1
- name: Bitronix (JTA)
id: jta-bitronix
description: Support for JTA distributed transactions via Bitronix
versionRange: 1.2.0.M1
- name: Data
content:
- name: JDBC
@ -55,6 +63,10 @@ initializr:
- name: AMQP
id: amqp
description: Support for the Advanced Message Queuing Protocol via spring-rabbit
- name: Mail
id: mail
description: Support for javax.mail
versionRange: 1.2.0.RC1
- name: Web
content:
- name: Web
@ -68,9 +80,17 @@ initializr:
- name: WS
id: ws
description: Support for Spring Web Services
- name: Jersey (JAX-RS)
id: jersey
description: Support for the Jersey RESTful Web Services framework
versionRange: 1.2.0.RELEASE
- name: Rest Repositories
id: data-rest
description: Support for exposing Spring Data repositories over REST via spring-data-rest-webmvc
- name: HATEOAS
id: hateoas
description: Support for HATEOAS-based RESTful services
versionRange: 1.2.2.BUILD-SNAPSHOT
- name: Mobile
id: mobile
description: Support for spring-mobile
@ -96,6 +116,12 @@ initializr:
description: Support for the Thymeleaf templating engine, including integration with Spring
facets:
- web
- name: Mustache
id: mustache
description: Support for the Mustache templating engine
versionRange: 1.2.2.BUILD-SNAPSHOT
facets:
- web
- name: Social
content:
- name: Facebook
@ -112,6 +138,10 @@ initializr:
- name: Actuator
id: actuator
description: Production ready features to help you monitor and manage your application
- name: Cloud Connectors
id: cloud-connectors
description: Simplifies connecting to services in cloud platforms
versionRange: 1.2.0.RELEASE
- name: Remote Shell
id: remote-shell
description: Support for CRaSH

View File

@ -20,6 +20,11 @@ import javax.annotation.PostConstruct
import groovy.transform.ToString
import groovy.util.logging.Slf4j
import io.spring.initializr.mapper.InitializrMetadataJsonMapper
import io.spring.initializr.mapper.InitializrMetadataV21JsonMapper
import io.spring.initializr.mapper.InitializrMetadataV2JsonMapper
import io.spring.initializr.support.InvalidVersionException
import io.spring.initializr.support.VersionRange
import org.springframework.boot.context.properties.ConfigurationProperties
@ -61,8 +66,6 @@ class InitializrMetadata {
private final Map<String, Dependency> indexedDependencies = [:]
private final transient InitializrMetadataJsonMapper jsonMapper = new InitializrMetadataJsonMapper()
/**
* Return the {@link Dependency} with the specified id or {@code null} if
* no such dependency exists.
@ -127,11 +130,11 @@ class InitializrMetadata {
/**
* Generate a JSON representation of the current metadata
*
* @param version the meta-data version
* @param appUrl the application url
*/
String generateJson(String appUrl) {
jsonMapper.write(this, appUrl)
String generateJson(InitializrMetadataVersion version, String appUrl) {
getJsonMapper(version).write(this, appUrl)
}
/**
@ -193,6 +196,14 @@ class InitializrMetadata {
"Invalid dependency, id should have the form groupId:artifactId[:version] but got $id")
}
}
if (dependency.versionRange) {
try {
VersionRange.parse(dependency.versionRange)
} catch (InvalidVersionException ex) {
throw new InvalidInitializrMetadataException("Invalid version range '$dependency.versionRange' for " +
"dependency with id '$dependency.id'")
}
}
}
static def getDefault(List elements) {
@ -205,6 +216,13 @@ class InitializrMetadata {
return (elements.isEmpty() ? null : elements.get(0).id)
}
private static InitializrMetadataJsonMapper getJsonMapper(InitializrMetadataVersion version) {
switch(version) {
case InitializrMetadataVersion.V2: return new InitializrMetadataV2JsonMapper();
default: return new InitializrMetadataV21JsonMapper();
}
}
static class DependencyGroup {
String name
@ -228,6 +246,8 @@ class InitializrMetadata {
String description
String versionRange
/**
* Specify if the dependency has its coordinates set, i.e. {@code groupId}
* and {@code artifactId}.

View File

@ -0,0 +1,50 @@
/*
* Copyright 2012-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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
import org.springframework.http.MediaType
/**
* Define the supported meta-data version.
*
* @author Stephane Nicoll
* @since 1.0
*/
enum InitializrMetadataVersion {
/**
* HAL-compliant metadata.
*/
V2('application/vnd.initializr.v2+json'),
/**
* Add 'versionRange' attribute to any dependency to specify which
* Spring Boot versions are compatible with it.
*/
V2_1('application/vnd.initializr.v2.1+json')
private final MediaType mediaType;
InitializrMetadataVersion(String mediaType) {
this.mediaType = MediaType.parseMediaType(mediaType)
}
MediaType getMediaType() {
return mediaType
}
}

View File

@ -17,6 +17,8 @@
package io.spring.initializr
import groovy.util.logging.Slf4j
import io.spring.initializr.support.Version
import io.spring.initializr.support.VersionRange
/**
* A request to generate a project.
@ -79,12 +81,21 @@ class ProjectRequest {
}
dependency
}
String actualBootVersion = bootVersion ?: metadata.defaults.bootVersion
Version requestedVersion = Version.parse(actualBootVersion)
resolvedDependencies.each {
it.facets.each {
if (!facets.contains(it)) {
facets.add(it)
}
}
if (it.versionRange) {
def range = VersionRange.parse(it.versionRange)
if (!range.match(requestedVersion)) {
throw new InvalidProjectRequestException("Dependency '$it.id' is not compatible " +
"with Spring Boot $bootVersion")
}
}
}
if (this.type) {

View File

@ -0,0 +1,34 @@
/*
* Copyright 2012-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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.mapper
import io.spring.initializr.InitializrMetadata
/**
* Generate a JSON representation of the metadata.
*
* @author Stephane Nicoll
* @since 1.0
*/
interface InitializrMetadataJsonMapper {
/**
* Write a json representation of the specified meta-data.
*/
String write(InitializrMetadata metadata, String appUrl);
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2012-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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.mapper
/**
* A {@link InitializrMetadataJsonMapper} handling the meta-data format for v2.1
* <p>
* Version 2.1 brings the 'versionRange' attribute for a dependency to restrict
* the Spring Boot versions that can be used against it.
*
* @author Stephane Nicoll
* @since 1.0
*/
class InitializrMetadataV21JsonMapper extends InitializrMetadataV2JsonMapper {
@Override
protected mapDependency(dependency) {
def content = mapValue(dependency)
if (dependency.versionRange) {
content['versionRange'] = dependency.versionRange
}
content
}
}

View File

@ -14,25 +14,27 @@
* limitations under the License.
*/
package io.spring.initializr
package io.spring.initializr.mapper
import groovy.json.JsonBuilder
import io.spring.initializr.InitializrMetadata
import org.springframework.hateoas.TemplateVariable
import org.springframework.hateoas.TemplateVariables
import org.springframework.hateoas.UriTemplate
import org.springframework.util.StringUtils
/**
* Generate a JSON representation of the metadata.
* A {@link InitializrMetadataJsonMapper} handling the meta-data format for v2.
*
* @author Stephane Nicoll
* @since 1.0
*/
class InitializrMetadataJsonMapper {
class InitializrMetadataV2JsonMapper implements InitializrMetadataJsonMapper {
private final TemplateVariables templateVariables
InitializrMetadataJsonMapper() {
InitializrMetadataV2JsonMapper() {
this.templateVariables = new TemplateVariables(
new TemplateVariable('dependencies', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('packaging', TemplateVariable.VariableType.REQUEST_PARAM),
@ -48,6 +50,7 @@ class InitializrMetadataJsonMapper {
)
}
@Override
String write(InitializrMetadata metadata, String appUrl) {
JsonBuilder json = new JsonBuilder()
json {
@ -68,7 +71,7 @@ class InitializrMetadataJsonMapper {
json.toString()
}
private links(parent, types, appUrl) {
protected links(parent, types, appUrl) {
def content = [:]
types.each {
content[it.id] = link(appUrl, it)
@ -76,7 +79,7 @@ class InitializrMetadataJsonMapper {
parent._links content
}
private link(appUrl, type) {
protected link(appUrl, type) {
def result = [:]
result.href = generateTemplatedUri(appUrl, type)
result.templated = true
@ -91,41 +94,40 @@ class InitializrMetadataJsonMapper {
}
private static dependencies(parent, groups) {
protected dependencies(parent, groups) {
parent.dependencies {
type 'hierarchical-multi-select'
values groups.collect {
processDependencyGroup(it)
mapDependencyGroup(it)
}
}
}
}
private static type(parent, defaultValue, dependencies) {
protected type(parent, defaultValue, dependencies) {
parent.type {
type 'action'
if (defaultValue) {
'default' defaultValue
}
values dependencies.collect {
processType(it)
mapType(it)
}
}
}
private static singleSelect(parent, name, defaultValue, itemValues) {
protected singleSelect(parent, name, defaultValue, itemValues) {
parent."$name" {
type 'single-select'
if (defaultValue) {
'default' defaultValue
}
values itemValues.collect {
processValue(it)
mapValue(it)
}
}
}
private static text(parent, name, value) {
protected text(parent, name, value) {
parent."$name" {
type 'text'
if (value) {
@ -134,8 +136,7 @@ class InitializrMetadataJsonMapper {
}
}
private static processDependencyGroup(group) {
protected mapDependencyGroup(group) {
def result = [:]
result.name = group.name
if (group.hasProperty('description') && group.description) {
@ -143,20 +144,29 @@ class InitializrMetadataJsonMapper {
}
def items = []
group.content.collect {
items << processValue(it)
def dependency = mapDependency(it)
if (dependency) {
items << dependency
}
}
result.values = items
result
}
private static processType(type) {
def result = processValue(type)
protected mapDependency(dependency) {
if (!dependency.versionRange) { // only map the dependency if no versionRange is set
mapValue(dependency)
}
}
protected mapType(type) {
def result = mapValue(type)
result.action = type.action
result.tags = type.tags
result
}
private static processValue(value) {
protected mapValue(value) {
def result = [:]
result.id = value.id
result.name = value.name

View File

@ -0,0 +1,29 @@
/*
* Copyright 2012-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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.support
import groovy.transform.InheritConstructors
/**
* Thrown if a input represents an invalid version.
*
* @author Stephane Nicoll
*/
@InheritConstructors
class InvalidVersionException extends RuntimeException {
}

View File

@ -19,6 +19,8 @@ package io.spring.initializr.support
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import org.springframework.util.Assert
/**
* Define the version number of a module. A typical version is represented
* as {@code MAJOR.MINOR.PATCH.QUALIFER} where the qualifier can have an
@ -49,16 +51,18 @@ class Version implements Comparable<Version> {
/**
* Parse the string representation of a {@link Version}. Throws an
* {@link IllegalArgumentException} if the version could not be parsed.
* {@link InvalidVersionException} if the version could not be parsed.
* @param text the version text
* @return a Version instance for the specified version text
* @throws IllegalArgumentException if the version text could not be parsed
* @throws InvalidVersionException if the version text could not be parsed
* @see #safeParse(java.lang.String)
*/
static Version parse(String text) {
def matcher = (text =~ VERSION_REGEX)
Assert.notNull(text, 'Text must not be null')
def matcher = (text.trim() =~ VERSION_REGEX)
if (!matcher.matches()) {
throw new IllegalArgumentException("Could not determine version based on $text")
throw new InvalidVersionException("Could not determine version based on '$text': version format " +
"is Minor.Major.Patch.Qualifier (i.e. 1.0.5.RELEASE")
}
Version version = new Version()
version.major = Integer.valueOf(matcher[0][1])
@ -87,7 +91,7 @@ class Version implements Comparable<Version> {
static safeParse(String text) {
try {
return parse(text)
} catch (IllegalArgumentException e) {
} catch (InvalidVersionException e) {
return null
}
}

View File

@ -0,0 +1,98 @@
/*
* Copyright 2012-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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.support
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import org.springframework.util.Assert
/**
* Define a {@link Version} range. A square bracket "[" or "]" denotes an inclusive
* end of the range and a round bracket "(" or ")" denotes an exclusive end of the
* range. A range can also be unbounded by defining a a single {@link Version}. The
* examples below make this clear.
*
* <ul>
* <li>"[1.2.0.RELEASE,1.3.0.RELEASE)" version 1.2.0 and any version after
* this, up to, but not including, version 1.3.0.</li>
* <li>"(2.0.0.RELEASE,3.2.0.RELEASE]" any version after 2.0.0 up to and
* including version 3.2.0.</li>
* <li>"1.4.5.RELEASE", version 1.4.5 and all later versions.</li>
* </ul>
*
* @author Stephane Nicoll
* @since 1.0
*/
@ToString
@EqualsAndHashCode
class VersionRange {
private static final String RANGE_REGEX = "(\\(|\\[)(.*),(.*)(\\)|\\])"
private Version lowerVersion
private boolean lowerInclusive
private Version higherVersion
private boolean higherInclusive
/**
* Specify if the {@link Version} matches this range. Returns {@code true}
* if the version is contained within this range, {@code false} otherwise.
*/
boolean match(Version version) {
Assert.notNull(version, "Version must not be null")
def lower = lowerVersion.compareTo(version)
if (lower > 0) {
return false;
} else if (!lowerInclusive && lower == 0) {
return false;
}
if (higherVersion) {
def higher = higherVersion.compareTo(version)
if (higher < 0) {
return false
} else if (!higherInclusive && higher == 0) {
return false
}
}
return true
}
/**
* Parse the string representation of a {@link VersionRange}. Throws an
* {@link InvalidVersionException} if the range could not be parsed.
* @param text the range text
* @return a VersionRange instance for the specified range text
* @throws InvalidVersionException if the range text could not be parsed
*/
static VersionRange parse(String text) {
Assert.notNull(text, "Text must not be null")
def matcher = (text.trim() =~ RANGE_REGEX)
if (!matcher.matches()) {
// Try to read it as simple string
Version version = Version.parse(text)
return new VersionRange(lowerInclusive: true, lowerVersion: version)
}
VersionRange range = new VersionRange()
range.lowerInclusive = matcher[0][1].equals('[')
range.lowerVersion = Version.parse(matcher[0][2])
range.higherVersion = Version.parse(matcher[0][3])
range.higherInclusive = matcher[0][4].equals(']')
range
}
}

View File

@ -18,6 +18,7 @@ package io.spring.initializr.web
import groovy.util.logging.Slf4j
import io.spring.initializr.CommandLineHelpGenerator
import io.spring.initializr.InitializrMetadataVersion
import io.spring.initializr.ProjectGenerator
import io.spring.initializr.ProjectRequest
@ -79,11 +80,20 @@ class MainController extends AbstractInitializrController {
builder.body(commandLineHelpGenerator.generateGenericCapabilities(metadata, appUrl))
}
@RequestMapping(value = "/", produces = ["application/vnd.initializr.v2+json", "application/json"])
ResponseEntity<String> serviceCapabilities() {
@RequestMapping(value = "/", produces = ["application/vnd.initializr.v2.1+json", "application/json"])
ResponseEntity<String> serviceCapabilitiesV21() {
serviceCapabilitiesFor(InitializrMetadataVersion.V2_1)
}
@RequestMapping(value = "/", produces = ["application/vnd.initializr.v2+json"])
ResponseEntity<String> serviceCapabilitiesV2() {
serviceCapabilitiesFor(InitializrMetadataVersion.V2)
}
private ResponseEntity<String> serviceCapabilitiesFor(InitializrMetadataVersion version) {
String appUrl = ServletUriComponentsBuilder.fromCurrentServletMapping().build()
def content = metadataProvider.get().generateJson(appUrl)
return ResponseEntity.ok().contentType(META_DATA_V2).body(content)
def content = metadataProvider.get().generateJson(version, appUrl)
return ResponseEntity.ok().contentType(version.mediaType).body(content)
}
@RequestMapping(value = '/', produces = 'text/html')

View File

@ -97,6 +97,16 @@ class InitializrMetadataTests {
metadata.validateDependency(new InitializrMetadata.Dependency())
}
@Test
void invalidSpringBootRange() {
def dependency = createDependency('web')
dependency.versionRange = 'A.B.C'
thrown.expect(InvalidInitializrMetadataException)
thrown.expectMessage('A.B.C')
metadata.validateDependency(dependency)
}
@Test
void invalidIdFormatTooManyColons() {
def dependency = createDependency('org.foo:bar:1.0:test:external')

View File

@ -95,6 +95,37 @@ class ProjectRequestTests {
thrown.expect(InvalidProjectRequestException)
thrown.expectMessage('org.foo:acme')
request.resolve(metadata)
assertEquals(1, request.resolvedDependencies.size())
}
@Test
void resolveDependencyInRange() {
def request = new ProjectRequest()
def dependency = createDependency('org.foo', 'bar', '1.2.0.RELEASE')
dependency.versionRange = '[1.0.1.RELEASE, 1.2.0.RELEASE)'
def metadata = InitializrMetadataBuilder.withDefaults()
.addDependencyGroup('code', dependency).validateAndGet()
request.style << 'org.foo:bar'
request.bootVersion = '1.1.2.RELEASE'
request.resolve(metadata)
}
@Test
void resolveDependencyNotInRange() {
def request = new ProjectRequest()
def dependency = createDependency('org.foo', 'bar', '1.2.0.RELEASE')
dependency.versionRange = '[1.0.1.RELEASE, 1.2.0.RELEASE)'
def metadata = InitializrMetadataBuilder.withDefaults()
.addDependencyGroup('code', dependency).validateAndGet()
request.style << 'org.foo:bar'
request.bootVersion = '0.9.9.RELEASE'
thrown.expect(InvalidProjectRequestException)
thrown.expectMessage('org.foo:bar')
thrown.expectMessage('0.9.9.RELEASE')
request.resolve(metadata)
}
@Test

View File

@ -14,9 +14,10 @@
* limitations under the License.
*/
package io.spring.initializr
package io.spring.initializr.mapper
import groovy.json.JsonSlurper
import io.spring.initializr.InitializrMetadata
import io.spring.initializr.test.InitializrMetadataBuilder
import org.junit.Test
@ -27,7 +28,7 @@ import static org.junit.Assert.assertEquals
*/
class InitializrMetadataJsonMapperTests {
private final InitializrMetadataJsonMapper jsonMapper = new InitializrMetadataJsonMapper()
private final InitializrMetadataJsonMapper jsonMapper = new InitializrMetadataV21JsonMapper()
private final JsonSlurper slurper = new JsonSlurper()
@Test

View File

@ -0,0 +1,124 @@
/*
* Copyright 2012-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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.support
import org.hamcrest.BaseMatcher
import org.hamcrest.Description
import org.junit.Rule
import org.junit.Test
import org.junit.rules.ExpectedException
import static org.hamcrest.core.IsNot.not
import static org.junit.Assert.assertThat
/**
* @author Stephane Nicoll
*/
class VersionRangeTests {
@Rule
public final ExpectedException thrown = ExpectedException.none()
@Test
void matchSimpleRange() {
assertThat('1.2.0.RC3', match('[1.2.0.RC1,1.2.0.RC5]'))
}
@Test
void matchSimpleRangeBefore() {
assertThat('1.1.9.RC3', not(match('[1.2.0.RC1,1.2.0.RC5]')))
}
@Test
void matchSimpleRangeAfter() {
assertThat('1.2.0.RC6', not(match('[1.2.0.RC1,1.2.0.RC5]')))
}
@Test
void matchInclusiveLowerRange() {
assertThat('1.2.0.RC1', match('[1.2.0.RC1,1.2.0.RC5]'))
}
@Test
void matchInclusiveHigherRange() {
assertThat('1.2.0.RC5', match('[1.2.0.RC1,1.2.0.RC5]'))
}
@Test
void matchExclusiveLowerRange() {
assertThat('1.2.0.RC1', not(match('(1.2.0.RC1,1.2.0.RC5)')))
}
@Test
void matchExclusiveHigherRange() {
assertThat('1.2.0.RC5', not(match('[1.2.0.RC1,1.2.0.RC5)')))
}
@Test
void matchUnboundedRangeEqual() {
assertThat('1.2.0.RELEASE', match('1.2.0.RELEASE'))
}
@Test
void matchUnboundedRangeAfter() {
assertThat('2.2.0.RELEASE', match('1.2.0.RELEASE'))
}
@Test
void matchUnboundedRangeBefore() {
assertThat('1.1.9.RELEASE', not(match('1.2.0.RELEASE')))
}
@Test
void invalidRange() {
thrown.expect(InvalidVersionException)
VersionRange.parse("foo-bar")
}
@Test
void rangeWithSpaces() {
assertThat('1.2.0.RC3', match('[ 1.2.0.RC1 , 1.2.0.RC5]'))
}
private static VersionRangeMatcher match(String range) {
new VersionRangeMatcher(range)
}
static class VersionRangeMatcher extends BaseMatcher<String> {
private final VersionRange range;
VersionRangeMatcher(String text) {
this.range = VersionRange.parse(text)
}
@Override
boolean matches(Object item) {
if (!item instanceof String) {
return false;
}
return this.range.match(Version.parse(item))
}
@Override
void describeTo(Description description) {
description.appendText(range)
}
}
}

View File

@ -130,7 +130,7 @@ class VersionTests {
@Test
void parseInvalidVersion() {
thrown.expect(IllegalArgumentException)
thrown.expect(InvalidVersionException)
parse('foo')
}
@ -139,4 +139,9 @@ class VersionTests {
assertNull safeParse('foo')
}
@Test
void parseVersionWithSpaces() {
assertThat(parse(' 1.2.0.RC3 '), lessThan(parse('1.3.0.RELEASE')))
}
}

View File

@ -19,7 +19,9 @@ package io.spring.initializr.web
import java.nio.charset.Charset
import groovy.json.JsonSlurper
import io.spring.initializr.InitializrMetadataVersion
import org.json.JSONObject
import org.junit.Ignore
import org.junit.Test
import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode
@ -43,8 +45,7 @@ import static org.junit.Assert.*
@ActiveProfiles('test-default')
class MainControllerIntegrationTests extends AbstractInitializrControllerIntegrationTests {
private static final MediaType CURRENT_METADATA_MEDIA_TYPE =
MediaType.parseMediaType('application/vnd.initializr.v2+json')
private static final MediaType CURRENT_METADATA_MEDIA_TYPE = InitializrMetadataVersion.V2_1.mediaType
private final def slurper = new JsonSlurper()
@ -60,10 +61,27 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
@Test
void simpleTgzProject() {
downloadTgz('/starter.tgz?style=org.acme:bar').isJavaProject().isMavenProject()
downloadTgz('/starter.tgz?style=org.acme:foo').isJavaProject().isMavenProject()
.hasStaticAndTemplatesResources(false).pomAssert()
.hasDependenciesCount(2)
.hasDependency('org.acme', 'bar', '2.1.0')
.hasDependency('org.acme', 'foo', '1.3.5')
}
@Test
void dependencyInRange() {
downloadTgz('/starter.tgz?style=org.acme:biz&bootVersion=1.2.1.RELEASE').isJavaProject().isMavenProject()
.hasStaticAndTemplatesResources(false).pomAssert()
.hasDependenciesCount(2)
.hasDependency('org.acme', 'biz', '1.3.5')
}
@Test
void dependencyNotInRange() {
try {
execute('/starter.tgz?style=org.acme:bur', byte[], null, null)
} catch (HttpClientErrorException ex) {
assertEquals HttpStatus.NOT_ACCEPTABLE, ex.statusCode
}
}
@Test
@ -136,12 +154,34 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
}
@Test
void metadataWithCurrentAcceptHeader() {
@Ignore("Need a comparator that does not care about the number of elements in an array")
void currentMetadataCompatibleWithV2() {
ResponseEntity<String> response = invokeHome(null, '*/*')
validateMetadata(response, CURRENT_METADATA_MEDIA_TYPE, '2.0.0', JSONCompareMode.LENIENT)
}
@Test
void metadataWithV2AcceptHeader() {
ResponseEntity<String> response = invokeHome(null, 'application/vnd.initializr.v2+json')
validateMetadata(response, InitializrMetadataVersion.V2.mediaType, '2.0.0', JSONCompareMode.STRICT)
}
@Test
void metadataWithCurrentAcceptHeader() {
ResponseEntity<String> response = invokeHome(null, 'application/vnd.initializr.v2.1+json')
validateContentType(response, CURRENT_METADATA_MEDIA_TYPE)
validateCurrentMetadata(new JSONObject(response.body))
}
@Test
void metadataWithUnknownAcceptHeader() {
try {
invokeHome(null, 'application/vnd.initializr.v5.4+json')
} catch (HttpClientErrorException ex) {
assertEquals HttpStatus.NOT_ACCEPTABLE, ex.statusCode
}
}
@Test
void curlReceivesTextByDefault() {
ResponseEntity<String> response = invokeHome('curl/1.2.4', "*/*")
@ -210,13 +250,21 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
validateCurrentMetadata(json)
}
private void validateMetadata(ResponseEntity<String> response, MediaType mediaType,
String version, JSONCompareMode compareMode) {
validateContentType(response, mediaType)
def json = new JSONObject(response.body)
def expected = readJson(version)
JSONAssert.assertEquals(expected, json, compareMode)
}
private void validateCurrentMetadata(ResponseEntity<String> response) {
validateContentType(response, CURRENT_METADATA_MEDIA_TYPE)
validateCurrentMetadata(new JSONObject(response.body))
}
private void validateCurrentMetadata(JSONObject json) {
def expected = readJson('2.0.0')
def expected = readJson('2.1.0')
JSONAssert.assertEquals(expected, json, JSONCompareMode.STRICT)
}

View File

@ -101,8 +101,8 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
page.artifactId = 'foo-bar'
page.name = 'My project'
page.description = 'A description for my project'
page.dependency('web')
page.dependency('data-jpa')
page.dependency('web').click()
page.dependency('data-jpa').click()
page.generateProject.click()
at HomePage
def projectAssert = zipProjectAssert(from('foo-bar.zip'))
@ -126,8 +126,8 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
page.artifactId = 'groovy-project'
page.name = 'My Groovy project'
page.description = 'A description for my Groovy project'
page.dependency('web')
page.dependency('data-jpa')
page.dependency('web').click()
page.dependency('data-jpa').click()
page.generateProject.click()
at HomePage
def projectAssert = zipProjectAssert(from('groovy-project.zip'))
@ -147,7 +147,7 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
void createSimpleGradleProject() {
toHome {
page.type = 'gradle-project'
page.dependency('data-jpa')
page.dependency('data-jpa').click()
page.generateProject.click()
at HomePage
def projectAssert = zipProjectAssert(from('demo.zip'))
@ -177,7 +177,7 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
void createMavenBuild() {
toHome {
page.type = 'maven-build'
page.dependency('data-jpa')
page.dependency('data-jpa').click()
page.artifactId = 'my-maven-project'
page.generateProject.click()
at HomePage
@ -200,6 +200,35 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
}
}
@Test
void dependencyHiddenAccordingToRange() {
toHome { // bur: [1.1.4.RELEASE,1.2.0.BUILD-SNAPSHOT)
page.dependency('org.acme:bur').displayed == true
page.bootVersion = '1.0.2.RELEASE'
page.dependency('org.acme:bur').displayed == false
page.dependency('org.acme:biz').displayed == false
page.bootVersion = '1.1.4.RELEASE'
page.dependency('org.acme:bur').displayed == true
page.dependency('org.acme:biz').displayed == false
page.bootVersion = '1.2.0.BUILD-SNAPSHOT'
page.dependency('org.acme:bur').displayed == false
page.dependency('org.acme:biz').displayed == true
}
}
@Test
void dependencyUncheckedWhenHidden() {
toHome {
page.dependency('org.acme:bur').value() == 'org.acme:bur'
page.bootVersion = '1.0.2.RELEASE'
page.dependency('org.acme:bur').displayed == false
page.bootVersion = '1.1.4.RELEASE'
page.dependency('org.acme:bur').displayed == true
page.dependency('org.acme:bur').value() == false
}
}
private Browser toHome(Closure script) {
browser.go("http://localhost:" + port + "/")
browser.at HomePage

View File

@ -39,7 +39,7 @@ class HomePage extends Page {
language { $('form').language() }
dependency { id ->
$("form").find('input', type: "checkbox", name: "style", value: id).click()
$("form").find('input', type: "checkbox", name: "style", value: id)
}
generateProject { $('form').find('button', name: 'generate-project') }

View File

@ -27,6 +27,15 @@ initializr:
- name: Bar
id: org.acme:bar
version: 2.1.0
- name: Biz
groupId: org.acme
artifactId: biz
version: 1.3.5
versionRange: 1.2.0.BUILD-SNAPSHOT
- name: Bur
id: org.acme:bur
version: 2.1.0
versionRange: "[1.1.4.RELEASE,1.2.0.BUILD-SNAPSHOT)"
types:
- name: Maven POM
id: maven-build

View File

@ -0,0 +1,196 @@
{
"_links": {
"maven-build": {
"href": "http://localhost:@port@/pom.xml?type=maven-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"maven-project": {
"href": "http://localhost:@port@/starter.zip?type=maven-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"gradle-build": {
"href": "http://localhost:@port@/build.gradle?type=gradle-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"gradle-project": {
"href": "http://localhost:@port@/starter.zip?type=gradle-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
}
},
"dependencies": {
"type": "hierarchical-multi-select",
"values": [
{
"name": "Core",
"values": [
{
"id": "web",
"name": "Web",
"description": "Web dependency description"
},
{
"id": "security",
"name": "Security"
},
{
"id": "data-jpa",
"name": "Data JPA"
}
]
},
{
"name": "Other",
"values": [
{
"id": "org.acme:foo",
"name": "Foo"
},
{
"id": "org.acme:bar",
"name": "Bar"
},
{
"id": "org.acme:biz",
"name": "Biz",
"versionRange": "1.2.0.BUILD-SNAPSHOT"
},
{
"id": "org.acme:bur",
"name": "Bur",
"versionRange": "[1.1.4.RELEASE,1.2.0.BUILD-SNAPSHOT)"
}
]
}
]
},
"type": {
"type": "action",
"default": "maven-project",
"values": [
{
"id": "maven-build",
"name": "Maven POM",
"action": "/pom.xml",
"tags": {
"build": "maven",
"format": "build"
}
},
{
"id": "maven-project",
"name": "Maven Project",
"action": "/starter.zip",
"tags": {
"build": "maven",
"format": "project"
}
},
{
"id": "gradle-build",
"name": "Gradle Config",
"action": "/build.gradle",
"tags": {
"build": "gradle",
"format": "build"
}
},
{
"id": "gradle-project",
"name": "Gradle Project",
"action": "/starter.zip",
"tags": {
"build": "gradle",
"format": "project"
}
}
]
},
"packaging": {
"type": "single-select",
"default": "jar",
"values": [
{
"id": "jar",
"name": "Jar"
},
{
"id": "war",
"name": "War"
}
]
},
"javaVersion": {
"type": "single-select",
"default": "1.7",
"values": [
{
"id": "1.6",
"name": "1.6"
},
{
"id": "1.7",
"name": "1.7"
},
{
"id": "1.8",
"name": "1.8"
}
]
},
"language": {
"type": "single-select",
"default": "java",
"values": [
{
"id": "groovy",
"name": "Groovy"
},
{
"id": "java",
"name": "Java"
}
]
},
"bootVersion": {
"type": "single-select",
"default": "1.1.4.RELEASE",
"values": [
{
"id": "1.2.0.BUILD-SNAPSHOT",
"name": "Latest SNAPSHOT"
},
{
"id": "1.1.4.RELEASE",
"name": "1.1.4"
},
{
"id": "1.0.2.RELEASE",
"name": "1.0.2"
}
]
},
"groupId": {
"type": "text",
"default": "org.test"
},
"artifactId": {
"type": "text",
"default": "demo"
},
"version": {
"type": "text",
"default": "0.0.1-SNAPSHOT"
},
"name": {
"type": "text",
"default": "demo"
},
"description": {
"type": "text",
"default": "Demo project for Spring Boot"
},
"packageName": {
"type": "text",
"default": "demo"
}
}