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>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>

View File

@@ -66,6 +66,8 @@ class InitializrMetadata {
@JsonIgnore
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.
@@ -121,6 +123,15 @@ class InitializrMetadata {
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.
*/
@@ -261,6 +272,14 @@ class InitializrMetadata {
String action
void setAction(String action) {
String actionToUse = action
if (!actionToUse.startsWith("/")) {
actionToUse = "/" + actionToUse
}
this.action = actionToUse
}
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.RequestMapping
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
/**
* The main initializr controller provides access to the configured
@@ -53,12 +54,20 @@ class MainController extends AbstractInitializrController {
request
}
@RequestMapping(value = "/")
@RequestMapping(value = "/", headers = "user-agent=SpringBootCli/1.2.0.RC1")
@ResponseBody
InitializrMetadata metadata() {
@Deprecated
InitializrMetadata oldMetadata() {
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')
@ResponseBody
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
}
@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
void validateArtifactRepository() {
def metadata = InitializrMetadataBuilder.withDefaults().instance()

View File

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

View File

@@ -26,7 +26,11 @@ import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode
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.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.test.context.ActiveProfiles
import org.springframework.util.StreamUtils
@@ -40,8 +44,10 @@ import static org.junit.Assert.*
@ActiveProfiles('test-default')
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
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
void validateCurrentProjectMetadata() {
def json = restTemplate.getForObject(createUrl('/'), String.class)
def expected = readJson('1.1.0')
JSONAssert.assertEquals(expected, new JSONObject(json), JSONCompareMode.STRICT)
def json = getMetadataJson()
validateCurrentMetadata(json)
}
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
void validateProjectMetadata101() {
def json = restTemplate.getForObject(createUrl('/'), String.class)
JSONObject json = getMetadataJson("SpringBootCli/1.2.0.RC1", null)
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
void validateProjectMetadata100() {
def json = restTemplate.getForObject(createUrl('/'), String.class)
JSONObject json = getMetadataJson("SpringBootCli/1.2.0.RC1", null)
def expected = readJson('1.0.0')
JSONAssert.assertEquals(expected, new JSONObject(json), JSONCompareMode.LENIENT)
JSONAssert.assertEquals(expected, json, JSONCompareMode.LENIENT)
}
@Test
@@ -186,7 +217,7 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
@Test
void homeIsJson() {
def body = restTemplate.getForObject(createUrl('/'), String)
assertTrue("Wrong body:\n$body", body.contains('{"dependencies"'))
assertTrue("Wrong body:\n$body", body.contains('"dependencies"'))
}
@Test
@@ -237,6 +268,27 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
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) {
restTemplate.getForObject(createUrl(context), byte[])
@@ -252,12 +304,15 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
tgzProjectAssert(body)
}
private static JSONObject readJson(String version) {
private JSONObject readJson(String version) {
def resource = new ClassPathResource("metadata/test-default-$version" + ".json")
def stream = resource.inputStream
try {
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 {
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"
}
}