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) === 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/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/74[#74]: remove support for meta-data V1.
* https://github.com/spring-io/initializr/issues/71[#71]: update project layout for Groovy-based projects. * 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 version: 0.3.1
# remember to update static/install.sh as well: # remember to update static/install.sh as well:
spring-boot: spring-boot:
version: 1.2.0.RELEASE version: 1.2.1.RELEASE
initializr: initializr:
dependencies: dependencies:
@ -16,6 +16,14 @@ initializr:
- name: AOP - name: AOP
id: aop id: aop
description: Support for aspect-oriented programming including spring-aop and AspectJ 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 - name: Data
content: content:
- name: JDBC - name: JDBC
@ -55,6 +63,10 @@ initializr:
- name: AMQP - name: AMQP
id: amqp id: amqp
description: Support for the Advanced Message Queuing Protocol via spring-rabbit 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 - name: Web
content: content:
- name: Web - name: Web
@ -68,9 +80,17 @@ initializr:
- name: WS - name: WS
id: ws id: ws
description: Support for Spring Web Services 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 - name: Rest Repositories
id: data-rest id: data-rest
description: Support for exposing Spring Data repositories over REST via spring-data-rest-webmvc 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 - name: Mobile
id: mobile id: mobile
description: Support for spring-mobile description: Support for spring-mobile
@ -96,6 +116,12 @@ initializr:
description: Support for the Thymeleaf templating engine, including integration with Spring description: Support for the Thymeleaf templating engine, including integration with Spring
facets: facets:
- web - web
- name: Mustache
id: mustache
description: Support for the Mustache templating engine
versionRange: 1.2.2.BUILD-SNAPSHOT
facets:
- web
- name: Social - name: Social
content: content:
- name: Facebook - name: Facebook
@ -112,6 +138,10 @@ initializr:
- name: Actuator - name: Actuator
id: actuator id: actuator
description: Production ready features to help you monitor and manage your application 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 - name: Remote Shell
id: remote-shell id: remote-shell
description: Support for CRaSH description: Support for CRaSH

View File

@ -20,6 +20,11 @@ import javax.annotation.PostConstruct
import groovy.transform.ToString import groovy.transform.ToString
import groovy.util.logging.Slf4j 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 import org.springframework.boot.context.properties.ConfigurationProperties
@ -61,8 +66,6 @@ class InitializrMetadata {
private final Map<String, Dependency> indexedDependencies = [:] 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 * Return the {@link Dependency} with the specified id or {@code null} if
* no such dependency exists. * no such dependency exists.
@ -127,11 +130,11 @@ class InitializrMetadata {
/** /**
* Generate a JSON representation of the current metadata * Generate a JSON representation of the current metadata
* * @param version the meta-data version
* @param appUrl the application url * @param appUrl the application url
*/ */
String generateJson(String appUrl) { String generateJson(InitializrMetadataVersion version, String appUrl) {
jsonMapper.write(this, 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") "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) { static def getDefault(List elements) {
@ -205,6 +216,13 @@ class InitializrMetadata {
return (elements.isEmpty() ? null : elements.get(0).id) 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 { static class DependencyGroup {
String name String name
@ -228,6 +246,8 @@ class InitializrMetadata {
String description String description
String versionRange
/** /**
* Specify if the dependency has its coordinates set, i.e. {@code groupId} * Specify if the dependency has its coordinates set, i.e. {@code groupId}
* and {@code artifactId}. * 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 package io.spring.initializr
import groovy.util.logging.Slf4j import groovy.util.logging.Slf4j
import io.spring.initializr.support.Version
import io.spring.initializr.support.VersionRange
/** /**
* A request to generate a project. * A request to generate a project.
@ -79,12 +81,21 @@ class ProjectRequest {
} }
dependency dependency
} }
String actualBootVersion = bootVersion ?: metadata.defaults.bootVersion
Version requestedVersion = Version.parse(actualBootVersion)
resolvedDependencies.each { resolvedDependencies.each {
it.facets.each { it.facets.each {
if (!facets.contains(it)) { if (!facets.contains(it)) {
facets.add(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) { 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. * limitations under the License.
*/ */
package io.spring.initializr package io.spring.initializr.mapper
import groovy.json.JsonBuilder import groovy.json.JsonBuilder
import io.spring.initializr.InitializrMetadata
import org.springframework.hateoas.TemplateVariable import org.springframework.hateoas.TemplateVariable
import org.springframework.hateoas.TemplateVariables import org.springframework.hateoas.TemplateVariables
import org.springframework.hateoas.UriTemplate 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 * @author Stephane Nicoll
* @since 1.0 * @since 1.0
*/ */
class InitializrMetadataJsonMapper { class InitializrMetadataV2JsonMapper implements InitializrMetadataJsonMapper {
private final TemplateVariables templateVariables private final TemplateVariables templateVariables
InitializrMetadataJsonMapper() { InitializrMetadataV2JsonMapper() {
this.templateVariables = new TemplateVariables( this.templateVariables = new TemplateVariables(
new TemplateVariable('dependencies', TemplateVariable.VariableType.REQUEST_PARAM), new TemplateVariable('dependencies', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('packaging', TemplateVariable.VariableType.REQUEST_PARAM), new TemplateVariable('packaging', TemplateVariable.VariableType.REQUEST_PARAM),
@ -48,6 +50,7 @@ class InitializrMetadataJsonMapper {
) )
} }
@Override
String write(InitializrMetadata metadata, String appUrl) { String write(InitializrMetadata metadata, String appUrl) {
JsonBuilder json = new JsonBuilder() JsonBuilder json = new JsonBuilder()
json { json {
@ -68,7 +71,7 @@ class InitializrMetadataJsonMapper {
json.toString() json.toString()
} }
private links(parent, types, appUrl) { protected links(parent, types, appUrl) {
def content = [:] def content = [:]
types.each { types.each {
content[it.id] = link(appUrl, it) content[it.id] = link(appUrl, it)
@ -76,7 +79,7 @@ class InitializrMetadataJsonMapper {
parent._links content parent._links content
} }
private link(appUrl, type) { protected link(appUrl, type) {
def result = [:] def result = [:]
result.href = generateTemplatedUri(appUrl, type) result.href = generateTemplatedUri(appUrl, type)
result.templated = true result.templated = true
@ -91,41 +94,40 @@ class InitializrMetadataJsonMapper {
} }
private static dependencies(parent, groups) { protected dependencies(parent, groups) {
parent.dependencies { parent.dependencies {
type 'hierarchical-multi-select' type 'hierarchical-multi-select'
values groups.collect { values groups.collect {
processDependencyGroup(it) mapDependencyGroup(it)
}
} }
} }
} protected type(parent, defaultValue, dependencies) {
private static type(parent, defaultValue, dependencies) {
parent.type { parent.type {
type 'action' type 'action'
if (defaultValue) { if (defaultValue) {
'default' defaultValue 'default' defaultValue
} }
values dependencies.collect { values dependencies.collect {
processType(it) mapType(it)
} }
} }
} }
private static singleSelect(parent, name, defaultValue, itemValues) { protected singleSelect(parent, name, defaultValue, itemValues) {
parent."$name" { parent."$name" {
type 'single-select' type 'single-select'
if (defaultValue) { if (defaultValue) {
'default' defaultValue 'default' defaultValue
} }
values itemValues.collect { values itemValues.collect {
processValue(it) mapValue(it)
} }
} }
} }
private static text(parent, name, value) { protected text(parent, name, value) {
parent."$name" { parent."$name" {
type 'text' type 'text'
if (value) { if (value) {
@ -134,8 +136,7 @@ class InitializrMetadataJsonMapper {
} }
} }
protected mapDependencyGroup(group) {
private static processDependencyGroup(group) {
def result = [:] def result = [:]
result.name = group.name result.name = group.name
if (group.hasProperty('description') && group.description) { if (group.hasProperty('description') && group.description) {
@ -143,20 +144,29 @@ class InitializrMetadataJsonMapper {
} }
def items = [] def items = []
group.content.collect { group.content.collect {
items << processValue(it) def dependency = mapDependency(it)
if (dependency) {
items << dependency
}
} }
result.values = items result.values = items
result result
} }
private static processType(type) { protected mapDependency(dependency) {
def result = processValue(type) 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.action = type.action
result.tags = type.tags result.tags = type.tags
result result
} }
private static processValue(value) { protected mapValue(value) {
def result = [:] def result = [:]
result.id = value.id result.id = value.id
result.name = value.name 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.EqualsAndHashCode
import groovy.transform.ToString import groovy.transform.ToString
import org.springframework.util.Assert
/** /**
* Define the version number of a module. A typical version is represented * Define the version number of a module. A typical version is represented
* as {@code MAJOR.MINOR.PATCH.QUALIFER} where the qualifier can have an * 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 * 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 * @param text the version text
* @return a Version instance for the specified 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) * @see #safeParse(java.lang.String)
*/ */
static Version parse(String text) { 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()) { 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 version = new Version()
version.major = Integer.valueOf(matcher[0][1]) version.major = Integer.valueOf(matcher[0][1])
@ -87,7 +91,7 @@ class Version implements Comparable<Version> {
static safeParse(String text) { static safeParse(String text) {
try { try {
return parse(text) return parse(text)
} catch (IllegalArgumentException e) { } catch (InvalidVersionException e) {
return null 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 groovy.util.logging.Slf4j
import io.spring.initializr.CommandLineHelpGenerator import io.spring.initializr.CommandLineHelpGenerator
import io.spring.initializr.InitializrMetadataVersion
import io.spring.initializr.ProjectGenerator import io.spring.initializr.ProjectGenerator
import io.spring.initializr.ProjectRequest import io.spring.initializr.ProjectRequest
@ -79,11 +80,20 @@ class MainController extends AbstractInitializrController {
builder.body(commandLineHelpGenerator.generateGenericCapabilities(metadata, appUrl)) builder.body(commandLineHelpGenerator.generateGenericCapabilities(metadata, appUrl))
} }
@RequestMapping(value = "/", produces = ["application/vnd.initializr.v2+json", "application/json"]) @RequestMapping(value = "/", produces = ["application/vnd.initializr.v2.1+json", "application/json"])
ResponseEntity<String> serviceCapabilities() { 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() String appUrl = ServletUriComponentsBuilder.fromCurrentServletMapping().build()
def content = metadataProvider.get().generateJson(appUrl) def content = metadataProvider.get().generateJson(version, appUrl)
return ResponseEntity.ok().contentType(META_DATA_V2).body(content) return ResponseEntity.ok().contentType(version.mediaType).body(content)
} }
@RequestMapping(value = '/', produces = 'text/html') @RequestMapping(value = '/', produces = 'text/html')

View File

@ -97,6 +97,16 @@ class InitializrMetadataTests {
metadata.validateDependency(new InitializrMetadata.Dependency()) 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 @Test
void invalidIdFormatTooManyColons() { void invalidIdFormatTooManyColons() {
def dependency = createDependency('org.foo:bar:1.0:test:external') def dependency = createDependency('org.foo:bar:1.0:test:external')

View File

@ -95,6 +95,37 @@ class ProjectRequestTests {
thrown.expect(InvalidProjectRequestException) thrown.expect(InvalidProjectRequestException)
thrown.expectMessage('org.foo:acme') thrown.expectMessage('org.foo:acme')
request.resolve(metadata) 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 @Test

View File

@ -14,9 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
package io.spring.initializr package io.spring.initializr.mapper
import groovy.json.JsonSlurper import groovy.json.JsonSlurper
import io.spring.initializr.InitializrMetadata
import io.spring.initializr.test.InitializrMetadataBuilder import io.spring.initializr.test.InitializrMetadataBuilder
import org.junit.Test import org.junit.Test
@ -27,7 +28,7 @@ import static org.junit.Assert.assertEquals
*/ */
class InitializrMetadataJsonMapperTests { class InitializrMetadataJsonMapperTests {
private final InitializrMetadataJsonMapper jsonMapper = new InitializrMetadataJsonMapper() private final InitializrMetadataJsonMapper jsonMapper = new InitializrMetadataV21JsonMapper()
private final JsonSlurper slurper = new JsonSlurper() private final JsonSlurper slurper = new JsonSlurper()
@Test @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 @Test
void parseInvalidVersion() { void parseInvalidVersion() {
thrown.expect(IllegalArgumentException) thrown.expect(InvalidVersionException)
parse('foo') parse('foo')
} }
@ -139,4 +139,9 @@ class VersionTests {
assertNull safeParse('foo') 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 java.nio.charset.Charset
import groovy.json.JsonSlurper import groovy.json.JsonSlurper
import io.spring.initializr.InitializrMetadataVersion
import org.json.JSONObject import org.json.JSONObject
import org.junit.Ignore
import org.junit.Test import org.junit.Test
import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode import org.skyscreamer.jsonassert.JSONCompareMode
@ -43,8 +45,7 @@ import static org.junit.Assert.*
@ActiveProfiles('test-default') @ActiveProfiles('test-default')
class MainControllerIntegrationTests extends AbstractInitializrControllerIntegrationTests { class MainControllerIntegrationTests extends AbstractInitializrControllerIntegrationTests {
private static final MediaType CURRENT_METADATA_MEDIA_TYPE = private static final MediaType CURRENT_METADATA_MEDIA_TYPE = InitializrMetadataVersion.V2_1.mediaType
MediaType.parseMediaType('application/vnd.initializr.v2+json')
private final def slurper = new JsonSlurper() private final def slurper = new JsonSlurper()
@ -60,10 +61,27 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
@Test @Test
void simpleTgzProject() { void simpleTgzProject() {
downloadTgz('/starter.tgz?style=org.acme:bar').isJavaProject().isMavenProject() downloadTgz('/starter.tgz?style=org.acme:foo').isJavaProject().isMavenProject()
.hasStaticAndTemplatesResources(false).pomAssert() .hasStaticAndTemplatesResources(false).pomAssert()
.hasDependenciesCount(2) .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 @Test
@ -136,12 +154,34 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
} }
@Test @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') 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) validateContentType(response, CURRENT_METADATA_MEDIA_TYPE)
validateCurrentMetadata(new JSONObject(response.body)) 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 @Test
void curlReceivesTextByDefault() { void curlReceivesTextByDefault() {
ResponseEntity<String> response = invokeHome('curl/1.2.4', "*/*") ResponseEntity<String> response = invokeHome('curl/1.2.4', "*/*")
@ -210,13 +250,21 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
validateCurrentMetadata(json) 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) { private void validateCurrentMetadata(ResponseEntity<String> response) {
validateContentType(response, CURRENT_METADATA_MEDIA_TYPE) validateContentType(response, CURRENT_METADATA_MEDIA_TYPE)
validateCurrentMetadata(new JSONObject(response.body)) validateCurrentMetadata(new JSONObject(response.body))
} }
private void validateCurrentMetadata(JSONObject json) { private void validateCurrentMetadata(JSONObject json) {
def expected = readJson('2.0.0') def expected = readJson('2.1.0')
JSONAssert.assertEquals(expected, json, JSONCompareMode.STRICT) JSONAssert.assertEquals(expected, json, JSONCompareMode.STRICT)
} }

View File

@ -101,8 +101,8 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
page.artifactId = 'foo-bar' page.artifactId = 'foo-bar'
page.name = 'My project' page.name = 'My project'
page.description = 'A description for my project' page.description = 'A description for my project'
page.dependency('web') page.dependency('web').click()
page.dependency('data-jpa') page.dependency('data-jpa').click()
page.generateProject.click() page.generateProject.click()
at HomePage at HomePage
def projectAssert = zipProjectAssert(from('foo-bar.zip')) def projectAssert = zipProjectAssert(from('foo-bar.zip'))
@ -126,8 +126,8 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
page.artifactId = 'groovy-project' page.artifactId = 'groovy-project'
page.name = 'My Groovy project' page.name = 'My Groovy project'
page.description = 'A description for my Groovy project' page.description = 'A description for my Groovy project'
page.dependency('web') page.dependency('web').click()
page.dependency('data-jpa') page.dependency('data-jpa').click()
page.generateProject.click() page.generateProject.click()
at HomePage at HomePage
def projectAssert = zipProjectAssert(from('groovy-project.zip')) def projectAssert = zipProjectAssert(from('groovy-project.zip'))
@ -147,7 +147,7 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
void createSimpleGradleProject() { void createSimpleGradleProject() {
toHome { toHome {
page.type = 'gradle-project' page.type = 'gradle-project'
page.dependency('data-jpa') page.dependency('data-jpa').click()
page.generateProject.click() page.generateProject.click()
at HomePage at HomePage
def projectAssert = zipProjectAssert(from('demo.zip')) def projectAssert = zipProjectAssert(from('demo.zip'))
@ -177,7 +177,7 @@ class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegratio
void createMavenBuild() { void createMavenBuild() {
toHome { toHome {
page.type = 'maven-build' page.type = 'maven-build'
page.dependency('data-jpa') page.dependency('data-jpa').click()
page.artifactId = 'my-maven-project' page.artifactId = 'my-maven-project'
page.generateProject.click() page.generateProject.click()
at HomePage 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) { private Browser toHome(Closure script) {
browser.go("http://localhost:" + port + "/") browser.go("http://localhost:" + port + "/")
browser.at HomePage browser.at HomePage

View File

@ -39,7 +39,7 @@ class HomePage extends Page {
language { $('form').language() } language { $('form').language() }
dependency { id -> 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') } generateProject { $('form').find('button', name: 'generate-project') }

View File

@ -27,6 +27,15 @@ initializr:
- name: Bar - name: Bar
id: org.acme:bar id: org.acme:bar
version: 2.1.0 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: types:
- name: Maven POM - name: Maven POM
id: maven-build 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"
}
}