Support for Bill of Materials

Add explicit support for Bill Of Materials. When a dependency defines a
bom ID, the related bom is added to the project. The metadata are
validated on startup to make sure a dependency does not refer to an
unknown bom entry.

Closes gh-99
This commit is contained in:
Stephane Nicoll
2015-03-26 14:47:08 +01:00
parent adf5cf068e
commit ddfc57443e
19 changed files with 305 additions and 57 deletions

View File

@@ -17,6 +17,7 @@
package io.spring.initializr.generator
import groovy.util.logging.Slf4j
import io.spring.initializr.metadata.BillOfMaterials
import io.spring.initializr.metadata.Dependency
import io.spring.initializr.metadata.InitializrMetadata
import io.spring.initializr.metadata.Type
@@ -59,6 +60,8 @@ class ProjectRequest {
// Resolved dependencies based on the ids provided by either "style" or "dependencies"
List<Dependency> resolvedDependencies
final List<BillOfMaterials> boms = []
def facets = []
def build
@@ -92,6 +95,7 @@ class ProjectRequest {
}
String actualBootVersion = bootVersion ?: metadata.bootVersions.default.id
Version requestedVersion = Version.parse(actualBootVersion)
Set<String> bomIds = []
resolvedDependencies.each {
it.facets.each {
if (!facets.contains(it)) {
@@ -105,6 +109,13 @@ class ProjectRequest {
"with Spring Boot $bootVersion")
}
}
if (it.bom) {
String bomId = it.bom
if (!bomIds.contains(bomId)) {
bomIds << bomId
boms << metadata.configuration.env.boms[bomId]
}
}
}
if (this.type) {

View File

@@ -0,0 +1,35 @@
/*
* 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.metadata
import groovy.transform.ToString
/**
* Define a Bill Of Materials to be represented in the generated project
* if a dependency refers to it.
*
* @author Stephane Nicoll
* @since 1.0
*/
@ToString(ignoreNulls = true, includePackage = false)
class BillOfMaterials {
String groupId
String artifactId
String version
}

View File

@@ -16,6 +16,7 @@
package io.spring.initializr.metadata
import com.fasterxml.jackson.annotation.JsonInclude
import groovy.transform.ToString
import io.spring.initializr.util.InvalidVersionException
import io.spring.initializr.util.VersionRange
@@ -28,6 +29,7 @@ import io.spring.initializr.util.VersionRange
* @since 1.0
*/
@ToString(ignoreNulls = true, includePackage = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
class Dependency extends MetadataElement {
static final String SCOPE_COMPILE = 'compile'
@@ -57,6 +59,8 @@ class Dependency extends MetadataElement {
String versionRange
String bom
void setScope(String scope) {
if (!SCOPE_ALL.contains(scope)) {
throw new InvalidInitializrMetadataException("Invalid scope $scope must be one of $SCOPE_ALL")

View File

@@ -14,8 +14,7 @@
* limitations under the License.
*/
package io.spring.initializr
package io.spring.initializr.metadata
/**
* Various configuration options used by the service.
*
@@ -113,6 +112,11 @@ class InitializrConfiguration {
*/
boolean forceSsl = true
/**
* The {@link BillOfMaterials} that are referenced in this instance.
*/
final Map<String, BillOfMaterials> boms = [:]
void setArtifactRepository(String artifactRepository) {
if (!artifactRepository.endsWith('/')) {
artifactRepository = artifactRepository + '/'
@@ -126,7 +130,13 @@ class InitializrConfiguration {
fallbackApplicationName = other.fallbackApplicationName
invalidApplicationNames = other.invalidApplicationNames
forceSsl = other.forceSsl
other.boms.each { id, bom ->
if (!boms[id]) {
boms[id] = bom
}
}
}
}
}

View File

@@ -15,9 +15,6 @@
*/
package io.spring.initializr.metadata
import io.spring.initializr.InitializrConfiguration
/**
* Meta-data used to generate a project.
*
@@ -86,6 +83,14 @@ class InitializrMetadata {
*/
void validate() {
dependencies.validate()
for (Dependency dependency : dependencies.all) {
def boms = configuration.env.boms
if (dependency.bom && !boms[dependency.bom]) {
throw new InvalidInitializrMetadataException("Dependency $dependency " +
"defines an invalid BOM id $dependency.bom, available boms $boms")
}
}
}
/**
@@ -147,7 +152,7 @@ class InitializrMetadata {
@Override
String getContent() {
String value = super.getContent()
value == null ? nameCapability.content.replace('-', '.') : value
value ?: (nameCapability.content != null ? nameCapability.content.replace('-', '.') : null)
}
}

View File

@@ -20,7 +20,6 @@ import java.nio.charset.Charset
import com.fasterxml.jackson.databind.ObjectMapper
import groovy.util.logging.Log
import io.spring.initializr.InitializrConfiguration
import org.springframework.core.io.Resource
import org.springframework.util.StreamUtils

View File

@@ -17,7 +17,6 @@
package io.spring.initializr.metadata
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import io.spring.initializr.InitializrConfiguration
import org.springframework.boot.context.properties.ConfigurationProperties

View File

@@ -17,6 +17,7 @@
package io.spring.initializr.metadata
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import org.springframework.util.Assert
@@ -28,6 +29,7 @@ import org.springframework.util.Assert
* @since 1.0
*/
@JsonIgnoreProperties(["default", "all"])
@JsonInclude(JsonInclude.Include.NON_NULL)
abstract class ServiceCapability<T> {
final String id

View File

@@ -55,7 +55,7 @@ abstract class AbstractInitializrController {
/**
* Generate a full URL of the service, mostly for use in templates.
* @see io.spring.initializr.InitializrConfiguration.Env#forceSsl
* @see io.spring.initializr.metadata.InitializrConfiguration.Env#forceSsl
*/
protected String generateAppUrl() {
def builder = ServletUriComponentsBuilder.fromCurrentServletMapping()

View File

@@ -49,6 +49,13 @@ dependencies {<% compileDependencies.each { %>
testCompile("org.springframework.boot:spring-boot-starter-test") <% testDependencies.each { %>
testCompile("${it.groupId}:${it.artifactId}${it.version ? ":$it.version" : ""}")<% } %>
}
<% if (boms) { %>
dependencyManagement {
imports { <% boms.each { %>
mavenBom "${it.groupId}:${it.artifactId}${it.version ? ":$it.version" : ""}" <% } %>
}
}
<% } %>
eclipse {
classpath {

View File

@@ -64,7 +64,19 @@
<scope>test</scope>
</dependency><% } %>
</dependencies>
<% if (boms) { %>
<dependencyManagement>
<dependencies><% boms.each { %>
<dependency>
<groupId>${it.groupId}</groupId>
<artifactId>${it.artifactId}</artifactId>
<version>${it.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency><% } %>
</dependencies>
</dependencyManagement>
<% } %>
<build>
<plugins>
<plugin>

View File

@@ -326,6 +326,46 @@ class ProjectGeneratorTests {
.doesNotContain("apply plugin: 'io.spring.dependency-management'")
}
@Test
void mavenBom() {
def foo = new Dependency(id: 'foo', groupId: 'org.acme', artifactId: 'foo', bom: 'foo-bom')
def metadata = InitializrMetadataTestBuilder.withDefaults()
.addDependencyGroup('foo', foo)
.addBom('foo-bom', 'org.acme', 'foo-bom', '1.2.3').build()
projectGenerator.metadata = metadata
def request = createProjectRequest('foo')
generateMavenPom(request).hasDependency(foo)
.hasBom('org.acme', 'foo-bom', '1.2.3')
}
@Test
void mavenBomWithSeveralDependenciesOnSameBom() {
def foo = new Dependency(id: 'foo', groupId: 'org.acme', artifactId: 'foo', bom: 'the-bom')
def bar = new Dependency(id: 'bar', groupId: 'org.acme', artifactId: 'bar', bom: 'the-bom')
def metadata = InitializrMetadataTestBuilder.withDefaults()
.addDependencyGroup('group', foo, bar)
.addBom('the-bom', 'org.acme', 'the-bom', '1.2.3').build()
projectGenerator.metadata = metadata
def request = createProjectRequest('foo', 'bar')
generateMavenPom(request).hasDependency(foo)
.hasBom('org.acme', 'the-bom', '1.2.3')
.hasBomsCount(1)
}
@Test
void gradleBom() {
def foo = new Dependency(id: 'foo', groupId: 'org.acme', artifactId: 'foo', bom: 'foo-bom')
def metadata = InitializrMetadataTestBuilder.withDefaults()
.addDependencyGroup('foo', foo)
.addBom('foo-bom', 'org.acme', 'foo-bom', '1.2.3').build()
projectGenerator.metadata = metadata
def request = createProjectRequest('foo')
generateGradleBuild(request)
.contains("dependencyManagement {")
.contains("imports {")
.contains("mavenBom \"org.acme:foo-bom:1.2.3\"")
}
PomAssert generateMavenPom(ProjectRequest request) {
def content = new String(projectGenerator.generateMavenPom(request))
new PomAssert(content).validateProjectRequest(request)

View File

@@ -14,8 +14,9 @@
* limitations under the License.
*/
package io.spring.initializr
package io.spring.initializr.metadata
import io.spring.initializr.metadata.InitializrConfiguration
import org.junit.Test
import static org.junit.Assert.assertEquals

View File

@@ -16,7 +16,6 @@
package io.spring.initializr.metadata
import io.spring.initializr.InitializrConfiguration
import org.junit.Test
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean
@@ -86,6 +85,26 @@ class InitializrMetadataBuilderTests {
assertEquals 'org.acme.demo', metadata.packageName.content
}
@Test
void mergeMetadataWithBom() {
def metadata = InitializrMetadataBuilder.create().withInitializrMetadata(
new ClassPathResource('metadata/service/test-bom.json')).build()
def boms = metadata.configuration.env.boms
assertEquals 2, boms.size()
BillOfMaterials myBom = boms['my-bom']
assertNotNull myBom
assertEquals 'org.acme', myBom.groupId
assertEquals 'my-bom', myBom.artifactId
assertEquals '1.2.3.RELEASE', myBom.version
BillOfMaterials anotherBom = boms['another-bom']
assertNotNull anotherBom
assertEquals 'org.acme', anotherBom.groupId
assertEquals 'another-bom', anotherBom.artifactId
assertEquals '4.5.6.RELEASE', anotherBom.version
}
@Test
void mergeConfigurationDisabledByDefault() {
def config = load(new ClassPathResource("application-test-default.yml"))
@@ -116,13 +135,6 @@ class InitializrMetadataBuilderTests {
assertEquals false, actualEnv.forceSsl
}
private static assertDefaultConfig(InitializrMetadata metadata) {
assertNotNull metadata
assertEquals "Wrong number of dependencies", 9, metadata.dependencies.all.size()
assertEquals "Wrong number of dependency group", 2, metadata.dependencies.content.size()
assertEquals "Wrong number of types", 4, metadata.types.content.size()
}
@Test
void addDependencyInCustomizer() {
def group = new DependencyGroup(name: 'Extra')
@@ -138,6 +150,12 @@ class InitializrMetadataBuilderTests {
assertEquals group, metadata.dependencies.content[0]
}
private static assertDefaultConfig(InitializrMetadata metadata) {
assertNotNull metadata
assertEquals "Wrong number of dependencies", 9, metadata.dependencies.all.size()
assertEquals "Wrong number of dependency group", 2, metadata.dependencies.content.size()
assertEquals "Wrong number of types", 4, metadata.types.content.size()
}
private static InitializrProperties load(Resource resource) {
PropertiesConfigurationFactory<InitializrProperties> factory =

View File

@@ -0,0 +1,45 @@
/*
* 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.metadata
import io.spring.initializr.test.InitializrMetadataTestBuilder
import org.junit.Rule
import org.junit.Test
import org.junit.rules.ExpectedException
/**
* @author Stephane Nicoll
*/
class InitializrMetadataTests {
@Rule
public final ExpectedException thrown = ExpectedException.none()
@Test
void invalidBom() {
def foo = new Dependency(id: 'foo', groupId: 'org.acme', artifactId: 'foo', bom: 'foo-bom')
InitializrMetadataTestBuilder builder = InitializrMetadataTestBuilder
.withDefaults().addBom('my-bom', 'org.acme', 'foo', '1.2.3')
.addDependencyGroup('test', foo);
thrown.expect(InvalidInitializrMetadataException)
thrown.expectMessage("foo-bom")
thrown.expectMessage("my-bom")
builder.build()
}
}

View File

@@ -16,6 +16,7 @@
package io.spring.initializr.test
import io.spring.initializr.metadata.BillOfMaterials
import io.spring.initializr.metadata.DefaultMetadataElement
import io.spring.initializr.metadata.Dependency
import io.spring.initializr.metadata.DependencyGroup
@@ -23,7 +24,6 @@ import io.spring.initializr.metadata.InitializrMetadata
import io.spring.initializr.metadata.InitializrMetadataBuilder
import io.spring.initializr.metadata.Type
/**
* Easily create a {@link InitializrMetadata} instance for testing purposes.
*
@@ -140,4 +140,13 @@ class InitializrMetadataTestBuilder {
this
}
InitializrMetadataTestBuilder addBom(String id, String groupId, String artifactId, String version) {
builder.withCustomizer {
BillOfMaterials bom = new BillOfMaterials(
groupId: groupId, artifactId: artifactId, version: version)
it.configuration.env.boms[id] = bom
}
this
}
}

View File

@@ -17,6 +17,7 @@
package io.spring.initializr.test
import io.spring.initializr.generator.ProjectRequest
import io.spring.initializr.metadata.BillOfMaterials
import io.spring.initializr.metadata.Dependency
import org.custommonkey.xmlunit.SimpleNamespaceContext
import org.custommonkey.xmlunit.XMLUnit
@@ -39,6 +40,7 @@ class PomAssert {
final XpathEngine eng
final Document doc
final Map<String, Dependency> dependencies = [:]
final Map<String, BillOfMaterials> boms = [:]
PomAssert(String content) {
eng = XMLUnit.newXpathEngine()
@@ -48,6 +50,7 @@ class PomAssert {
eng.namespaceContext = namespaceContext
doc = XMLUnit.buildControlDocument(content)
parseDependencies()
parseBoms()
}
/**
@@ -135,7 +138,7 @@ class PomAssert {
}
PomAssert hasDependency(Dependency expected) {
def id = generateId(expected.groupId, expected.artifactId)
def id = generateDependencyId(expected.groupId, expected.artifactId)
def dependency = dependencies[id]
assertNotNull "No dependency found with '$id' --> ${dependencies.keySet()}", dependency
if (expected.version) {
@@ -147,6 +150,20 @@ class PomAssert {
this
}
PomAssert hasBom(String groupId, String artifactId, String version) {
def id = generateBomId(groupId, artifactId)
def bom = boms[id]
assertNotNull "No BOM found with '$id' --> ${boms.keySet()}", bom
assertEquals "Wrong version for $bom", version, bom.version
this
}
PomAssert hasBomsCount(int count) {
assertEquals "Wrong number of declared boms -->'${boms.keySet()}",
count, boms.size()
this
}
PomAssert hasNoRepository() {
assertEquals 0, eng.getMatchingNodes(createRootNodeXPath('repositories'), doc).length
this
@@ -216,7 +233,50 @@ class PomAssert {
}
}
private static String generateId(String groupId, String artifactId) {
private def parseBoms() {
def nodes = eng.getMatchingNodes(createRootNodeXPath('dependencyManagement/pom:dependencies/pom:dependency'), doc)
for (int i = 0; i < nodes.length; i++) {
def item = nodes.item(i)
if (item instanceof Element) {
def element = (Element) item
def type = element.getElementsByTagName('type')
def scope = element.getElementsByTagName('scope')
if (isBom(type, scope)) {
def bom = new BillOfMaterials()
def groupId = element.getElementsByTagName('groupId')
if (groupId.length > 0) {
bom.groupId = groupId.item(0).textContent
}
def artifactId = element.getElementsByTagName('artifactId')
if (artifactId.length > 0) {
bom.artifactId = artifactId.item(0).textContent
}
def version = element.getElementsByTagName('version')
if (version.length > 0) {
bom.version = version.item(0).textContent
}
def id = generateBomId(bom.groupId, bom.artifactId)
assertFalse("Duplicate BOM with id $id", boms.containsKey(id))
boms[id] = bom
}
}
}
}
private static boolean isBom(def type, def scope) {
if (type.length == 0 || scope.length == 0) {
return false
}
String typeValue = type.item(0).textContent
String scopeValue = scope.item(0).textContent
return "pom".equals(typeValue) && "import".equals(scopeValue)
}
private static String generateBomId(def groupId, def artifactId) {
"$groupId:$artifactId"
}
private static String generateDependencyId(String groupId, String artifactId) {
def dependency = new Dependency()
dependency.groupId = groupId
dependency.artifactId = artifactId

View File

@@ -0,0 +1,19 @@
{
"configuration": {
"env": {
"forceSsl": false,
"boms": {
"my-bom": {
"groupId": "org.acme",
"artifactId": "my-bom",
"version": "1.2.3.RELEASE"
},
"another-bom": {
"groupId": "org.acme",
"artifactId": "another-bom",
"version": "4.5.6.RELEASE"
}
}
}
}
}

View File

@@ -1,7 +1,6 @@
{
"artifactId": {
"content": "demo",
"description": null,
"id": "artifactId",
"type": "TEXT"
},
@@ -23,13 +22,13 @@
"name": "1.0.2"
}
],
"description": null,
"id": "bootVersion",
"type": "SINGLE_SELECT"
},
"configuration": {
"env": {
"artifactRepository": "https://repo.spring.io/release/",
"boms": {},
"fallbackApplicationName": "Application",
"forceSsl": true,
"invalidApplicationNames": [
@@ -51,33 +50,25 @@
"groupId": "org.springframework.boot",
"id": "web",
"name": "Web",
"scope": "compile",
"version": null,
"versionRange": null
"scope": "compile"
},
{
"aliases": [],
"artifactId": "spring-boot-starter-security",
"description": null,
"facets": [],
"groupId": "org.springframework.boot",
"id": "security",
"name": "Security",
"scope": "compile",
"version": null,
"versionRange": null
"scope": "compile"
},
{
"aliases": ["jpa"],
"artifactId": "spring-boot-starter-data-jpa",
"description": null,
"facets": [],
"groupId": "org.springframework.boot",
"id": "data-jpa",
"name": "Data JPA",
"scope": "compile",
"version": null,
"versionRange": null
"scope": "compile"
}
],
"name": "Core"
@@ -87,31 +78,26 @@
{
"aliases": [],
"artifactId": "foo",
"description": null,
"facets": [],
"groupId": "org.acme",
"id": "org.acme:foo",
"name": "Foo",
"scope": "compile",
"version": "1.3.5",
"versionRange": null
"version": "1.3.5"
},
{
"aliases": [],
"artifactId": "bar",
"description": null,
"facets": [],
"groupId": "org.acme",
"id": "org.acme:bar",
"name": "Bar",
"scope": "compile",
"version": "2.1.0",
"versionRange": null
"version": "2.1.0"
},
{
"aliases": [],
"artifactId": "biz",
"description": null,
"facets": [],
"groupId": "org.acme",
"id": "org.acme:biz",
@@ -123,7 +109,6 @@
{
"aliases": [],
"artifactId": "bur",
"description": null,
"facets": [],
"groupId": "org.acme",
"id": "org.acme:bur",
@@ -135,32 +120,26 @@
{
"aliases": [],
"artifactId": "my-api",
"description": null,
"facets": [],
"groupId": "org.acme",
"id": "my-api",
"name": "My API",
"scope": "provided",
"version": null,
"versionRange": null
"scope": "provided"
}
],
"name": "Other"
}
],
"description": null,
"id": "dependencies",
"type": "HIERARCHICAL_MULTI_SELECT"
},
"description": {
"content": "Demo project for Spring Boot",
"description": null,
"id": "description",
"type": "TEXT"
},
"groupId": {
"content": "org.test",
"description": null,
"id": "groupId",
"type": "TEXT"
},
@@ -182,7 +161,6 @@
"name": "1.8"
}
],
"description": null,
"id": "javaVersion",
"type": "SINGLE_SELECT"
},
@@ -199,19 +177,16 @@
"name": "Java"
}
],
"description": null,
"id": "language",
"type": "SINGLE_SELECT"
},
"name": {
"content": "demo",
"description": null,
"id": "name",
"type": "TEXT"
},
"packageName": {
"content": "demo",
"description": null,
"id": "packageName",
"type": "TEXT"
},
@@ -228,7 +203,6 @@
"name": "War"
}
],
"description": null,
"id": "packaging",
"type": "SINGLE_SELECT"
},
@@ -283,13 +257,11 @@
}
}
],
"description": null,
"id": "type",
"type": "ACTION"
},
"version": {
"content": "0.0.1-SNAPSHOT",
"description": null,
"id": "version",
"type": "TEXT"
}