Update metadata format to v2

Update the metadata format in a non backward compatible way to ease
the use of the service from 3rd party clients. The updated metadata
format is now more descriptive and HAL-compliant and can be used with
Spring HATEOAS to build a client.

Metadata v1 is still served to preserve backward compatibility with
Spring Boot 1.2.0.RC1.

Closes gh-49
This commit is contained in:
Stephane Nicoll
2014-11-18 17:05:01 +01:00
parent 8427b32a8e
commit 6116ca94aa
9 changed files with 516 additions and 14 deletions

View File

@@ -30,6 +30,10 @@
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId> <artifactId>jackson-core</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.google.guava</groupId> <groupId>com.google.guava</groupId>
<artifactId>guava</artifactId> <artifactId>guava</artifactId>

View File

@@ -66,6 +66,8 @@ class InitializrMetadata {
@JsonIgnore @JsonIgnore
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.
@@ -121,6 +123,15 @@ class InitializrMetadata {
refreshDefaults() refreshDefaults()
} }
/**
* Generate a JSON representation of the current metadata
*
* @param appUrl the application url
*/
String generateJson(String appUrl) {
jsonMapper.write(this, appUrl)
}
/** /**
* Initialize and validate the configuration. * Initialize and validate the configuration.
*/ */
@@ -261,6 +272,14 @@ class InitializrMetadata {
String action String action
void setAction(String action) {
String actionToUse = action
if (!actionToUse.startsWith("/")) {
actionToUse = "/" + actionToUse
}
this.action = actionToUse
}
final Map<String, String> tags = [:] final Map<String, String> tags = [:]
} }

View File

