Improve command-line service capabilities

Provide a polished version of the command-line service capabilities
with tables and support for the new version range.

Also support SpringBootCli explicitly so that the service capabilities
are rendered directly by the server instead of having the client parsing
the meta-data manually.

Closes gh-76
This commit is contained in:
Stephane Nicoll
2015-02-18 17:24:09 +01:00
parent a6afbd7960
commit 3e7658c879
9 changed files with 236 additions and 55 deletions

View File

@@ -16,6 +16,8 @@
package io.spring.initializr
import io.spring.initializr.support.VersionRange
import static io.spring.initializr.support.GroovyTemplate.template
/**
@@ -42,10 +44,9 @@ class CommandLineHelpGenerator {
String generateGenericCapabilities(InitializrMetadata metadata, String serviceUrl) {
def model = initializeModel(metadata, serviceUrl)
model['hasExamples'] = false
doGenerateCapabilities(model)
template 'cli-capabilities.txt', model
}
/**
* Generate the capabilities of the service using "curl" as a plain text
* document.
@@ -54,7 +55,7 @@ class CommandLineHelpGenerator {
def model = initializeModel(metadata, serviceUrl)
model['examples'] = template 'curl-examples.txt', model
model['hasExamples'] = true
doGenerateCapabilities(model)
template 'cli-capabilities.txt', model
}
/**
@@ -65,43 +66,47 @@ class CommandLineHelpGenerator {
def model = initializeModel(metadata, serviceUrl)
model['examples'] = template 'httpie-examples.txt', model
model['hasExamples'] = true
doGenerateCapabilities(model)
}
private doGenerateCapabilities(def model) {
template 'cli-capabilities.txt', model
}
/**
* Generate the capabilities of the service using Spring Boot CLI as a plain
* text document.
*/
String generateSpringBootCliCapabilities(InitializrMetadata metadata, String serviceUrl) {
def model = initializeModel(metadata, serviceUrl)
model['hasExamples'] = false
template('boot-cli-capabilities.txt', model)
}
private Map initializeModel(InitializrMetadata metadata, serviceUrl) {
Map model = [:]
model['logo'] = logo
model['serviceUrl'] = serviceUrl
Map dependencies = [:]
new ArrayList(metadata.allDependencies).sort { a, b -> a.id <=> b.id }.each {
String description = it.name
if (it.description) {
description += ": $it.description"
}
if (it.versionRange) {
String range = it.versionRange.trim()
description += " - $range"
}
dependencies[it.id] = description
String[][] dependencyTable = new String[metadata.allDependencies.size() + 1][];
dependencyTable[0] = ["Id", "Description", "Required version"]
new ArrayList(metadata.allDependencies).sort { a, b -> a.id <=> b.id }
.eachWithIndex { dep, i ->
String[] data = new String[3]
data[0] = dep.id
data[1] = dep.description ?: dep.name
data[2] = buildVersionRangeRepresentation(dep.versionRange)
dependencyTable[i + 1] = data
}
model['dependencies'] = dependencies
model['dependencies'] = TableGenerator.generate(dependencyTable)
Map types = [:]
new ArrayList<>(metadata.types).sort { a, b -> a.id <=> b.id }.each {
String description = it.description
if (!description) {
description = it.name
}
types[it.id] = description
String[][] typeTable = new String[metadata.types.size() + 1][];
typeTable[0] = ["Id", "Description", "Tags"]
new ArrayList<>(metadata.types).sort { a, b -> a.id <=> b.id }.eachWithIndex { type, i ->
String[] data = new String[3]
data[0] = (metadata.defaults.type.equals(type.id) ? type.id + " *" : type.id)
data[1] = type.description ?: type.name
data[2] = buildTagRepresentation(type)
typeTable[i + 1] = data;
}
model['types'] = types
model['types'] = TableGenerator.generate(typeTable)
Map defaults = [:]
metadata.defaults.properties.sort().each {
@@ -109,11 +114,117 @@ class CommandLineHelpGenerator {
defaults[it.key] = it.value
}
}
String[][] parameterTable = new String[defaults.size() + 1][];
parameterTable[0] = ["Id", "Default value"]
defaults.keySet().eachWithIndex { id, i ->
String[] data = new String[2]
data[0] = id
data[1] = metadata.defaults.properties[id]
parameterTable[i + 1] = data
}
model['parameters'] = TableGenerator.generate(parameterTable)
defaults['applicationName'] = ProjectRequest.generateApplicationName(metadata.defaults.name,
ProjectRequest.DEFAULT_APPLICATION_NAME)
model['defaults'] = defaults
model
}
private static String buildVersionRangeRepresentation(String range) {
if (!range) {
return null
}
VersionRange versionRange = VersionRange.parse(range)
if (versionRange.higherVersion == null) {
return ">= $range"
} else {
return range.trim()
}
}
private static String buildTagRepresentation(InitializrMetadata.Type type) {
if (type.tags.isEmpty()) {
return "";
}
type.tags.collect { key, value ->
"$key:$value"
}.join(",")
}
private static class TableGenerator {
static final String NEW_LINE = System.getProperty("line.separator")
/**
* Generate a table description for the specified {@code content}.
* <p>
* The {@code content} is a two-dimensional array holding the rows
* of the table. The first entry holds the header of the table.
*/
public static String generate(String[][] content) {
StringBuilder sb = new StringBuilder()
int[] columnsLength = computeColumnsLength(content)
appendTableSeparation(sb, columnsLength)
appendRow(sb, content, columnsLength, 0) // Headers
appendTableSeparation(sb, columnsLength)
for (int i = 1; i < content.length; i++) {
appendRow(sb, content, columnsLength, i)
}
appendTableSeparation(sb, columnsLength)
sb.toString()
}
private static void appendRow(StringBuilder sb, String[][] content,
int[] columnsLength, int rowIndex) {
String[] row = content[rowIndex]
for (int i = 0; i < row.length; i++) {
sb.append("| ").append(fill(row[i], columnsLength[i])).append(" ")
}
sb.append("|")
sb.append(NEW_LINE)
}
private static void appendTableSeparation(StringBuilder sb, int[] headersLength) {
for (int headerLength : headersLength) {
sb.append("+").append("-".multiply(headerLength + 2))
}
sb.append("+")
sb.append(NEW_LINE)
}
private static String fill(String data, int columnSize) {
if (data == null) {
return " ".multiply(columnSize)
} else {
int i = columnSize - data.length()
return data + " ".multiply(i)
}
}
private static int[] computeColumnsLength(String[][] content) {
int count = content[0].length
int[] result = new int[count]
for (int i = 0; i < count; i++) {
result[i] = largest(content, i)
}
return result
}
private static int largest(String[][] content, int column) {
int max = 0
for (String[] rows : content) {
String s = rows[column]
if (s && s.length() > max) {
max = s.length()
}
}
return max
}
}
}

View File

@@ -248,6 +248,10 @@ class InitializrMetadata {
String versionRange
void setVersionRange(String versionRange) {
this.versionRange = versionRange ? versionRange.trim() : null
}
/**
* Specify if the dependency has its coordinates set, i.e. {@code groupId}
* and {@code artifactId}.

View File

@@ -44,12 +44,19 @@ class VersionRange {
private static final String RANGE_REGEX = "(\\(|\\[)(.*),(.*)(\\)|\\])"
private Version lowerVersion
private boolean lowerInclusive
private Version higherVersion
private boolean higherInclusive
final Version lowerVersion
final boolean lowerInclusive
final Version higherVersion
final boolean higherInclusive
/**
private VersionRange(Version lowerVersion, boolean lowerInclusive,
Version higherVersion, boolean higherInclusive) {
this.lowerVersion = lowerVersion
this.lowerInclusive = lowerInclusive
this.higherVersion = higherVersion
this.higherInclusive = higherInclusive
}
/**
* Specify if the {@link Version} matches this range. Returns {@code true}
* if the version is contained within this range, {@code false} otherwise.
*/
@@ -85,14 +92,13 @@ class VersionRange {
if (!matcher.matches()) {
// Try to read it as simple string
Version version = Version.parse(text)
return new VersionRange(lowerInclusive: true, lowerVersion: version)
return new VersionRange(version, true, null, true)
}
VersionRange range = new VersionRange()
range.lowerInclusive = matcher[0][1].equals('[')
range.lowerVersion = Version.parse(matcher[0][2])
range.higherVersion = Version.parse(matcher[0][3])
range.higherInclusive = matcher[0][4].equals(']')
range
boolean lowerInclusive = matcher[0][1].equals('[')
Version lowerVersion = Version.parse(matcher[0][2])
Version higherVersion = Version.parse(matcher[0][3])
boolean higherInclusive = matcher[0][4].equals(']')
new VersionRange(lowerVersion, lowerInclusive, higherVersion, higherInclusive)
}
}

View File

@@ -76,6 +76,9 @@ class MainController extends AbstractInitializrController {
if (userAgent.startsWith(WebConfig.HTTPIE_USER_AGENT_PREFIX)) {
return builder.body(commandLineHelpGenerator.generateHttpieCapabilities(metadata, appUrl))
}
if (userAgent.startsWith(WebConfig.SPRING_BOOT_CLI_AGENT_PREFIX)) {
return builder.body(commandLineHelpGenerator.generateSpringBootCliCapabilities(metadata, appUrl))
}
}
builder.body(commandLineHelpGenerator.generateGenericCapabilities(metadata, appUrl))
}

