Initiate initializr documentation

This commit commit adds restdocs and stub generators and initiate
a reference guide for Initializr.

Most of the controller tests now use MockMvc via a custom version
of the MockMvcClientHttpRequestFactory (from spring-test). The
snippet names are auto-generated in the form

<HttpMethod>/<path>[/queries(/<name-value)*][/headers](/name-value)*]

when there is a comma-separated value in a header it is
abbreviated as <first-value>.MORE.

Wiremock stubs are generated in the same form under
snippets/stubs (with ".json" as the
file extension).

The controller tests that stayed as full stack use a different
base class AbstractFullStackInitializrIntegrationTests.

A long JSON body can be broken out into separate snippets
for each field (or rather a list of fields supplied by the
user). This feature was already used with hard-coded snippets
in the wiki.

See gh-295
This commit is contained in:
Dave Syer 2016-09-26 16:13:35 +01:00 committed by Stephane Nicoll
parent 6efcef1186
commit b7d8d5c813
27 changed files with 1543 additions and 211 deletions

View File

@ -65,6 +65,11 @@
<artifactId>json-path</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>

View File

@ -17,6 +17,8 @@
package io.spring.initializr.actuate.stat
import groovy.json.JsonSlurper
import io.spring.initializr.web.AbstractFullStackInitializrIntegrationTests;
import io.spring.initializr.web.AbstractInitializrControllerIntegrationTests
import org.junit.Before
import org.junit.Test
@ -46,7 +48,7 @@ import static org.junit.Assert.fail
*/
@Import(StatsMockController)
@ActiveProfiles(['test-default', 'test-custom-stats'])
class MainControllerStatsIntegrationTests extends AbstractInitializrControllerIntegrationTests {
class MainControllerStatsIntegrationTests extends AbstractFullStackInitializrIntegrationTests {
@Autowired
private StatsMockController statsMockController

47
initializr-stubs/pom.xml Normal file
View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.spring.initializr</groupId>
<artifactId>initializr</artifactId>
<version>1.0.0.BUILD-SNAPSHOT</version>
</parent>
<artifactId>initializr-stubs</artifactId>
<name>Spring Initializr :: Stubs</name>
<build>
<plugins>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<id>default-jar</id>
<!-- put the default-jar in the none phase to skip it from being created -->
<phase>none</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<id>stub</id>
<phase>prepare-package</phase>
<goals>
<goal>single</goal>
</goals>
<inherited>false</inherited>
<configuration>
<attach>true</attach>
<descriptor>${basedir}/src/assembly/stub.xml</descriptor>
<appendAssemblyId>false</appendAssemblyId>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,19 @@
<assembly
xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
<id>stubs</id>
<formats>
<format>jar</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<fileSets>
<fileSet>
<directory>${basedir}/../initializr-web/target/snippets/stubs</directory>
<outputDirectory>META-INF/${project.groupId}/${project.artifactId}/${project.version}/mappings</outputDirectory>
<includes>
<include>**/*</include>
</includes>
</fileSet>
</fileSets>
</assembly>

View File

@ -61,6 +61,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-wiremock</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.spring.initializr</groupId>
<artifactId>initializr-generator</artifactId>
@ -72,6 +77,11 @@
<artifactId>spring-boot-starter-web</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>xmlunit</groupId>
<artifactId>xmlunit</artifactId>
@ -99,6 +109,18 @@
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Camden.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>

View File

@ -0,0 +1,46 @@
/*
* Copyright 2012-2016 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.web
import io.spring.initializr.web.AbstractInitializrIntegrationTests.Config
import org.junit.runner.RunWith
import org.springframework.boot.context.embedded.LocalServerPort
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit4.SpringRunner
import static org.junit.Assert.assertTrue
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
/**
* @author Stephane Nicoll
* @author Dave Syer
*/
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Config.class, webEnvironment = RANDOM_PORT)
abstract class AbstractFullStackInitializrIntegrationTests extends AbstractInitializrIntegrationTests {
@LocalServerPort
int port
String host = "localhost"
String createUrl(String context) {
"http://${host}:${port}" + (context.startsWith('/') ? context : '/' + context)
}
}

View File

@ -16,213 +16,49 @@
package io.spring.initializr.web
import java.nio.charset.Charset
import io.spring.initializr.web.test.MockMvcClientHttpRequestFactory
import io.spring.initializr.web.test.MockMvcClientHttpRequestFactoryTestExecutionListener
import io.spring.initializr.metadata.InitializrMetadata
import io.spring.initializr.metadata.InitializrMetadataBuilder
import io.spring.initializr.metadata.InitializrMetadataProvider
import io.spring.initializr.metadata.InitializrProperties
import io.spring.initializr.test.generator.ProjectAssert
import io.spring.initializr.web.mapper.InitializrMetadataVersion
import io.spring.initializr.web.support.DefaultInitializrMetadataProvider
import org.json.JSONObject
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
import org.springframework.boot.context.embedded.LocalServerPort
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.beans.factory.BeanFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.web.client.RestTemplateCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
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.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.util.StreamUtils
import org.springframework.web.client.RestTemplate
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.TestExecutionListeners.MergeMode
import static org.junit.Assert.assertTrue
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment
/**
* @author Stephane Nicoll
*/
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Config.class, webEnvironment = RANDOM_PORT)
abstract class AbstractInitializrControllerIntegrationTests {
@ContextConfiguration(classes = RestTemplateConfig)
@TestExecutionListeners(mergeMode = MergeMode.MERGE_WITH_DEFAULTS, listeners = MockMvcClientHttpRequestFactoryTestExecutionListener)
@AutoConfigureMockMvc
@AutoConfigureRestDocs(outputDir="target/snippets", uriPort=80, uriHost="start.spring.io")
abstract class AbstractInitializrControllerIntegrationTests extends AbstractInitializrIntegrationTests {
static final MediaType CURRENT_METADATA_MEDIA_TYPE = InitializrMetadataVersion.V2_1.mediaType
@Rule
public final TemporaryFolder folder = new TemporaryFolder()
@LocalServerPort
protected int port
final RestTemplate restTemplate = new RestTemplate()
String host = "start.spring.io"
@Autowired
MockMvcClientHttpRequestFactory requests
String createUrl(String context) {
"http://localhost:$port$context"
}
String htmlHome() {
def headers = new HttpHeaders()
headers.setAccept([MediaType.TEXT_HTML])
restTemplate.exchange(createUrl('/'), HttpMethod.GET, new HttpEntity<Void>(headers), String).body
}
/**
* Validate the 'Content-Type' header of the specified response.
*/
protected void validateContentType(ResponseEntity<String> response, MediaType expected) {
def actual = response.headers.getContentType()
assertTrue "Non compatible media-type, expected $expected, got $actual",
actual.isCompatibleWith(expected)
}
protected void validateMetadata(ResponseEntity<String> response, MediaType mediaType,
String version, JSONCompareMode compareMode) {
validateContentType(response, mediaType)
def json = new JSONObject(response.body)
def expected = readMetadataJson(version)
JSONAssert.assertEquals(expected, json, compareMode)
}
protected void validateCurrentMetadata(ResponseEntity<String> response) {
validateContentType(response, CURRENT_METADATA_MEDIA_TYPE)
validateCurrentMetadata(new JSONObject(response.body))
}
protected void validateCurrentMetadata(JSONObject json) {
def expected = readMetadataJson('2.1.0')
JSONAssert.assertEquals(expected, json, JSONCompareMode.STRICT)
}
private JSONObject readMetadataJson(String version) {
readJsonFrom("metadata/test-default-$version" + ".json")
}
/**
* Return a {@link ProjectAssert} for the following archive content.
*/
protected ProjectAssert zipProjectAssert(byte[] content) {
projectAssert(content, ArchiveType.ZIP)
}
/**
* Return a {@link ProjectAssert} for the following TGZ archive.
*/
protected ProjectAssert tgzProjectAssert(byte[] content) {
projectAssert(content, ArchiveType.TGZ)
}
protected ProjectAssert downloadZip(String context) {
def body = downloadArchive(context)
zipProjectAssert(body)
}
protected ProjectAssert downloadTgz(String context) {
def body = downloadArchive(context)
tgzProjectAssert(body)
}
protected byte[] downloadArchive(String context) {
restTemplate.getForObject(createUrl(context), byte[])
}
protected ResponseEntity<String> invokeHome(String userAgentHeader, String... acceptHeaders) {
execute('/', String, userAgentHeader, acceptHeaders)
}
protected <T> ResponseEntity<T> execute(String contextPath, Class<T> responseType,
String userAgentHeader, String... acceptHeaders) {
HttpHeaders headers = new HttpHeaders();
if (userAgentHeader) {
headers.set("User-Agent", userAgentHeader);
}
if (acceptHeaders) {
List<MediaType> mediaTypes = new ArrayList<>()
for (String acceptHeader : acceptHeaders) {
mediaTypes.add(MediaType.parseMediaType(acceptHeader))
}
headers.setAccept(mediaTypes)
} else {
headers.setAccept(Collections.emptyList())
}
return restTemplate.exchange(createUrl(contextPath),
HttpMethod.GET, new HttpEntity<Void>(headers), responseType)
}
protected ProjectAssert projectAssert(byte[] content, ArchiveType archiveType) {
def archiveFile = writeArchive(content)
def project = folder.newFolder()
switch (archiveType) {
case ArchiveType.ZIP:
new AntBuilder().unzip(dest: project, src: archiveFile)
break
case ArchiveType.TGZ:
new AntBuilder().untar(dest: project, src: archiveFile, compression: 'gzip')
break
}
new ProjectAssert(project)
}
protected File writeArchive(byte[] body) {
def archiveFile = folder.newFile()
def stream = new FileOutputStream(archiveFile)
try {
stream.write(body)
} finally {
stream.close()
}
archiveFile
}
protected JSONObject readJsonFrom(String path) {
def resource = new ClassPathResource(path)
def stream = resource.inputStream
try {
def json = StreamUtils.copyToString(stream, Charset.forName('UTF-8'))
// Let's parse the port as it is random
def content = json.replaceAll('@port@', String.valueOf(this.port))
new JSONObject(content)
} finally {
stream.close()
}
}
private enum ArchiveType {
ZIP,
TGZ
context.startsWith('/') ? context : '/' + context
}
@Configuration
@EnableAutoConfiguration
static class Config {
static class RestTemplateConfig {
@Bean
InitializrMetadataProvider initializrMetadataProvider(InitializrProperties properties) {
new DefaultInitializrMetadataProvider(
InitializrMetadataBuilder.fromInitializrProperties(properties).build(),
new RestTemplate()) {
@Override
protected void updateInitializrMetadata(InitializrMetadata metadata) {
null // Disable metadata fetching from spring.io
}
RestTemplateCustomizer mockMvcCustomizer(BeanFactory beanFactory) {
{ template ->
template.setRequestFactory(beanFactory.getBean(MockMvcClientHttpRequestFactory))
}
}
}
}

View File

@ -0,0 +1,237 @@
/*
* Copyright 2012-2016 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.web
import io.spring.initializr.metadata.InitializrMetadata
import io.spring.initializr.metadata.InitializrMetadataBuilder
import io.spring.initializr.metadata.InitializrMetadataProvider
import io.spring.initializr.metadata.InitializrProperties
import io.spring.initializr.test.generator.ProjectAssert
import io.spring.initializr.web.mapper.InitializrMetadataVersion
import io.spring.initializr.web.support.DefaultInitializrMetadataProvider
import org.json.JSONObject
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.context.annotation.Bean
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.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.util.StreamUtils
import org.springframework.web.client.RestTemplate
import java.nio.charset.Charset
import static org.junit.Assert.assertTrue
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
/**
* @author Stephane Nicoll
*/
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Config.class)
abstract class AbstractInitializrIntegrationTests {
static final MediaType CURRENT_METADATA_MEDIA_TYPE = InitializrMetadataVersion.V2_1.mediaType
@Rule
public final TemporaryFolder folder = new TemporaryFolder()
@Autowired
private RestTemplateBuilder restTemplateBuilder
RestTemplate restTemplate
@Before
void before() {
restTemplate = restTemplateBuilder.build()
}
abstract String createUrl(String context)
String htmlHome() {
def headers = new HttpHeaders()
headers.setAccept([MediaType.TEXT_HTML])
restTemplate.exchange(createUrl('/'), HttpMethod.GET, new HttpEntity<Void>(headers), String).body
}
/**
* Validate the 'Content-Type' header of the specified response.
*/
protected void validateContentType(ResponseEntity<String> response, MediaType expected) {
def actual = response.headers.getContentType()
assertTrue "Non compatible media-type, expected $expected, got $actual",
actual.isCompatibleWith(expected)
}
protected void validateMetadata(ResponseEntity<String> response, MediaType mediaType,
String version, JSONCompareMode compareMode) {
validateContentType(response, mediaType)
def json = new JSONObject(response.body)
def expected = readMetadataJson(version)
JSONAssert.assertEquals(expected, json, compareMode)
}
protected void validateCurrentMetadata(ResponseEntity<String> response) {
validateContentType(response, CURRENT_METADATA_MEDIA_TYPE)
validateCurrentMetadata(new JSONObject(response.body))
}
protected void validateCurrentMetadata(JSONObject json) {
def expected = readMetadataJson('2.1.0')
JSONAssert.assertEquals(expected, json, JSONCompareMode.STRICT)
}
private JSONObject readMetadataJson(String version) {
readJsonFrom("metadata/test-default-$version" + ".json")
}
/**
* Return a {@link ProjectAssert} for the following archive content.
*/
protected ProjectAssert zipProjectAssert(byte[] content) {
projectAssert(content, ArchiveType.ZIP)
}
/**
* Return a {@link ProjectAssert} for the following TGZ archive.
*/
protected ProjectAssert tgzProjectAssert(byte[] content) {
projectAssert(content, ArchiveType.TGZ)
}
protected ProjectAssert downloadZip(String context) {
def body = downloadArchive(context)
zipProjectAssert(body)
}
protected ProjectAssert downloadTgz(String context) {
def body = downloadArchive(context)
tgzProjectAssert(body)
}
protected byte[] downloadArchive(String context) {
restTemplate.getForObject(createUrl(context), byte[])
}
protected ResponseEntity<String> invokeHome(String userAgentHeader, String... acceptHeaders) {
execute('/', String, userAgentHeader, acceptHeaders)
}
protected <T> ResponseEntity<T> execute(String contextPath, Class<T> responseType,
String userAgentHeader, String... acceptHeaders) {
HttpHeaders headers = new HttpHeaders();
if (userAgentHeader) {
headers.set("User-Agent", userAgentHeader);
}
if (acceptHeaders) {
List<MediaType> mediaTypes = new ArrayList<>()
for (String acceptHeader : acceptHeaders) {
mediaTypes.add(MediaType.parseMediaType(acceptHeader))
}
headers.setAccept(mediaTypes)
} else {
headers.setAccept(Collections.emptyList())
}
return restTemplate.exchange(createUrl(contextPath),
HttpMethod.GET, new HttpEntity<Void>(headers), responseType)
}
protected ProjectAssert projectAssert(byte[] content, ArchiveType archiveType) {
def archiveFile = writeArchive(content)
def project = folder.newFolder()
switch (archiveType) {
case ArchiveType.ZIP:
new AntBuilder().unzip(dest: project, src: archiveFile)
break
case ArchiveType.TGZ:
new AntBuilder().untar(dest: project, src: archiveFile, compression: 'gzip')
break
}
new ProjectAssert(project)
}
protected File writeArchive(byte[] body) {
def archiveFile = folder.newFile()
def stream = new FileOutputStream(archiveFile)
try {
stream.write(body)
} finally {
stream.close()
}
archiveFile
}
protected JSONObject readJsonFrom(String path) {
def resource = new ClassPathResource(path)
def stream = resource.inputStream
try {
def json = StreamUtils.copyToString(stream, Charset.forName('UTF-8'))
String placeholder = ""
if (this.hasProperty("host")) {
placeholder = "$host"
}
if (this.hasProperty("port")) {
placeholder = "$host:$port"
}
// Let's parse the port as it is random
// TODO: put the port back somehow so it appears in stubs
def content = json.replaceAll('@host@', placeholder)
new JSONObject(content)
} finally {
stream.close()
}
}
private enum ArchiveType {
ZIP,
TGZ
}
@EnableAutoConfiguration
static class Config {
@Bean
InitializrMetadataProvider initializrMetadataProvider(InitializrProperties properties) {
new DefaultInitializrMetadataProvider(
InitializrMetadataBuilder.fromInitializrProperties(properties).build(),
new RestTemplate()) {
@Override
protected void updateInitializrMetadata(InitializrMetadata metadata) {
null // Disable metadata fetching from spring.io
}
}
}
}
}

View File

@ -45,7 +45,7 @@ class MainControllerEnvIntegrationTests extends AbstractInitializrControllerInte
void doNotForceSsl() {
ResponseEntity<String> response = invokeHome('curl/1.2.4', "*/*")
String body = response.getBody()
assertTrue "Must not force https", body.contains("http://localhost:$port/")
assertTrue "Must not force https", body.contains("http://start.spring.io/")
assertFalse "Must not force https", body.contains('https://')
}

View File

@ -74,7 +74,7 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
@Test
void dependencyInRange() {
def biz = new Dependency(id: 'biz', groupId: 'org.acme',
artifactId: 'biz', version: '1.3.5', scope: 'runtime')
artifactId: 'biz', version: '1.3.5', scope: 'runtime')
downloadTgz('/starter.tgz?style=org.acme:biz&bootVersion=1.2.1.RELEASE').isJavaProject().isMavenProject()
.hasStaticAndTemplatesResources(false).pomAssert()
.hasDependenciesCount(2)
@ -153,7 +153,8 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
}
@Test
void metadataWithNoAcceptHeader() { // rest template sets application/json by default
void metadataWithNoAcceptHeader() {
// rest template sets application/json by default
ResponseEntity<String> response = invokeHome(null, '*/*')
validateCurrentMetadata(response)
}
@ -167,6 +168,9 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
@Test
void metadataWithV2AcceptHeader() {
requests.setFields("_links.maven-project", "dependencies.values[0]", "type.values[0]",
"javaVersion.values[0]", "packaging.values[0]",
"bootVersion.values[0]", "language.values[0]");
ResponseEntity<String> response = invokeHome(null, 'application/vnd.initializr.v2+json')
validateMetadata(response, InitializrMetadataVersion.V2.mediaType, '2.0.0', JSONCompareMode.STRICT)
}

View File

@ -16,15 +16,16 @@
package io.spring.initializr.web.project
import static org.junit.Assert.assertEquals
import io.spring.initializr.metadata.InitializrMetadata
import io.spring.initializr.metadata.InitializrMetadataBuilder
import io.spring.initializr.metadata.InitializrMetadataProvider
import io.spring.initializr.web.AbstractInitializrControllerIntegrationTests
import io.spring.initializr.web.AbstractFullStackInitializrIntegrationTests
import org.json.JSONObject
import org.junit.Test
import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.core.io.UrlResource
import org.springframework.http.HttpStatus
@ -33,13 +34,11 @@ import org.springframework.http.ResponseEntity
import org.springframework.test.context.ActiveProfiles
import org.springframework.web.client.HttpClientErrorException
import static org.junit.Assert.assertEquals
/**
* @author Stephane Nicoll
*/
@ActiveProfiles('test-default')
class MainControllerServiceMetadataIntegrationTests extends AbstractInitializrControllerIntegrationTests {
class MainControllerServiceMetadataIntegrationTests extends AbstractFullStackInitializrIntegrationTests {
@Autowired
private InitializrMetadataProvider metadataProvider

View File

@ -18,7 +18,7 @@ package io.spring.initializr.web.project
import geb.Browser
import io.spring.initializr.test.generator.ProjectAssert
import io.spring.initializr.web.AbstractInitializrControllerIntegrationTests
import io.spring.initializr.web.AbstractFullStackInitializrIntegrationTests
import io.spring.initializr.web.project.test.HomePage
import org.junit.After
import org.junit.Assume
@ -40,7 +40,7 @@ import static org.junit.Assert.assertTrue
* @author Stephane Nicoll
*/
@ActiveProfiles('test-default')
class ProjectGenerationSmokeTests extends AbstractInitializrControllerIntegrationTests {
class ProjectGenerationSmokeTests extends AbstractFullStackInitializrIntegrationTests {
private File downloadDir
private WebDriver driver

View File

@ -0,0 +1,130 @@
/*
* Copyright 2014-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.web.test;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* A path that identifies a field in a JSON payload.
*
* @author Andy Wilkinson
* @author Jeremy Rickard
*
*/
//Copied from RestDocs to make it visible
final class JsonFieldPath {
private static final Pattern BRACKETS_AND_ARRAY_PATTERN = Pattern
.compile("\\[\'(.+?)\'\\]|\\[([0-9]+|\\*){0,1}\\]");
private static final Pattern ARRAY_INDEX_PATTERN = Pattern
.compile("\\[([0-9]+|\\*){0,1}\\]");
private final String rawPath;
private final List<String> segments;
private final boolean precise;
private final boolean array;
private JsonFieldPath(String rawPath, List<String> segments, boolean precise,
boolean array) {
this.rawPath = rawPath;
this.segments = segments;
this.precise = precise;
this.array = array;
}
boolean isPrecise() {
return this.precise;
}
boolean isArray() {
return this.array;
}
List<String> getSegments() {
return this.segments;
}
@Override
public String toString() {
return this.rawPath;
}
static JsonFieldPath compile(String path) {
List<String> segments = extractSegments(path);
return new JsonFieldPath(path, segments, matchesSingleValue(segments),
isArraySegment(segments.get(segments.size() - 1)));
}
static boolean isArraySegment(String segment) {
return ARRAY_INDEX_PATTERN.matcher(segment).matches();
}
static boolean matchesSingleValue(List<String> segments) {
Iterator<String> iterator = segments.iterator();
while (iterator.hasNext()) {
if (isArraySegment(iterator.next()) && iterator.hasNext()) {
return false;
}
}
return true;
}
private static List<String> extractSegments(String path) {
Matcher matcher = BRACKETS_AND_ARRAY_PATTERN.matcher(path);
int previous = 0;
List<String> segments = new ArrayList<>();
while (matcher.find()) {
if (previous != matcher.start()) {
segments.addAll(extractDotSeparatedSegments(
path.substring(previous, matcher.start())));
}
if (matcher.group(1) != null) {
segments.add(matcher.group(1));
}
else {
segments.add(matcher.group());
}
previous = matcher.end(0);
}
if (previous < path.length()) {
segments.addAll(extractDotSeparatedSegments(path.substring(previous)));
}
return segments;
}
private static List<String> extractDotSeparatedSegments(String path) {
List<String> segments = new ArrayList<>();
for (String segment : path.split("\\.")) {
if (segment.length() > 0) {
segments.add(segment);
}
}
return segments;
}
}

View File

@ -0,0 +1,251 @@
/*
* Copyright 2014-2016 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.web.test;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* A {@code JsonFieldProcessor} processes a payload's fields, allowing them to be
* extracted and removed.
*
* @author Andy Wilkinson
*
*/
// Copied from RestDocs to make it visible
final class JsonFieldProcessor {
boolean hasField(JsonFieldPath fieldPath, Object payload) {
final AtomicReference<Boolean> hasField = new AtomicReference<>(false);
traverse(new ProcessingContext(payload, fieldPath), new MatchCallback() {
@Override
public void foundMatch(Match match) {
hasField.set(true);
}
});
return hasField.get();
}
Object extract(JsonFieldPath path, Object payload) {
final List<Object> matches = new ArrayList<>();
traverse(new ProcessingContext(payload, path), new MatchCallback() {
@Override
public void foundMatch(Match match) {
matches.add(match.getValue());
}
});
if (matches.isEmpty()) {
throw new IllegalArgumentException("Field does not exist: " + path);
}
if ((!path.isArray()) && path.isPrecise()) {
return matches.get(0);
}
else {
return matches;
}
}
void remove(final JsonFieldPath path, Object payload) {
traverse(new ProcessingContext(payload, path), new MatchCallback() {
@Override
public void foundMatch(Match match) {
match.remove();
}
});
}
private void traverse(ProcessingContext context, MatchCallback matchCallback) {
final String segment = context.getSegment();
if (JsonFieldPath.isArraySegment(segment)) {
if (context.getPayload() instanceof List) {
handleListPayload(context, matchCallback);
}
}
else if (context.getPayload() instanceof Map
&& ((Map<?, ?>) context.getPayload()).containsKey(segment)) {
handleMapPayload(context, matchCallback);
}
}
private void handleListPayload(ProcessingContext context,
MatchCallback matchCallback) {
List<?> list = context.getPayload();
final Iterator<?> items = list.iterator();
if (context.isLeaf()) {
while (items.hasNext()) {
Object item = items.next();
matchCallback.foundMatch(
new ListMatch(items, list, item, context.getParentMatch()));
}
}
else {
while (items.hasNext()) {
Object item = items.next();
traverse(
context.descend(item,
new ListMatch(items, list, item, context.parent)),
matchCallback);
}
}
}
private void handleMapPayload(ProcessingContext context,
MatchCallback matchCallback) {
Map<?, ?> map = context.getPayload();
Object item = map.get(context.getSegment());
MapMatch mapMatch = new MapMatch(item, map, context.getSegment(),
context.getParentMatch());
if (context.isLeaf()) {
matchCallback.foundMatch(mapMatch);
}
else {
traverse(context.descend(item, mapMatch), matchCallback);
}
}
private static final class MapMatch implements Match {
private final Object item;
private final Map<?, ?> map;
private final String segment;
private final Match parent;
private MapMatch(Object item, Map<?, ?> map, String segment, Match parent) {
this.item = item;
this.map = map;
this.segment = segment;
this.parent = parent;
}
@Override
public Object getValue() {
return this.item;
}
@Override
public void remove() {
this.map.remove(this.segment);
if (this.map.isEmpty() && this.parent != null) {
this.parent.remove();
}
}
}
private static final class ListMatch implements Match {
private final Iterator<?> items;
private final List<?> list;
private final Object item;
private final Match parent;
private ListMatch(Iterator<?> items, List<?> list, Object item, Match parent) {
this.items = items;
this.list = list;
this.item = item;
this.parent = parent;
}
@Override
public Object getValue() {
return this.item;
}
@Override
public void remove() {
this.items.remove();
if (this.list.isEmpty() && this.parent != null) {
this.parent.remove();
}
}
}
private interface MatchCallback {
void foundMatch(Match match);
}
private interface Match {
Object getValue();
void remove();
}
private static final class ProcessingContext {
private final Object payload;
private final List<String> segments;
private final Match parent;
private final JsonFieldPath path;
private ProcessingContext(Object payload, JsonFieldPath path) {
this(payload, path, null, null);
}
private ProcessingContext(Object payload, JsonFieldPath path,
List<String> segments, Match parent) {
this.payload = payload;
this.path = path;
this.segments = segments == null ? path.getSegments() : segments;
this.parent = parent;
}
private String getSegment() {
return this.segments.get(0);
}
@SuppressWarnings("unchecked")
private <T> T getPayload() {
return (T) this.payload;
}
private boolean isLeaf() {
return this.segments.size() == 1;
}
private Match getParentMatch() {
return this.parent;
}
private ProcessingContext descend(Object payload, Match match) {
return new ProcessingContext(payload, this.path,
this.segments.subList(1, this.segments.size()), match);
}
}
}

View File

@ -0,0 +1,140 @@
/*
* 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.web.test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.mock.http.client.MockClientHttpRequest;
import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.restdocs.snippet.Snippet;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.util.Assert;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.servlet.RequestDispatcher;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
/**
* @author Dave Syer
*
*/
public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory {
private final MockMvc mockMvc;
private String label = "UNKNOWN";
private List<String> fields = new ArrayList<>();
public MockMvcClientHttpRequestFactory(MockMvc mockMvc) {
Assert.notNull(mockMvc, "MockMvc must not be null");
this.mockMvc = mockMvc;
}
@Override
public ClientHttpRequest createRequest(final URI uri, final HttpMethod httpMethod)
throws IOException {
return new MockClientHttpRequest(httpMethod, uri) {
@Override
public ClientHttpResponse executeInternal() throws IOException {
try {
MockHttpServletRequestBuilder requestBuilder = request(httpMethod,
uri.toString());
requestBuilder.content(getBodyAsBytes());
requestBuilder.headers(getHeaders());
MockHttpServletResponse servletResponse = actions(requestBuilder)
.andReturn().getResponse();
HttpStatus status = HttpStatus.valueOf(servletResponse.getStatus());
if (status.value() >= 400) {
requestBuilder = request(HttpMethod.GET, "/error")
.requestAttr(RequestDispatcher.ERROR_STATUS_CODE,
status.value())
.requestAttr(RequestDispatcher.ERROR_REQUEST_URI,
uri.toString());
if (servletResponse.getErrorMessage() != null) {
requestBuilder.requestAttr(RequestDispatcher.ERROR_MESSAGE,
servletResponse.getErrorMessage());
}
// Overwrites the snippets from the first request
servletResponse = actions(requestBuilder).andReturn()
.getResponse();
}
byte[] body = servletResponse.getContentAsByteArray();
HttpHeaders headers = getResponseHeaders(servletResponse);
MockClientHttpResponse clientResponse = new MockClientHttpResponse(
body, status);
clientResponse.getHeaders().putAll(headers);
return clientResponse;
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
};
}
private ResultActions actions(MockHttpServletRequestBuilder requestBuilder)
throws Exception {
ResultActions actions = MockMvcClientHttpRequestFactory.this.mockMvc
.perform(requestBuilder);
List<Snippet> snippets = new ArrayList<>();
for (String field : this.fields) {
snippets.add(new ResponseFieldSnippet(field));
}
actions.andDo(document(label, preprocessResponse(prettyPrint()), snippets.toArray(new Snippet[0])));
this.fields = new ArrayList<>();
return actions;
}
private HttpHeaders getResponseHeaders(MockHttpServletResponse response) {
HttpHeaders headers = new HttpHeaders();
for (String name : response.getHeaderNames()) {
List<String> values = response.getHeaders(name);
for (String value : values) {
headers.add(name, value);
}
}
return headers;
}
public void setTest(Class<?> testClass, Method testMethod) {
this.label = testMethod.getName();
}
public void setFields(String... fields) {
this.fields = Arrays.asList(fields);
}
}

View File

@ -0,0 +1,52 @@
/*
* 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.web.test;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.support.AbstractTestExecutionListener;
import org.springframework.test.web.servlet.MockMvc;
/**
* @author Dave Syer
*
*/
public final class MockMvcClientHttpRequestFactoryTestExecutionListener
extends AbstractTestExecutionListener {
private MockMvcClientHttpRequestFactory factory;
@Override
public void beforeTestClass(TestContext testContext) throws Exception {
ConfigurableBeanFactory beanFactory = (ConfigurableBeanFactory) testContext
.getApplicationContext().getAutowireCapableBeanFactory();
if (!beanFactory.containsBean("mockMvcClientHttpRequestFactory")) {
factory = new MockMvcClientHttpRequestFactory(
beanFactory.getBean(MockMvc.class));
beanFactory.registerSingleton("mockMvcClientHttpRequestFactory",
this.factory);
}
}
@Override
public void beforeTestMethod(TestContext testContext) throws Exception {
if (factory != null) {
this.factory.setTest(testContext.getTestClass(), testContext.getTestMethod());
}
}
}

View File

@ -0,0 +1,115 @@
/*
* 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.web.test;
import org.springframework.restdocs.RestDocumentationContext;
import org.springframework.restdocs.operation.Operation;
import org.springframework.restdocs.snippet.TemplatedSnippet;
import org.springframework.restdocs.snippet.WriterResolver;
import org.springframework.restdocs.templates.TemplateEngine;
import java.io.IOException;
import java.io.Writer;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* Creates a separate snippet for a single field in a larger payload. The output comes in
* a sub-directory ("response-fields") of one containing the request and response
* snippets, with a file name the same as the path. An exception to the last rule is if
* you pick a single array element by using a path like `foo.bar[0]`, the snippet file
* name is then just the array name (because asciidoctor cannot import snippets with
* brackets in the name).
*
* @author Dave Syer
*
*/
public class ResponseFieldSnippet extends TemplatedSnippet {
private String path;
private final JsonFieldProcessor fieldProcessor = new JsonFieldProcessor();
private final ObjectMapper objectMapper = new ObjectMapper();
private final Integer index;
private final String file;
public ResponseFieldSnippet(String path) {
super("response-fields", Collections.emptyMap());
String file = path;
if (path.endsWith("]")) {
// In this project we actually only need snippets whose last segment is an
// array index, so we can deal with it as a special case here. Ideally the
// restdocs implementation of JsonField would support this use case as well.
String index = path.substring(path.lastIndexOf("[") + 1);
index = index.substring(0, index.length() - 1);
this.index = Integer.valueOf(index);
path = path.substring(0, path.lastIndexOf("["));
file = file.replace("]", "").replace("[", ".");
} else {
this.index = null;
}
this.file = file;
this.path = path;
objectMapper.configure(SerializationFeature.INDENT_OUTPUT, true);
}
/*
* Copy of super class method, but changes the path of the output file to include the
* path
*/
@Override
public void document(Operation operation) throws IOException {
RestDocumentationContext context = (RestDocumentationContext) operation
.getAttributes().get(RestDocumentationContext.class.getName());
WriterResolver writerResolver = (WriterResolver) operation.getAttributes()
.get(WriterResolver.class.getName());
try (Writer writer = writerResolver
.resolve(operation.getName() + "/" + getSnippetName(), file, context)) {
Map<String, Object> model = createModel(operation);
model.putAll(getAttributes());
TemplateEngine templateEngine = (TemplateEngine) operation.getAttributes()
.get(TemplateEngine.class.getName());
writer.append(templateEngine.compileTemplate(getSnippetName()).render(model));
}
}
@Override
protected Map<String, Object> createModel(Operation operation) {
String value = "{}";
try {
Object object = objectMapper.readValue(
operation.getResponse().getContentAsString(), Object.class);
Object field = fieldProcessor.extract(JsonFieldPath.compile(path), object);
if (field instanceof List && index != null) {
field = ((List<?>) field).get(index);
}
value = objectMapper.writeValueAsString(field);
}
catch (Exception e) {
throw new IllegalStateException(e);
}
return Collections.singletonMap("value", value);
}
}

View File

@ -17,11 +17,11 @@
package io.spring.initializr.web.ui
import io.spring.initializr.web.AbstractInitializrControllerIntegrationTests
import org.json.JSONObject
import org.junit.Test
import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.test.context.ActiveProfiles
@ -50,5 +50,4 @@ class UiControllerIntegrationTests extends AbstractInitializrControllerIntegrati
def expected = readJsonFrom("metadata/ui/test-dependencies-$version" + ".json")
JSONAssert.assertEquals(expected, actual, JSONCompareMode.STRICT)
}
}

View File

@ -1,19 +1,19 @@
{
"_links": {
"maven-build": {
"href": "https://localhost:@port@/pom.xml?type=maven-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"href": "https://@host@/pom.xml?type=maven-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"maven-project": {
"href": "https://localhost:@port@/starter.zip?type=maven-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"href": "https://@host@/starter.zip?type=maven-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"gradle-build": {
"href": "https://localhost:@port@/build.gradle?type=gradle-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"href": "https://@host@/build.gradle?type=gradle-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"gradle-project": {
"href": "https://localhost:@port@/starter.zip?type=gradle-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"href": "https://@host@/starter.zip?type=gradle-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
}
},

View File

@ -1,23 +1,23 @@
{
"_links": {
"dependencies": {
"href": "https://localhost:@port@/dependencies{?bootVersion}",
"href": "https://@host@/dependencies{?bootVersion}",
"templated": true
},
"maven-build": {
"href": "https://localhost:@port@/pom.xml?type=maven-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"href": "https://@host@/pom.xml?type=maven-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"maven-project": {
"href": "https://localhost:@port@/starter.zip?type=maven-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"href": "https://@host@/starter.zip?type=maven-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"gradle-build": {
"href": "https://localhost:@port@/build.gradle?type=gradle-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"href": "https://@host@/build.gradle?type=gradle-build{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
},
"gradle-project": {
"href": "https://localhost:@port@/starter.zip?type=gradle-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"href": "https://@host@/starter.zip?type=gradle-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
"templated": true
}
},

View File

@ -0,0 +1,4 @@
[source,json,options="nowrap"]
----
{{value}}
----

24
pom.xml
View File

@ -45,6 +45,7 @@
<module>initializr-actuator</module>
<module>initializr-generator</module>
<module>initializr-web</module>
<module>initializr-stubs</module>
</modules>
<dependencyManagement>
@ -117,6 +118,29 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.asciidoctor</groupId>
<artifactId>asciidoctor-maven-plugin</artifactId>
<version>1.5.3</version>
<executions>
<execution>
<id>generate-docs</id>
<phase>prepare-package</phase>
<inherited>false</inherited>
<goals>
<goal>process-asciidoc</goal>
</goals>
<configuration>
<sourceDocumentName>index.adoc</sourceDocumentName>
<backend>html</backend>
<doctype>book</doctype>
<attributes>
<snippets>${basedir}/initializr-web/target/snippets</snippets>
</attributes>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
<pluginManagement>
<plugins>

View File

@ -0,0 +1,127 @@
:code: https://github.com/spring-io/initializr/tree/master/initializr
:service: https://github.com/spring-io/initializr/tree/master/initializr-service
# Configuration Guide
This section describes the configuration structure that is used by the initializr. The metadata provided through configuration are actually used to generate the link:Project-metadata-json-output[Project metadata json format].
TIP: A good way to get started with the configuration is to look at the {service}/src/main/resources/application.yml[configuration of the production instance] and check the end-result on https://start.spring.io
The configuration is split in several sections:
* An `env` section used to provide various global settings
* A `dependencies` section lists the available dependencies. This is the most important section of the service as it defines the "libraries" that the user can choose
* The `groupId`, `artifactId`, `version`, `name`, `description` and `packageName` provide default values for these project settings
* The `types`, `packagings`, `javaVersions`, `languages` and `bootVersions` provide the list of available option for each setting and which one is the default.
## Env section
TIP: Check {code}/src/main/groovy/io/spring/initializr/metadata/InitializrConfiguration.groovy#L113[the code] for a full list of the available configuration options.
The `env` element defines environment option that the service uses:
* `artifactRepository`: the URL of the (maven) repository that should be used to download the Spring Boot CLI distribution bundle. This is only used by the `/spring` endpoint at the moment
* `springBootMetadataUrl` the URL of the resource that provides the list of available Spring Boot versions.
* `forceSsl`: a boolean flag that determines if we should use `https` even when browsing a resource via `http`. This is _enabled_ by default.
* `fallbackApplicationName`: the name of the _default_ application. Application names are generated based on the project's name. However, some user input may result in an invalid identifier for a Java class name for instance.
* `invalidApplicationNames`: a fixed list of invalid application names. If a project generation uses one of these names, the fallback is used instead
* `invalidPackageNames`: a fixed list of invalid package names. If a project generation uses one of these names, the default is used instead
* `googleAnalyticsTrackingCode`: the Google Analytics code to use. If this is set, Google analytics is automatically enabled
* `kotlin`: kotlin-specific settings. For now, only the kotlin version to use can be configured
* `maven`: maven-specified settings. A custom maven parent POM can be defined and whether or not the `spring-boot-dependencies` BOM should be automatically added to the project
If some of your dependencies require a custom Bill of Materials (BOM) and/or a custom repository, you can add them here and use the id as a reference. For instance, let's say that you want to integrate with library `foo` and it requires a `foo-bom` and a `foo-repo`. You can configure things as follows:
```yml
initializr:
env:
boms:
foo-bom:
groupId: com.example
artifactId: foo-bom
version: 1.2.3
repositories:
foo-repo:
name: foo-release-repo
url: https://repo.example.com/foo
snapshotsEnabled: false
```
You can then use the `foo-bom` and `foo-repo` in a "dependency" or "dependency group" section.
NOTE: The `spring-milestones` and `spring-snapshots` repositories are available by default. Please note that these are just references and won't impact the project unless you choose a dependency that explicitly refer to a bom and/or repo by id. Check the example below for more details.
## Dependencies section
The `dependencies` section allows you define a list of groups, each group having one more `dependency`. A group gather dependencies that share a common characteristics (i.e. all web-related dependencies for instance).
A `dependency` has the following basic characteristics:
* A mandatory identifier. If no further information is provided, a Spring Boot starer with that id is assumed
* A `name` and `description` used in the generated meta-data and the web ui
* A `groupId` and `artifactId` to define the coordinates of the dependency
* A `version` if Spring Boot does not already provide a dependency management for that dependency
* A `scope` (can be `compile`, `runtime`, `provided` or `test`)
* The reference to a `bom` or a `repository` that must be added to the project once that dependency is added
* A `versionRange` used to determine the Spring Boot versions that are compatible with the dependency
TIP: Check {code}/src/main/groovy/io/spring/initializr/metadata/Dependency.groovy[the code] for a full list of the available configuration options.
Here is the most basic dependency entry you could have
```yml
initializr:
dependencies:
- name: Core
content:
- id: security
name: Security
description: Secure your application via spring-security
```
TIP: The `security` dependency is held within a group called "Core".
This adds an option name `Security` with a tooltip showing the description above. If a project is generated with that dependency, the `org.springframework.boot:spring-boot-starter-security` dependency will be added to the project.
Let's now add a custom dependency that is not managed by Spring Boot and that only work from Spring Boot `1.2.0.RELEASE` and onwards but should not be used in the 1.3 lines and further for some reason.
```yml
initializr:
dependencies:
- name: Core
content:
- id: my-lib-id
name: My lib
description: Secure your application via spring-security
groupId: com.example.foo
artifactId: foo-core
bom: foo-bom
repository: foo-repo
versionRange: "[1.2.0.RELEASE,1.3.0.M1)"
```
If one selects this entry, the `com.example.foo:foo-core}` dependency will be added and the Bill of Materials and repository for `foo` will be added automatically to the project as well (see the "Env section" above for a reference to those identifiers). Because the bom provides a dependency management for `foo-core` there is no need to hardcode the version in the configuration.
The `versionRange` syntax follows some simple rules: 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 version. In the example above, the dependency will be available as from `1.2.0.RELEASE` up to, not included, `1.3.0.M1` (which is the first milestone of the 1.3 line).
### Dependency group
A dependency group gather a set of dependencies as well as some common settings: `bom`, `repository` and `versionRange`. If one of them is set, it is applied for all dependencies within that group. It is still possible to override a particular value at the dependency level.
## Other sections
The other section defines the default and the list of available options in the web UI. This also drives how the meta-data for your instance are generated and tooling support is meant to react to that.
For instance, if you want your groupId to default to `org.acme` and the `javaVersions` to only be `1.7` and `1.8` you would write the following config:
```yml
initializr:
groupId:
value: org.acme
javaVersions:
- id: 1.8
default: true
- id: 1.7
default: false
```

View File

@ -0,0 +1,36 @@
# Third Party Starters
Spring Initializr (start.spring.io) provides an opinionated, web based generator for Spring Boot-based projects. start.spring.io provides a number of core starters as well as 3rd party contributed starters. While its possible to create 3rd party starter POMs without having them listed on start.spring.io, members of the community have asked for guidelines to be considered for inclusion in our production instance.
These guidelines aim to balance:
. An opinionated getting started experience
. Alignment with the overall direction of the Spring ecosystem
. Community contributions
. Community confidence in the long term viability and support for 3rd party starters
## Guidelines
. Code of Conduct
* Anyone contributing to any of the projects in the Spring portfolio, including Spring Initializr, must adhere to our Code of Conduct
. Open sourced and available on Maven central
* The library must be open sourced and available in Maven Central repository. Please see the Maven Central requirements for more details
* The project must be made available with an appropriate OSS license which is deemed compatible with the Apache 2.0 license
. Community
* There should be a sizeable community that are actively using and maintaining the library
* An established support forum such as Stack Overflow and issue tracker
. Spring Boot Compliance:
* The starter should comply with http://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-custom-starter-naming[the rules of a 3rd party starter].
* If the starter provides configuration entries, they should have proper meta-data (including documentation)
* start.spring.io will host one starter POM per 3rd party library
* The starter should be actively maintained and upgraded to the newest version of Spring Boot if necessary
. Dependencies:
* The 3rd party starter POM should not bring in any supporting dependencies that arent compatible with the Apache 2.0 license
* All of these supporting dependencies must be available in Maven Central
* Dependencies should be compatible with Spring Boots core starters
. Test Coverage:
* The 3rd party starter should have proper test coverage to validate that it works properly with Spring Boot
Testing should encompass combinations of optional dependencies using standalone sample projects
. Spring Engineering Review:
* The Spring Boot team periodically reviews starter statistics and may decide to remove 3rd party starters from start.spring.io if they are not being used. The main driver is to help keep Spring Boot opinionated and remove choices that arent being used by large audiences.
* Even if these requirements are met, the Spring Boot team may decide that a proposed 3rd party starter does not meet the strategic direction of Spring Boot and the overall portfolio. In this case, its still possible to create and share a 3rd party starters but they wont be listed on start.spring.io.

View File

@ -0,0 +1,23 @@
= Spring Initializr Reference Guide
Stéphane Nicoll; Dave Syer
:doctype: book
:toc:
:toclevels: 4
:source-highlighter: prettify
:numbered:
:icons: font
:hide-uri-scheme:
:github-tag: master
:github-repo: spring-io/initializr
:github-raw: https://raw.github.com/{github-repo}/{github-tag}
:github-code: https://github.com/{github-repo}/tree/{github-tag}
:github-wiki: https://github.com/{github-repo}/wiki
:github-master-code: https://github.com/{github-repo}/tree/master
// ======================================================================================
include::metadata-format.adoc[leveloffset=+1]
include::using-the-stubs.adoc[leveloffset=+1]
include::configuration-format.adoc[leveloffset=+1]
include::guidelines-for-3rd-party-starters.adoc[leveloffset=+1]
// ======================================================================================

View File

@ -0,0 +1,115 @@
= Metadata Format
This section describes the hal/json structure of the metadata exposed by the initializr. Such metadata can be used by third party clients to provide a list of options and default settings that can be used to request the creation of a project.
Each third-party client is advised to set a `User-Agent` header for *each* request sent to the service. A good structure for a user agent is `clientId/clientVersion` (i.e. `foo/1.2.0` for the "foo" client and version `1.2.0`).
== Content
Any third party client can retrieve the capabilities of the service by issuing a `GET` on the root URL using the following `Accept` header: `application/vnd.initializr.v2+json`. Please note that the metadata may evolve in a non backward compatible way in the future so adding this header ensures the service returns the metadata format you expect.
This is a full example of an output for a service running at `https://start.spring.io`:
.request
include::{snippets}/metadataWithV2AcceptHeader/http-request.adoc[]
.response
include::{snippets}/metadataWithV2AcceptHeader/http-response.adoc[]
The current capabilities are the following:
* Project dependencies: these are the _starters_ really or actually any dependency that we might want to add to the project.
* Project types: these define the action that can be invoked on this service and a description of what it would produce (for instance a zip holding a pre-configured Maven project). Each type may have one more tags that further define what it generates
* Packaging: the kind of projects to generate. This merely gives a hint to the component responsible to generate the project (for instance, generate an executable _jar_ project)
* Java version: the supported java versions
* Language: the language to use (e.g. Java)
* Boot version: the Spring Boot version to use
* Additional basic information such as: `groupId`, `artifactId`, `version`, `name`, `description` and `packageName`
Each top-level attribute (i.e. capability) has a standard format:
* A `type` attribute that defines the semantic of the attribute (see below)
* A `default` attribute that defines either the default value or the reference to the default value
* A `values` attribute that defines the set of acceptable values (if any). This can be hierarchical (with `values` being held in `values`). Each item in a `values` array can have an `id`, `name` and `description`)
The following attribute `type` are supported:
* `text`: defines a simple text value with no option
* `single-select`: defines a simple value to be chosen amongst the specified options
* `hierarchical-multi-select`: defines a hierarchical set of values (values in values) with the ability to select multiple values
* `action`: a special type that defines the attribute defining the action to use
Each action is defined as a HAL-compliant URL. For instance, the `maven-project` type templated URL is defined as follows:
._links.maven-project
include::{snippets}/metadataWithV2AcceptHeader/response-fields/_links.maven-project.adoc[]
You can use Spring HATEOAS and the `UriTemplate` helper in particular to generate an URI from template variables. Note that the variables match the name of top-level attribute in the metadata document. If you can't parse such URI, the `action` attribute of each type gives you the root action to invoke on the server. This requires more manual handling on your end.
=== Project dependencies
A dependency is usually the coordinates of a _starter_ module but it can be just as well be a regular dependency. A typical dependency structure looks like this:
```json
{
"name": "Display name",
"id": "org.acme.project:project-starter-foo",
"description": "What starter foo does"
}
```
The name is used as a display name to be shown in whatever UI used by the remote client. The id can be anything, really as the actual dependency definition is defined through configuration. If no id is defined, a default one is built using the `groupId` and `artifactId` of the dependency. Note in particular that the version is **never** used as part of an automatic id.
Each dependency belongs to a group. The idea of the group is to gather similar dependencies and order them. Here is a value containing the `core` group to illustrates the feature:
.dependencies.values[0]
include::{snippets}/metadataWithV2AcceptHeader/response-fields/dependencies.values.0.adoc[]
=== Project types
The `type` element defines what kind of project can be generated and how. For instance, if the service exposes the capability to generate a Maven project, this would look like this:
.type.values[0]
include::{snippets}/metadataWithV2AcceptHeader/response-fields/type.values.0.adoc[]
You should not rely on the output format depending that information. Always use the response headers that define a `Content-Type` and also a `Content-Disposition` header.
Note that each id has a related HAL-compliant link that can be used to generate a proper URI based on template variables. The top-level `type` has, as any other attribute, a `default` attribute that is a hint to select what the service consider to be a good default.
The `action` attribute defines the endpoint the client should contact to actually generate a project of that type if you can't use the HAL-compliant url.
The `tags` object is used to categorize the project type and give _hints_ to 3rd party client. For instance, the _build_ tag defines the build system the project is going to use and the _format_ tag defines the format of the generated content (i.e. here a complete project vs. a build file. Note that the `Content-type` header of the reply provides additional metadata).
=== Packaging
The `packaging` element defines the kind of project that should be generated.
.packaging.values[0]
include::{snippets}/metadataWithV2AcceptHeader/response-fields/packaging.values.0.adoc[]
The obvious values for this element are `jar` and `war`.
=== Java version
The `javaVersion` element provides a list of possible java versions for the project:
.javaVersion.values[0]
include::{snippets}/metadataWithV2AcceptHeader/response-fields/javaVersion.values.0.adoc[]
=== Languages
The `language` element provides a list of possible languages for the project:
.language.values[0]
include::{snippets}/metadataWithV2AcceptHeader/response-fields/language.values.0.adoc[]
=== Boot version
The `bootVersion` element provides the list of available boot versions
.bootVersion.values[0]
include::{snippets}/metadataWithV2AcceptHeader/response-fields/bootVersion.values.0.adoc[]
== Defaults
Each top-level element has a `default` attribute that should be used as a hint to provide the default value in the relevant UI component.

View File

@ -0,0 +1,99 @@
:project-version: 1.0.0.BUILD-SNAPSHOT
= Using the Stubs
The Initializr project publishes
https://github.com/tomakehurst/wiremock[WireMock] stubs for all the
JSON responses that are tested in the project. If you are writing a
client for the Initializr service, you can use these stubs to test
your own code. You can consume them with the raw Wiremock APIs, or via
some features of
https://github.com/spring-cloud/spring-cloud-contract[Spring Cloud
Contract].
WireMock is an embedded web server that analyses incoming requests and
chooses stub responses based on matching some rules (e.g. a specific
header value). So if you send it a request which matches one of its
stubs, it will send you a response as if it was a real Initializr
service, and you can use that to do full stack integration testing of
your client.
== Using WireMock with Spring Boot
A convenient way to consume the stubs in your project is to add a test
dependency:
[source,xml,indent=0,subs="attributes,specialchars"]
----
<dependency>
<groupId>io.spring.initializr</groupId>
<artifactId>initializr</artifactId>
<classifier>stubs</classifier>
<version>{project-version}</version>
<scope>test</scope>
</dependency>
----
and then pull the stubs from the classpath. In a Spring Boot
application, using Spring Cloud Contract, you can start a WireMock
server and register all the stubs with it like this:
[source,java,subs="attributes"]
----
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureWireMock(port = 0, stubs="classpath:META-INF/io.spring.initializr/initializr/{project-version}")
public class ClientApplicationTests {
@Value("${wiremock.server.port}")
private int port;
...
}
----
Alternatively you can register individual stubs with the server by
autowiring it, and using its own API. For example:
[source,java,indent=0]
----
@Value("classpath:META-INF/io.spring.initializr/initializr/{project-version}/mappings/metadataWithV2AcceptHeader.json")
private Resource root;
@Autowired
private WireMockServer server;
@Test
public void testDependencies() throws IOException {
server.addStubMapping(StubMapping.buildFrom(StreamUtils.copyToString(
root.getInputStream(), Charset.forName("UTF-8"))));
...
}
----
Then you have a server that returns the stub of the JSON metadata
("metadataWithV2AcceptHeader.json") when you send it a header "Accept:
application/vnd.initializr.v2+json" (as recommended).
NOTE: Spring Cloud Contract also has support for easily running stubs
of multiple services on different ports. You might find that useful if
your client uses other services. To just load up the Initializr stubs
in your test case you use `@AutoConfigureStubRunner` (instead of
`@AutoConfigureWireMock`)
== Names and Paths of Stubs
The stubs are laid out in a jar file in a form (under "/mappings")
that can be consumed by WireMock just by setting its file source. The
names of the individual stubs are the same as the method names of the
test cases that generated them in the Initializr project. So for
example there is a test case "metadataWithV2AcceptHeader" in
`MainControllerIntegrationTests` that makes assertions about the
response when the accept header is
"application/vnd.initializr.v2+json". The response is recorded in the
stub, and it will match in WireMock if the same headers and request
parameters that were used in the Initializr test case and used in the
client. The method name usually summarizes what those values are.