@@ -0,0 +1,168 @@
/*
* Copyright 2012-2014 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 groovy.json.JsonBuilder
import org.springframework.hateoas.TemplateVariable
import org.springframework.hateoas.TemplateVariables
import org.springframework.hateoas.UriTemplate
/**
* Generate a JSON representation of the metadata.
*
* @author Stephane Nicoll
* @since 1.0
*/
class InitializrMetadataJsonMapper {
private final TemplateVariables templateVariables
InitializrMetadataJsonMapper() {
this.templateVariables = new TemplateVariables(
new TemplateVariable('type', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('packaging', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('javaVersion', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('language', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('bootVersion', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('groupId', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('artifactId', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('version', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('name', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('description', TemplateVariable.VariableType.REQUEST_PARAM),
new TemplateVariable('packageName', TemplateVariable.VariableType.REQUEST_PARAM)
)
}
String write(InitializrMetadata metadata, String appUrl) {
JsonBuilder json = new JsonBuilder()
json {
links(delegate, metadata.types, appUrl)
dependencies(delegate, metadata.dependencies)
type(delegate, metadata.defaults.type, metadata.types)
singleSelect(delegate, 'packaging', metadata.defaults.packaging, metadata.packagings)
singleSelect(delegate, 'javaVersion', metadata.defaults.javaVersion, metadata.javaVersions)
singleSelect(delegate, 'language', metadata.defaults.language, metadata.languages)
singleSelect(delegate, 'bootVersion', metadata.defaults.bootVersion, metadata.bootVersions)
text(delegate, 'groupId', metadata.defaults.groupId)
text(delegate, 'artifactId', metadata.defaults.artifactId)
text(delegate, 'version', metadata.defaults.version)
text(delegate, 'name', metadata.defaults.name)
text(delegate, 'description', metadata.defaults.description)
text(delegate, 'packageName', metadata.defaults.packageName)
}
json.toString()
}
private links(parent, types, appUrl) {
def content = [:]
types.each {
content[it.id] = link(appUrl, it)
}
parent._links content
}
private link(appUrl, type) {
def result = [:]
result.href = generateTemplatedUri(appUrl, type.action)
result.templated = true
result
}
private generateTemplatedUri(appUrl, action) {
String uri = appUrl != null ? appUrl + action : action
UriTemplate uriTemplate = new UriTemplate(uri + '?style={dependencies}', this.templateVariables)
uriTemplate.toString()
}
private static dependencies(parent, groups) {
parent.dependencies {
type 'hierarchical-multi-select'
values groups.collect {
processDependencyGroup(it)
}
}
}
private static type(parent, defaultValue, dependencies) {
parent.type {
type 'action'
if (defaultValue) {
'default' defaultValue
}
values dependencies.collect {
processType(it)
}
}
}
private static singleSelect(parent, name, defaultValue, itemValues) {
parent."$name" {
type 'single-select'
if (defaultValue) {
'default' defaultValue
}
values itemValues.collect {
processValue(it)
}
}
}
private static text(parent, name, value) {
parent."$name" {
type 'text'
if (value) {
'default' value
}
}
}
private static processDependencyGroup(group) {
def result = [:]
result.name = group.name
if (group.hasProperty('description') && group.description) {
result.description = group.description
}
def items = []
group.content.collect {
items << processValue(it)
}
result.values = items
result
}
private static processType(type) {
def result = processValue(type)
result.action = type.action
result.tags = type.tags
result
}
private static processValue(value) {
def result = [:]
result.id = value.id
result.name = value.name
if (value.hasProperty('description') && value.description) {
result.description = value.description
}
result
}
}

View File

@@ -29,6 +29,7 @@ import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
/** /**
* The main initializr controller provides access to the configured * The main initializr controller provides access to the configured
@@ -53,12 +54,20 @@ class MainController extends AbstractInitializrController {
request request
} }
@RequestMapping(value = "/") @RequestMapping(value = "/", headers = "user-agent=SpringBootCli/1.2.0.RC1")
@ResponseBody @ResponseBody
InitializrMetadata metadata() { @Deprecated
InitializrMetadata oldMetadata() {
metadataProvider.get() metadataProvider.get()
} }
@RequestMapping(value = "/", produces = ["application/vnd.initializr.v2+json","application/json"])
@ResponseBody
String metadata() {
String appUrl = ServletUriComponentsBuilder.fromCurrentServletMapping().build()
metadataProvider.get().generateJson(appUrl)
}
@RequestMapping(value = '/', produces = 'text/html') @RequestMapping(value = '/', produces = 'text/html')
@ResponseBody @ResponseBody
String home() { String home() {

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2012-2014 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 groovy.json.JsonSlurper
import io.spring.initializr.support.InitializrMetadataBuilder
import org.junit.Test
import static org.junit.Assert.assertEquals
/**
* @author Stephane Nicoll
*/
class InitializrMetadataJsonMapperTests {
private final InitializrMetadataJsonMapper jsonMapper = new InitializrMetadataJsonMapper()
private final JsonSlurper slurper = new JsonSlurper()
@Test
void withNoAppUrl() {
InitializrMetadata metadata = new InitializrMetadataBuilder().addType('foo', true, '/foo.zip', 'none', 'test')
.addDependencyGroup('foo', 'one', 'two').validateAndGet()
def json = jsonMapper.write(metadata, null)
def result = slurper.parseText(json)
assertEquals '/foo.zip?style={dependencies}{&type,packaging,javaVersion,language,bootVersion,' +
'groupId,artifactId,version,name,description,packageName}', result._links.foo.href
}
@Test
void withAppUrl() {
InitializrMetadata metadata = new InitializrMetadataBuilder().addType('foo', true, '/foo.zip', 'none', 'test')
.addDependencyGroup('foo', 'one', 'two').validateAndGet()
def json = jsonMapper.write(metadata, 'http://server:8080/my-app')
def result = slurper.parseText(json)
assertEquals 'http://server:8080/my-app/foo.zip?style={dependencies}{&type,packaging,javaVersion,' +
'language,bootVersion,groupId,artifactId,version,name,description,packageName}',
result._links.foo.href
}
}

View File

@@ -177,6 +177,13 @@ class InitializrMetadataTests {
assertEquals metadata.defaults.groupId, request.groupId assertEquals metadata.defaults.groupId, request.groupId
} }
@Test
void validateAction() {
def metadata = new InitializrMetadataBuilder()
.addType('foo', false, 'my-action.zip', 'none', 'none').validateAndGet()
assertEquals '/my-action.zip', metadata.getType('foo').action
}
@Test @Test
void validateArtifactRepository() { void validateArtifactRepository() {
def metadata = InitializrMetadataBuilder.withDefaults().instance() def metadata = InitializrMetadataBuilder.withDefaults().instance()

View File

@@ -50,7 +50,7 @@ abstract class AbstractInitializrControllerIntegrationTests {
public final TemporaryFolder folder = new TemporaryFolder() public final TemporaryFolder folder = new TemporaryFolder()
@Value('${local.server.port}') @Value('${local.server.port}')
private int port protected int port
final RestTemplate restTemplate = new RestTemplate() final RestTemplate restTemplate = new RestTemplate()

View File

@@ -26,7 +26,11 @@ import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode import org.skyscreamer.jsonassert.JSONCompareMode
import org.springframework.core.io.ClassPathResource import org.springframework.core.io.ClassPathResource
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ActiveProfiles
import org.springframework.util.StreamUtils import org.springframework.util.StreamUtils
@@ -40,8 +44,10 @@ import static org.junit.Assert.*
@ActiveProfiles('test-default') @ActiveProfiles('test-default')
class MainControllerIntegrationTests extends AbstractInitializrControllerIntegrationTests { class MainControllerIntegrationTests extends AbstractInitializrControllerIntegrationTests {
private final def slurper = new JsonSlurper() private static final MediaType CURRENT_METADATA_MEDIA_TYPE =
MediaType.parseMediaType('application/vnd.initializr.v2+json')
private final def slurper = new JsonSlurper()
@Test @Test
void simpleZipProject() { void simpleZipProject() {
@@ -104,25 +110,50 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
} }
@Test
void metadataWithNoAcceptHeader() { // rest template sets application/json by default
ResponseEntity<String> response = getMetadata(null, '*/*')
assertEquals CURRENT_METADATA_MEDIA_TYPE, response.getHeaders().getContentType()
validateCurrentMetadata(new JSONObject(response.body))
}
@Test
void metadataWithCurrentAcceptHeader() {
ResponseEntity<String> response = getMetadata(null, 'application/vnd.initializr.v2+json')
assertEquals CURRENT_METADATA_MEDIA_TYPE, response.getHeaders().getContentType()
validateCurrentMetadata(new JSONObject(response.body))
}
@Test // Test that the current output is exactly what we expect @Test // Test that the current output is exactly what we expect
void validateCurrentProjectMetadata() { void validateCurrentProjectMetadata() {
def json = restTemplate.getForObject(createUrl('/'), String.class) def json = getMetadataJson()
def expected = readJson('1.1.0') validateCurrentMetadata(json)
JSONAssert.assertEquals(expected, new JSONObject(json), JSONCompareMode.STRICT) }
private void validateCurrentMetadata(JSONObject json) {
def expected = readJson('2.0.0')
JSONAssert.assertEquals(expected, json, JSONCompareMode.STRICT)
}
@Test // Test that the current code complies exactly with 1.1.0
void validateProjectMetadata110() {
JSONObject json = getMetadataJson("SpringBootCli/1.2.0.RC1", null)
def expected = readJson('1.0.1')
JSONAssert.assertEquals(expected, json, JSONCompareMode.LENIENT)
} }
@Test // Test that the current code complies "at least" with 1.0.1 @Test // Test that the current code complies "at least" with 1.0.1
void validateProjectMetadata101() { void validateProjectMetadata101() {
def json = restTemplate.getForObject(createUrl('/'), String.class) JSONObject json = getMetadataJson("SpringBootCli/1.2.0.RC1", null)
def expected = readJson('1.0.1') def expected = readJson('1.0.1')
JSONAssert.assertEquals(expected, new JSONObject(json), JSONCompareMode.LENIENT) JSONAssert.assertEquals(expected, json, JSONCompareMode.LENIENT)
} }
@Test // Test that the current code complies "at least" with 1.0.0 @Test // Test that the current code complies "at least" with 1.0.0
void validateProjectMetadata100() { void validateProjectMetadata100() {
def json = restTemplate.getForObject(createUrl('/'), String.class) JSONObject json = getMetadataJson("SpringBootCli/1.2.0.RC1", null)
def expected = readJson('1.0.0') def expected = readJson('1.0.0')
JSONAssert.assertEquals(expected, new JSONObject(json), JSONCompareMode.LENIENT) JSONAssert.assertEquals(expected, json, JSONCompareMode.LENIENT)
} }
@Test @Test
@@ -186,7 +217,7 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
@Test @Test
void homeIsJson() { void homeIsJson() {
def body = restTemplate.getForObject(createUrl('/'), String) def body = restTemplate.getForObject(createUrl('/'), String)
assertTrue("Wrong body:\n$body", body.contains('{"dependencies"')) assertTrue("Wrong body:\n$body", body.contains('"dependencies"'))
} }
@Test @Test
@@ -237,6 +268,27 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
assertNotNull(response.body) assertNotNull(response.body)
} }
private JSONObject getMetadataJson() {
getMetadataJson(null, null)
}
private JSONObject getMetadataJson(String userAgentHeader, String acceptHeader) {
String json = getMetadata(userAgentHeader, acceptHeader).body
return new JSONObject(json)
}
private ResponseEntity<String> getMetadata(String userAgentHeader, String acceptHeader) {
HttpHeaders headers = new HttpHeaders();
if (userAgentHeader) {
headers.set("User-Agent", userAgentHeader);
}
if (acceptHeader) {
headers.setAccept(Collections.singletonList(MediaType.parseMediaType(acceptHeader)))
}
return restTemplate.exchange(createUrl('/'),
HttpMethod.GET, new HttpEntity<Void>(headers), String.class)
}
private byte[] invoke(String context) { private byte[] invoke(String context) {
restTemplate.getForObject(createUrl(context), byte[]) restTemplate.getForObject(createUrl(context), byte[])
@@ -252,12 +304,15 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
tgzProjectAssert(body) tgzProjectAssert(body)
} }
private static JSONObject readJson(String version) { private JSONObject readJson(String version) {
def resource = new ClassPathResource("metadata/test-default-$version" + ".json") def resource = new ClassPathResource("metadata/test-default-$version" + ".json")
def stream = resource.inputStream def stream = resource.inputStream
try { try {
def json = StreamUtils.copyToString(stream, Charset.forName('UTF-8')) def json = StreamUtils.copyToString(stream, Charset.forName('UTF-8'))
new JSONObject(json)
// Let's parse the port as it is random
def content = json.replaceAll('@port@', String.valueOf(this.port))
new JSONObject(content)
} finally { } finally {
stream.close() stream.close()
} }

View File

@@ -0,0 +1,186 @@
{
"_links": {
"maven-build": {
"href": "http://localhost:@port@/pom.xml?style={dependencies}{&type,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"maven-project": {
"href": "http://localhost:@port@/starter.zip?style={dependencies}{&type,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"gradle-build": {
"href": "http://localhost:@port@/build.gradle?style={dependencies}{&type,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"gradle-project": {
"href": "http://localhost:@port@/starter.zip?style={dependencies}{&type,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"
}
]
}
]
},
"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"
}
}