View File

@@ -39,6 +39,8 @@ class WebConfig extends WebMvcConfigurerAdapter {
static final String HTTPIE_USER_AGENT_PREFIX = 'HTTPie'
static final String SPRING_BOOT_CLI_AGENT_PREFIX = 'SpringBootCli'
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.defaultContentTypeStrategy(new CommandLineContentNegotiationStrategy())

View File

@@ -0,0 +1,11 @@
${logo}
:: Service capabilities :: ${serviceUrl}
Supported dependencies
${dependencies}
Project types (* denotes the default)
${types}
Parameters
${parameters}

View File

@@ -9,15 +9,13 @@ The services uses a HAL based hypermedia format to expose a set of
resources to interact with. If you access this root resource
requesting application/json as media type the response will contain
the following links:
<% types.each {key, value -> %>
* ${key} - ${value} <% } %>
${types}
The URI templates take a set of parameters to customize the result of
a request to the linked resource.
+-----------------+------------------------------------------+-----------------
| Parameter | Description | Default
|-----------------+------------------------------------------+-----------------
+-----------------+------------------------------------------+-----------------
| groupId | project coordinates | ${defaults.groupId}
| artifactId | project coordinates (infer archive name) | ${defaults.artifactId}
| version | project version | ${defaults.version}
@@ -36,13 +34,9 @@ a request to the linked resource.
The following section has a list of supported identifiers for the comma separated
list of "dependencies".
<% dependencies.each {key, value -> %>
${key} - ${value} <% } %>
${dependencies}
<% if (hasExamples) { %>
Examples:
${examples}
<% } %>

View File

@@ -36,8 +36,8 @@ class CommandLineHelpGeneratorTest {
createDependency('id-b', 'depB'),
createDependency('id-a', 'depA', 'and some description')).validateAndGet()
String content = generator.generateGenericCapabilities(metadata, "https://fake-service")
assertThat content, containsString('id-a - depA: and some description')
assertThat content, containsString('id-b - depB')
assertThat content, containsString('id-a | and some description |')
assertThat content, containsString('id-b | depB')
assertThat content, containsString("https://fake-service")
assertThat content, not(containsString('Examples:'))
assertThat content, not(containsString('curl'))
@@ -49,7 +49,9 @@ class CommandLineHelpGeneratorTest {
.addType(new InitializrMetadata.Type(id: 'foo', name: 'foo-name', description: 'foo-desc'))
.validateAndGet()
String content = generator.generateGenericCapabilities(metadata, "https://fake-service")
assertThat content, containsString('foo - foo-desc')
assertThat content, containsString('| foo')
assertThat content, containsString('| foo-desc')
}
@Test
@@ -58,8 +60,8 @@ class CommandLineHelpGeneratorTest {
createDependency('id-b', 'depB'),
createDependency('id-a', 'depA', 'and some description')).validateAndGet()
String content = generator.generateCurlCapabilities(metadata, "https://fake-service")
assertThat content, containsString('id-a - depA: and some description')
assertThat content, containsString('id-b - depB')
assertThat content, containsString('id-a | and some description |')
assertThat content, containsString('id-b | depB')
assertThat content, containsString("https://fake-service")
assertThat content, containsString('Examples:')
assertThat content, containsString('curl')
@@ -71,14 +73,40 @@ class CommandLineHelpGeneratorTest {
createDependency('id-b', 'depB'),
createDependency('id-a', 'depA', 'and some description')).validateAndGet()
String content = generator.generateHttpieCapabilities(metadata, "https://fake-service")
assertThat content, containsString('id-a - depA: and some description')
assertThat content, containsString('id-b - depB')
assertThat content, containsString('id-a | and some description |')
assertThat content, containsString('id-b | depB')
assertThat content, containsString("https://fake-service")
assertThat content, containsString('Examples:')
assertThat content, not(containsString('curl'))
assertThat content, containsString("http https://fake-service")
}
@Test
void generateSpringBootCliCapabilities() {
def metadata = InitializrMetadataBuilder.withDefaults().addDependencyGroup("test",
createDependency('id-b', 'depB'),
createDependency('id-a', 'depA', 'and some description')).validateAndGet()
String content = generator.generateSpringBootCliCapabilities(metadata, "https://fake-service")
assertThat content, containsString('id-a | and some description |')
assertThat content, containsString('id-b | depB')
assertThat content, containsString("https://fake-service")
assertThat content, not(containsString('Examples:'))
assertThat content, not(containsString('curl'))
}
@Test
void generateCapabilitiesWithVersionRange() {
InitializrMetadata.Dependency first = new InitializrMetadata.Dependency(
id: 'first', description: 'first desc', versionRange: '1.2.0.RELEASE')
InitializrMetadata.Dependency second = new InitializrMetadata.Dependency(
id: 'second', description: 'second desc', versionRange: ' [1.2.0.RELEASE,1.3.0.M1) ')
def metadata = InitializrMetadataBuilder.withDefaults().addDependencyGroup("test", first, second).validateAndGet()
String content = generator.generateSpringBootCliCapabilities(metadata, "https://fake-service")
assertThat content, containsString('| first | first desc | >= 1.2.0.RELEASE |')
assertThat content, containsString('| second | second desc | [1.2.0.RELEASE,1.3.0.M1) |')
}
private static def createDependency(String id, String name) {
createDependency(id, name, null)
}

