diff --git a/initializr/pom.xml b/initializr/pom.xml index 73e77248..e424cb20 100644 --- a/initializr/pom.xml +++ b/initializr/pom.xml @@ -30,6 +30,10 @@ com.fasterxml.jackson.core jackson-core + + org.springframework.hateoas + spring-hateoas + com.google.guava guava diff --git a/initializr/src/main/groovy/io/spring/initializr/InitializrMetadata.groovy b/initializr/src/main/groovy/io/spring/initializr/InitializrMetadata.groovy index f80c5867..39a496dd 100644 --- a/initializr/src/main/groovy/io/spring/initializr/InitializrMetadata.groovy +++ b/initializr/src/main/groovy/io/spring/initializr/InitializrMetadata.groovy @@ -66,6 +66,8 @@ class InitializrMetadata { @JsonIgnore private final Map 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 tags = [:] } diff --git a/initializr/src/main/groovy/io/spring/initializr/InitializrMetadataJsonMapper.groovy b/initializr/src/main/groovy/io/spring/initializr/InitializrMetadataJsonMapper.groovy new file mode 100644 index 00000000..f8b6334c --- /dev/null +++ b/initializr/src/main/groovy/io/spring/initializr/InitializrMetadataJsonMapper.groovy @@ -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 + } + +} diff --git a/initializr/src/main/groovy/io/spring/initializr/web/MainController.groovy b/initializr/src/main/groovy/io/spring/initializr/web/MainController.groovy index d38af95e..0a27bdc8 100644 --- a/initializr/src/main/groovy/io/spring/initializr/web/MainController.groovy +++ b/initializr/src/main/groovy/io/spring/initializr/web/MainController.groovy @@ -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() { diff --git a/initializr/src/test/groovy/io/spring/initializr/InitializrMetadataJsonMapperTests.groovy b/initializr/src/test/groovy/io/spring/initializr/InitializrMetadataJsonMapperTests.groovy new file mode 100644 index 00000000..c6a0d4ed --- /dev/null +++ b/initializr/src/test/groovy/io/spring/initializr/InitializrMetadataJsonMapperTests.groovy @@ -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 + } + +} diff --git a/initializr/src/test/groovy/io/spring/initializr/InitializrMetadataTests.groovy b/initializr/src/test/groovy/io/spring/initializr/InitializrMetadataTests.groovy index ec0dcee4..96994e91 100644 --- a/initializr/src/test/groovy/io/spring/initializr/InitializrMetadataTests.groovy +++ b/initializr/src/test/groovy/io/spring/initializr/InitializrMetadataTests.groovy @@ -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() diff --git a/initializr/src/test/groovy/io/spring/initializr/web/AbstractInitializrControllerIntegrationTests.groovy b/initializr/src/test/groovy/io/spring/initializr/web/AbstractInitializrControllerIntegrationTests.groovy index 883f4499..dae97a8d 100644 --- a/initializr/src/test/groovy/io/spring/initializr/web/AbstractInitializrControllerIntegrationTests.groovy +++ b/initializr/src/test/groovy/io/spring/initializr/web/AbstractInitializrControllerIntegrationTests.groovy @@ -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() diff --git a/initializr/src/test/groovy/io/spring/initializr/web/MainControllerIntegrationTests.groovy b/initializr/src/test/groovy/io/spring/initializr/web/MainControllerIntegrationTests.groovy index b9009e8f..914cf62f 100644 --- a/initializr/src/test/groovy/io/spring/initializr/web/MainControllerIntegrationTests.groovy +++ b/initializr/src/test/groovy/io/spring/initializr/web/MainControllerIntegrationTests.groovy @@ -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 response = getMetadata(null, '*/*') + assertEquals CURRENT_METADATA_MEDIA_TYPE, response.getHeaders().getContentType() + validateCurrentMetadata(new JSONObject(response.body)) + } + + @Test + void metadataWithCurrentAcceptHeader() { + ResponseEntity 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 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(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() } diff --git a/initializr/src/test/resources/metadata/test-default-2.0.0.json b/initializr/src/test/resources/metadata/test-default-2.0.0.json new file mode 100644 index 00000000..de896065 --- /dev/null +++ b/initializr/src/test/resources/metadata/test-default-2.0.0.json @@ -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" + } +} \ No newline at end of file