View File

@@ -252,6 +252,19 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
validateGenericHelpContent(response)
}
@Test
void springBootCliReceivesJsonByDefault() {
ResponseEntity<String> response = invokeHome('SpringBootCli/1.2.0', "*/*")
validateContentType(response, CURRENT_METADATA_MEDIA_TYPE)
validateCurrentMetadata(new JSONObject(response.body))
}
@Test
void springBootCliWithAcceptHeaderText() {
ResponseEntity<String> response = invokeHome('SpringBootCli/1.2.0', "text/plain")
validateSpringBootHelpContent(response)
}
@Test // Test that the current output is exactly what we expect
void validateCurrentProjectMetadata() {
def json = getMetadataJson()
@@ -301,6 +314,15 @@ class MainControllerIntegrationTests extends AbstractInitializrControllerIntegra
not(containsString("curl"))))
}
private void validateSpringBootHelpContent(ResponseEntity<String> response) {
validateContentType(response, MediaType.TEXT_PLAIN)
assertThat(response.body, allOf(
containsString("Service capabilities"),
containsString("Supported dependencies"),
not(containsString('Examples:')),
not(containsString("curl"))))
}
@Test
void metricsAvailableByDefault() {
downloadZip('/starter.zip?packaging=jar&javaVersion=1.8&style=web&style=jpa')