diff --git a/spring-boot-cli/pom.xml b/spring-boot-cli/pom.xml index 01d524423e..11e5a74ad8 100644 --- a/spring-boot-cli/pom.xml +++ b/spring-boot-cli/pom.xml @@ -57,6 +57,10 @@ org.springframework spring-core + + org.json + json + org.apache.maven maven-aether-provider diff --git a/spring-boot-cli/src/it/java/org/springframework/boot/cli/CommandLineIT.java b/spring-boot-cli/src/it/java/org/springframework/boot/cli/CommandLineIT.java index d52bd1afea..8f0b4fd0c3 100644 --- a/spring-boot-cli/src/it/java/org/springframework/boot/cli/CommandLineIT.java +++ b/spring-boot-cli/src/it/java/org/springframework/boot/cli/CommandLineIT.java @@ -43,7 +43,7 @@ public class CommandLineIT { Invocation cli = this.cli.invoke("hint"); assertThat(cli.await(), equalTo(0)); assertThat(cli.getErrorOutput().length(), equalTo(0)); - assertThat(cli.getStandardOutputLines().size(), equalTo(9)); + assertThat(cli.getStandardOutputLines().size(), equalTo(10)); } @Test diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/DefaultCommandFactory.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/DefaultCommandFactory.java index 1d3c7035c2..a06406ec3c 100644 --- a/spring-boot-cli/src/main/java/org/springframework/boot/cli/DefaultCommandFactory.java +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/DefaultCommandFactory.java @@ -24,6 +24,7 @@ import org.springframework.boot.cli.command.Command; import org.springframework.boot.cli.command.CommandFactory; import org.springframework.boot.cli.command.core.VersionCommand; import org.springframework.boot.cli.command.grab.GrabCommand; +import org.springframework.boot.cli.command.init.InitCommand; import org.springframework.boot.cli.command.install.InstallCommand; import org.springframework.boot.cli.command.install.UninstallCommand; import org.springframework.boot.cli.command.jar.JarCommand; @@ -39,7 +40,7 @@ public class DefaultCommandFactory implements CommandFactory { private static final List DEFAULT_COMMANDS = Arrays. asList( new VersionCommand(), new RunCommand(), new TestCommand(), new GrabCommand(), - new JarCommand(), new InstallCommand(), new UninstallCommand()); + new JarCommand(), new InstallCommand(), new UninstallCommand(), new InitCommand()); @Override public Collection getCommands() { diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/Dependency.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/Dependency.java new file mode 100644 index 0000000000..5cfc24f6c1 --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/Dependency.java @@ -0,0 +1,56 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +/** + * Provide some basic information about a dependency. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +class Dependency { + + private String id; + + private String name; + + private String description; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitCommand.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitCommand.java new file mode 100644 index 0000000000..0339cbc1e0 --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitCommand.java @@ -0,0 +1,40 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import org.apache.http.impl.client.HttpClientBuilder; + +import org.springframework.boot.cli.command.Command; +import org.springframework.boot.cli.command.OptionParsingCommand; + +/** + * {@link Command} that initializes a project using Spring initializr. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +public class InitCommand extends OptionParsingCommand { + + InitCommand(InitCommandOptionHandler handler) { + super("init", "Initialize a new project structure using Spring Initializr", handler); + } + + public InitCommand() { + this(new InitCommandOptionHandler(HttpClientBuilder.create().build())); + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitCommandOptionHandler.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitCommandOptionHandler.java new file mode 100644 index 0000000000..7fefe8341b --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitCommandOptionHandler.java @@ -0,0 +1,280 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import joptsimple.OptionSet; +import joptsimple.OptionSpec; +import org.apache.http.impl.client.CloseableHttpClient; + +import org.springframework.boot.cli.command.options.OptionHandler; +import org.springframework.boot.cli.command.status.ExitStatus; +import org.springframework.boot.cli.util.Log; +import org.springframework.util.StreamUtils; + +/** + * The {@link OptionHandler} implementation for the init command. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +public class InitCommandOptionHandler extends OptionHandler { + + private final CloseableHttpClient httpClient; + + private OptionSpec target; + + private OptionSpec listMetadata; + + // Project generation options + + private OptionSpec bootVersion; + + private OptionSpec dependencies; + + private OptionSpec javaVersion; + + private OptionSpec packaging; + + private OptionSpec build; + + private OptionSpec format; + + private OptionSpec type; + + // Other options + + private OptionSpec extract; + + private OptionSpec force; + + private OptionSpec output; + + InitCommandOptionHandler(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + protected void options() { + this.target = option(Arrays.asList("target"), + "URL of the service to use").withRequiredArg().defaultsTo(ProjectGenerationRequest.DEFAULT_SERVICE_URL); + this.listMetadata = option(Arrays.asList("list", "l"), "List the capabilities of the service. Use it to " + + "discover the dependencies and the types that are available."); + + // Project generation settings + this.bootVersion = option(Arrays.asList("boot-version", "bv"), + "Spring Boot version to use (e.g. 1.2.0.RELEASE)").withRequiredArg(); + this.dependencies = option(Arrays.asList("dependencies", "d"), + "Comma separated list of dependencies to include in the generated project").withRequiredArg(); + this.javaVersion = option(Arrays.asList("java-version", "jv"), + "Java version to use (e.g. 1.8)").withRequiredArg(); + this.packaging = option(Arrays.asList("packaging", "p"), "Packaging type to use (e.g. jar)").withRequiredArg(); + + this.build = option("build", "The build system to use (e.g. maven, gradle). To be used alongside " + + "--format to uniquely identify one type that is supported by the service. " + + "Use --type in case of conflict").withRequiredArg().defaultsTo("maven"); + this.format = option("format", "The format of the generated content (e.g. build for a build file, " + + "project for a project archive). To be used alongside --build to uniquely identify one type " + + "that is supported by the service. Use --type in case of conflict") + .withRequiredArg().defaultsTo("project"); + this.type = option(Arrays.asList("type", "t"), "The project type to use. Not normally needed if you " + + "use --build and/or --format. Check the capabilities of the service (--list) for " + + "more details.").withRequiredArg(); + + // Others + this.extract = option(Arrays.asList("extract", "x"), "Extract the project archive"); + this.force = option(Arrays.asList("force", "f"), "Force overwrite of existing files"); + this.output = option(Arrays.asList("output", "o"), + "Location of the generated project. Can be an absolute or a relative reference and " + + "should refer to a directory when --extract is used.").withRequiredArg(); + } + + @Override + protected ExitStatus run(OptionSet options) throws Exception { + if (options.has(listMetadata)) { + return listServiceCapabilities(options, httpClient); + } + else { + return generateProject(options, httpClient); + } + } + + public ProjectGenerationRequest createProjectGenerationRequest(OptionSet options) { + ProjectGenerationRequest request = new ProjectGenerationRequest(); + request.setServiceUrl(determineServiceUrl(options)); + + if (options.has(this.bootVersion)) { + request.setBootVersion(options.valueOf(this.bootVersion)); + } + if (options.has(this.dependencies)) { + for (String dep : options.valueOf(this.dependencies).split(",")) { + request.getDependencies().add(dep.trim()); + } + } + if (options.has(this.javaVersion)) { + request.setJavaVersion(options.valueOf(this.javaVersion)); + } + if (options.has(this.packaging)) { + request.setPackaging(options.valueOf(this.packaging)); + } + request.setBuild(options.valueOf(this.build)); + request.setFormat(options.valueOf(this.format)); + request.setDetectType(options.has(this.build) || options.has(this.format)); + if (options.has(this.type)) { + request.setType(options.valueOf(this.type)); + } + if (options.has(this.output)) { + request.setOutput(options.valueOf(this.output)); + } + return request; + } + + protected ExitStatus listServiceCapabilities(OptionSet options, CloseableHttpClient httpClient) throws IOException { + ListMetadataCommand command = new ListMetadataCommand(httpClient); + Log.info(command.generateReport(determineServiceUrl(options))); + return ExitStatus.OK; + } + + protected ExitStatus generateProject(OptionSet options, CloseableHttpClient httpClient) { + ProjectGenerationRequest request = createProjectGenerationRequest(options); + boolean forceValue = options.has(this.force); + try { + ProjectGenerationResponse entity = new InitializrServiceHttpInvoker(httpClient).generate(request); + if (options.has(this.extract)) { + if (isZipArchive(entity)) { + return extractProject(entity, options.valueOf(this.output), forceValue); + } + else { + Log.info("Could not extract '" + entity.getContentType() + "'"); + } + } + String outputFileName = entity.getFileName() != null ? entity.getFileName() : options.valueOf(this.output); + if (outputFileName == null) { + Log.error("Could not save the project, the server did not set a preferred " + + "file name. Use --output to specify the output location for the project."); + return ExitStatus.ERROR; + } + return writeProject(entity, outputFileName, forceValue); + } + catch (ProjectGenerationException ex) { + Log.error(ex.getMessage()); + return ExitStatus.ERROR; + } + catch (Exception ex) { + Log.error(ex); + return ExitStatus.ERROR; + } + } + + private String determineServiceUrl(OptionSet options) { + return options.valueOf(this.target); + } + + private ExitStatus writeProject(ProjectGenerationResponse entity, String outputFileName, boolean overwrite) + throws IOException { + + File f = new File(outputFileName); + if (f.exists()) { + if (overwrite) { + if (!f.delete()) { + throw new IllegalStateException("Failed to delete existing file " + + f.getPath()); + } + } + else { + Log.error("File '" + f.getName() + "' already exists. Use --force if you want to " + + "overwrite or --output to specify an alternate location."); + return ExitStatus.ERROR; + } + } + FileOutputStream stream = new FileOutputStream(f); + try { + StreamUtils.copy(entity.getContent(), stream); + Log.info("Content saved to '" + outputFileName + "'"); + return ExitStatus.OK; + } + finally { + stream.close(); + } + } + + private boolean isZipArchive(ProjectGenerationResponse entity) { + if (entity.getContentType() == null) { + return false; + } + try { + return "application/zip".equals(entity.getContentType().getMimeType()); + } + catch (Exception e) { + return false; + } + } + + private ExitStatus extractProject(ProjectGenerationResponse entity, String outputValue, boolean overwrite) throws IOException { + File output = outputValue != null ? new File(outputValue) : new File(System.getProperty("user.dir")); + if (!output.exists()) { + output.mkdirs(); + } + ZipInputStream zipIn = new ZipInputStream(new ByteArrayInputStream(entity.getContent())); + try { + ZipEntry entry = zipIn.getNextEntry(); + while (entry != null) { + File f = new File(output, entry.getName()); + if (f.exists() && !overwrite) { + StringBuilder sb = new StringBuilder(); + sb.append(f.isDirectory() ? "Directory" : "File") + .append(" '").append(f.getName()).append("' already exists. Use --force if you want to " + + "overwrite or --output to specify an alternate location."); + Log.error(sb.toString()); + return ExitStatus.ERROR; + } + if (!entry.isDirectory()) { + extractZipEntry(zipIn, f); + } + else { + f.mkdir(); + } + zipIn.closeEntry(); + entry = zipIn.getNextEntry(); + } + Log.info("Project extracted to '" + output.getAbsolutePath() + "'"); + return ExitStatus.OK; + } + finally { + zipIn.close(); + } + } + + private void extractZipEntry(ZipInputStream in, File outputFile) throws IOException { + BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(outputFile)); + try { + StreamUtils.copy(in, out); + } + finally { + out.close(); + } + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitializrServiceHttpInvoker.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitializrServiceHttpInvoker.java new file mode 100644 index 0000000000..2a5cbfbe39 --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitializrServiceHttpInvoker.java @@ -0,0 +1,215 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.Charset; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHeaders; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.entity.ContentType; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicHeader; +import org.json.JSONException; +import org.json.JSONObject; + +import org.springframework.boot.cli.util.Log; +import org.springframework.util.StreamUtils; +import org.springframework.util.StringUtils; + +/** + * Invokes the initializr service over HTTP. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +class InitializrServiceHttpInvoker { + + private final CloseableHttpClient httpClient; + + /** + * Create a new instance with the given {@link CloseableHttpClient http client}. + */ + InitializrServiceHttpInvoker(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Generate a project based on the specified {@link ProjectGenerationRequest} + * @return an entity defining the project + */ + ProjectGenerationResponse generate(ProjectGenerationRequest request) throws IOException { + Log.info("Using service at " + request.getServiceUrl()); + InitializrServiceMetadata metadata = loadMetadata(request.getServiceUrl()); + URI url = request.generateUrl(metadata); + CloseableHttpResponse httpResponse = executeProjectGenerationRequest(url); + + HttpEntity httpEntity = httpResponse.getEntity(); + if (httpEntity == null) { + throw new ProjectGenerationException("No content received from server using '" + url + "'"); + } + if (httpResponse.getStatusLine().getStatusCode() != 200) { + throw buildProjectGenerationException(request.getServiceUrl(), httpResponse); + } + return createResponse(httpResponse, httpEntity); + } + + /** + * Load the {@link InitializrServiceMetadata} at the specified url. + */ + InitializrServiceMetadata loadMetadata(String serviceUrl) throws IOException { + CloseableHttpResponse httpResponse = executeInitializrMetadataRetrieval(serviceUrl); + if (httpResponse.getEntity() == null) { + throw new ProjectGenerationException("No content received from server using '" + serviceUrl + "'"); + } + if (httpResponse.getStatusLine().getStatusCode() != 200) { + throw buildProjectGenerationException(serviceUrl, httpResponse); + } + try { + HttpEntity httpEntity = httpResponse.getEntity(); + JSONObject root = getContentAsJson(getContent(httpEntity), getContentType(httpEntity)); + return new InitializrServiceMetadata(root); + } + catch (JSONException e) { + throw new ProjectGenerationException("Invalid content received from server (" + e.getMessage() + ")"); + } + } + + private ProjectGenerationResponse createResponse(CloseableHttpResponse httpResponse, HttpEntity httpEntity) + throws IOException { + ProjectGenerationResponse response = new ProjectGenerationResponse(); + ContentType contentType = ContentType.getOrDefault(httpEntity); + response.setContentType(contentType); + + InputStream in = httpEntity.getContent(); + try { + response.setContent(StreamUtils.copyToByteArray(in)); + } + finally { + in.close(); + } + + String detectedFileName = extractFileName(httpResponse.getFirstHeader("Content-Disposition")); + if (detectedFileName != null) { + response.setFileName(detectedFileName); + } + return response; + } + + /** + * Request the creation of the project using the specified url + */ + private CloseableHttpResponse executeProjectGenerationRequest(URI url) { + try { + HttpGet get = new HttpGet(url); + return this.httpClient.execute(get); + } + catch (IOException e) { + throw new ProjectGenerationException( + "Failed to invoke server at '" + url + "' (" + e.getMessage() + ")"); + } + } + + /** + * Retrieves the metadata of the service at the specified url + */ + private CloseableHttpResponse executeInitializrMetadataRetrieval(String serviceUrl) { + try { + HttpGet get = new HttpGet(serviceUrl); + get.setHeader(new BasicHeader(HttpHeaders.ACCEPT, "application/json")); + return this.httpClient.execute(get); + } + catch (IOException e) { + throw new ProjectGenerationException( + "Failed to retrieve metadata from service at '" + serviceUrl + "' (" + e.getMessage() + ")"); + } + } + + + private byte[] getContent(HttpEntity httpEntity) throws IOException { + InputStream in = httpEntity.getContent(); + try { + return StreamUtils.copyToByteArray(in); + } + finally { + in.close(); + } + } + + private ContentType getContentType(HttpEntity httpEntity) { + return ContentType.getOrDefault(httpEntity); + } + + private JSONObject getContentAsJson(byte[] content, ContentType contentType) { + Charset charset = contentType.getCharset() != null ? contentType.getCharset() : Charset.forName("UTF-8"); + String data = new String(content, charset); + return new JSONObject(data); + } + + private ProjectGenerationException buildProjectGenerationException(String url, CloseableHttpResponse httpResponse) { + StringBuilder sb = new StringBuilder("Project generation failed using '"); + sb.append(url).append("' - service returned ") + .append(httpResponse.getStatusLine().getReasonPhrase()); + String error = extractMessage(httpResponse.getEntity()); + if (StringUtils.hasText(error)) { + sb.append(": '").append(error).append("'"); + } + else { + sb.append(" (unexpected ").append(httpResponse.getStatusLine().getStatusCode()).append(" error)"); + } + throw new ProjectGenerationException(sb.toString()); + } + + private String extractMessage(HttpEntity entity) { + if (entity == null) { + return null; + } + try { + JSONObject error = getContentAsJson(getContent(entity), getContentType(entity)); + if (error.has("message")) { + return error.getString("message"); + } + return null; + } + catch (Exception e) { + return null; + } + } + + private static String extractFileName(Header h) { + if (h == null) { + return null; + } + String value = h.getValue(); + String prefix = "filename=\""; + int start = value.indexOf(prefix); + if (start != -1) { + value = value.substring(start + prefix.length(), value.length()); + int end = value.indexOf("\""); + if (end != -1) { + return value.substring(0, end); + } + } + return null; + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitializrServiceMetadata.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitializrServiceMetadata.java new file mode 100644 index 0000000000..2559f5720e --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/InitializrServiceMetadata.java @@ -0,0 +1,236 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Define the metadata available for a particular service instance. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +class InitializrServiceMetadata { + + private static final String DEPENDENCIES_EL = "dependencies"; + + private static final String TYPES_EL = "types"; + + private static final String DEFAULTS_EL = "defaults"; + + private static final String CONTENT_EL = "content"; + + private static final String NAME_ATTRIBUTE = "name"; + + private static final String ID_ATTRIBUTE = "id"; + + private static final String DESCRIPTION_ATTRIBUTE = "description"; + + private static final String ACTION_ATTRIBUTE = "action"; + + private static final String DEFAULT_ATTRIBUTE = "default"; + + + private final Map dependencies; + + private final MetadataHolder projectTypes; + + private final Map defaults; + + + /** + * Creates a new instance using the specified root {@link JSONObject}. + */ + InitializrServiceMetadata(JSONObject root) { + this.dependencies = parseDependencies(root); + this.projectTypes = parseProjectTypes(root); + this.defaults = Collections.unmodifiableMap(parseDefaults(root)); + } + + InitializrServiceMetadata(ProjectType defaultProjectType) { + this.dependencies = new HashMap(); + this.projectTypes = new MetadataHolder(); + this.projectTypes.getContent().put(defaultProjectType.getId(), defaultProjectType); + this.projectTypes.setDefaultItem(defaultProjectType); + this.defaults = new HashMap(); + } + + /** + * Return the dependencies supported by the service. + */ + public Collection getDependencies() { + return dependencies.values(); + } + + /** + * Return the dependency with the specified id or {@code null} if no + * such dependency exists. + */ + public Dependency getDependency(String id) { + return dependencies.get(id); + } + + /** + * Return the project types supported by the service. + */ + public Map getProjectTypes() { + return projectTypes.getContent(); + } + + /** + * Return the default type to use or {@code null} or the metadata does + * not define any default. + */ + public ProjectType getDefaultType() { + if (projectTypes.getDefaultItem() != null) { + return projectTypes.getDefaultItem(); + } + String defaultTypeId = getDefaults().get("type"); + if (defaultTypeId != null) { + return projectTypes.getContent().get(defaultTypeId); + } + return null; + } + + /** + * Returns the defaults applicable to the service. + */ + public Map getDefaults() { + return defaults; + } + + private Map parseDependencies(JSONObject root) { + Map result = new HashMap(); + if (!root.has(DEPENDENCIES_EL)) { + return result; + } + JSONArray array = root.getJSONArray(DEPENDENCIES_EL); + for (int i = 0; i < array.length(); i++) { + JSONObject group = array.getJSONObject(i); + parseGroup(group, result); + } + return result; + } + + private MetadataHolder parseProjectTypes(JSONObject root) { + MetadataHolder result = new MetadataHolder(); + if (!root.has(TYPES_EL)) { + return result; + } + JSONArray array = root.getJSONArray(TYPES_EL); + for (int i = 0; i < array.length(); i++) { + JSONObject typeJson = array.getJSONObject(i); + ProjectType projectType = parseType(typeJson); + result.getContent().put(projectType.getId(), projectType); + if (projectType.isDefaultType()) { + result.setDefaultItem(projectType); + } + } + return result; + } + + private Map parseDefaults(JSONObject root) { + Map result = new HashMap(); + if (!root.has(DEFAULTS_EL)) { + return result; + } + JSONObject defaults = root.getJSONObject(DEFAULTS_EL); + result.putAll(parseStringItems(defaults)); + return result; + } + + private void parseGroup(JSONObject group, Map dependencies) { + if (group.has(CONTENT_EL)) { + JSONArray content = group.getJSONArray(CONTENT_EL); + for (int i = 0; i < content.length(); i++) { + Dependency dependency = parseDependency(content.getJSONObject(i)); + dependencies.put(dependency.getId(), dependency); + } + } + } + + private Dependency parseDependency(JSONObject object) { + Dependency dependency = new Dependency(); + dependency.setName(getStringValue(object, NAME_ATTRIBUTE, null)); + dependency.setId(getStringValue(object, ID_ATTRIBUTE, null)); + dependency.setDescription(getStringValue(object, DESCRIPTION_ATTRIBUTE, null)); + return dependency; + } + + private ProjectType parseType(JSONObject object) { + String id = getStringValue(object, ID_ATTRIBUTE, null); + String name = getStringValue(object, NAME_ATTRIBUTE, null); + String action = getStringValue(object, ACTION_ATTRIBUTE, null); + boolean defaultType = getBooleanValue(object, DEFAULT_ATTRIBUTE, false); + Map tags = new HashMap(); + if (object.has("tags")) { + JSONObject jsonTags = object.getJSONObject("tags"); + tags.putAll(parseStringItems(jsonTags)); + } + return new ProjectType(id, name, action, defaultType, tags); + } + + private String getStringValue(JSONObject object, String name, String defaultValue) { + return object.has(name) ? object.getString(name) : defaultValue; + } + + private boolean getBooleanValue(JSONObject object, String name, boolean defaultValue) { + return object.has(name) ? object.getBoolean(name) : defaultValue; + } + + private Map parseStringItems(JSONObject json) { + Map result = new HashMap(); + for (Object k : json.keySet()) { + String key = (String) k; + Object value = json.get(key); + if (value instanceof String) { + result.put(key, (String) value); + } + } + return result; + } + + private static class MetadataHolder { + + private final Map content; + + private T defaultItem; + + private MetadataHolder() { + this.content = new HashMap(); + } + + public Map getContent() { + return content; + } + + public T getDefaultItem() { + return defaultItem; + } + + public void setDefaultItem(T defaultItem) { + this.defaultItem = defaultItem; + } + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ListMetadataCommand.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ListMetadataCommand.java new file mode 100644 index 0000000000..9062c7bdbc --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ListMetadataCommand.java @@ -0,0 +1,119 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.apache.http.impl.client.CloseableHttpClient; +import org.codehaus.plexus.util.StringUtils; + +/** + * A helper class generating a report from the metadata of a particular service. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +class ListMetadataCommand { + + private static final String NEW_LINE = System.getProperty("line.separator"); + + private final InitializrServiceHttpInvoker initializrServiceInvoker; + + /** + * Creates an instance using the specified {@link CloseableHttpClient}. + */ + ListMetadataCommand(CloseableHttpClient httpClient) { + this.initializrServiceInvoker = new InitializrServiceHttpInvoker(httpClient); + } + + /** + * Generate a report for the specified service. The report contains the available + * capabilities as advertized by the root endpoint. + */ + String generateReport(String serviceUrl) throws IOException { + InitializrServiceMetadata metadata = initializrServiceInvoker.loadMetadata(serviceUrl); + String header = "Capabilities of " + serviceUrl; + int size = header.length(); + + StringBuilder sb = new StringBuilder(); + sb.append(StringUtils.repeat("=", size)).append(NEW_LINE) + .append(header).append(NEW_LINE) + .append(StringUtils.repeat("=", size)).append(NEW_LINE) + .append(NEW_LINE) + .append("Available dependencies:").append(NEW_LINE) + .append("-----------------------").append(NEW_LINE); + + List dependencies = new ArrayList(metadata.getDependencies()); + Collections.sort(dependencies, new Comparator() { + @Override + public int compare(Dependency o1, Dependency o2) { + return o1.getId().compareTo(o2.getId()); + } + }); + for (Dependency dependency : dependencies) { + sb.append(dependency.getId()).append(" - ").append(dependency.getName()); + if (dependency.getDescription() != null) { + sb.append(": ").append(dependency.getDescription()); + } + sb.append(NEW_LINE); + } + + sb.append(NEW_LINE) + .append("Available project types:").append(NEW_LINE) + .append("------------------------").append(NEW_LINE); + List typeIds = new ArrayList(metadata.getProjectTypes().keySet()); + Collections.sort(typeIds); + for (String typeId : typeIds) { + ProjectType type = metadata.getProjectTypes().get(typeId); + sb.append(typeId).append(" - ").append(type.getName()); + if (!type.getTags().isEmpty()) { + sb.append(" ["); + Iterator> it = type.getTags().entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + sb.append(entry.getKey()).append(":").append(entry.getValue()); + if (it.hasNext()) { + sb.append(", "); + } + } + sb.append("]"); + } + if (type.isDefaultType()) { + sb.append(" (default)"); + } + sb.append(NEW_LINE); + } + + sb.append(NEW_LINE) + .append("Defaults:").append(NEW_LINE) + .append("---------").append(NEW_LINE); + + List defaultsKeys = new ArrayList(metadata.getDefaults().keySet()); + Collections.sort(defaultsKeys); + for (String defaultsKey : defaultsKeys) { + sb.append(defaultsKey).append(": ").append(metadata.getDefaults().get(defaultsKey)).append(NEW_LINE); + } + return sb.toString(); + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationException.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationException.java new file mode 100644 index 0000000000..7237e0ff86 --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationException.java @@ -0,0 +1,31 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +/** + * Thrown when a project could not be generated. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +public class ProjectGenerationException extends RuntimeException { + + public ProjectGenerationException(String message) { + super(message); + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationRequest.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationRequest.java new file mode 100644 index 0000000000..527903cfdc --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationRequest.java @@ -0,0 +1,257 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.apache.http.client.utils.URIBuilder; + +/** + * Represent the settings to apply to generating the project. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +class ProjectGenerationRequest { + + public static final String DEFAULT_SERVICE_URL = "https://start.spring.io"; + + private String serviceUrl = DEFAULT_SERVICE_URL; + + private String output; + + private String bootVersion; + + private List dependencies = new ArrayList(); + + private String javaVersion; + + private String packaging; + + private String build; + + private String format; + + private boolean detectType; + + private String type; + + /** + * The url of the service to use. + * @see #DEFAULT_SERVICE_URL + */ + public String getServiceUrl() { + return serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + + /** + * The location of the generated project. + */ + public String getOutput() { + return output; + } + + public void setOutput(String output) { + this.output = output; + } + + /** + * The Spring Boot version to use or {@code null} if it should not be customized. + */ + public String getBootVersion() { + return bootVersion; + } + + public void setBootVersion(String bootVersion) { + this.bootVersion = bootVersion; + } + + /** + * The identifiers of the dependencies to include in the project. + */ + public List getDependencies() { + return dependencies; + } + + /** + * The Java version to use or {@code null} if it should not be customized. + */ + public String getJavaVersion() { + return javaVersion; + } + + public void setJavaVersion(String javaVersion) { + this.javaVersion = javaVersion; + } + + /** + * The packaging type or {@code null} if it should not be customized. + */ + public String getPackaging() { + return packaging; + } + + public void setPackaging(String packaging) { + this.packaging = packaging; + } + + /** + * The build type to use. Ignored if a type is set. Can be used alongside + * the {@link #getFormat() format} to identify the type to use. + */ + public String getBuild() { + return build; + } + + public void setBuild(String build) { + this.build = build; + } + + /** + * The project format to use. Ignored if a type is set. Can be used alongside + * the {@link #getBuild() build} to identify the type to use. + */ + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + /** + * Specify if the type should be detected based on the build + * and format value. + */ + public boolean isDetectType() { + return detectType; + } + + public void setDetectType(boolean detectType) { + this.detectType = detectType; + } + + /** + * The type of project to generate. Should match one of the advertized type + * that the service supports. If not set, the default is retrieved from + * the service metadata. + */ + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + /** + * Generates the URL to use to generate a project represented + * by this request + */ + URI generateUrl(InitializrServiceMetadata metadata) { + try { + URIBuilder builder = new URIBuilder(serviceUrl); + StringBuilder sb = new StringBuilder(); + if (builder.getPath() != null) { + sb.append(builder.getPath()); + } + + ProjectType projectType = determineProjectType(metadata); + this.type = projectType.getId(); + sb.append(projectType.getAction()); + builder.setPath(sb.toString()); + + if (this.bootVersion != null) { + builder.setParameter("bootVersion", this.bootVersion); + } + for (String dependency : dependencies) { + builder.addParameter("style", dependency); + } + if (this.javaVersion != null) { + builder.setParameter("javaVersion", this.javaVersion); + } + if (this.packaging != null) { + builder.setParameter("packaging", this.packaging); + } + if (this.type != null) { + builder.setParameter("type", projectType.getId()); + } + + return builder.build(); + } + catch (URISyntaxException e) { + throw new ProjectGenerationException("Invalid service URL (" + e.getMessage() + ")"); + } + } + + protected ProjectType determineProjectType(InitializrServiceMetadata metadata) { + if (this.type != null) { + ProjectType result = metadata.getProjectTypes().get(this.type); + if (result == null) { + throw new ProjectGenerationException(("No project type with id '" + this.type + + "' - check the service capabilities (--list)")); + } + } + if (isDetectType()) { + Map types = new HashMap(metadata.getProjectTypes()); + if (this.build != null) { + filter(types, "build", this.build); + } + if (this.format != null) { + filter(types, "format", this.format); + } + if (types.size() == 1) { + return types.values().iterator().next(); + } + else if (types.size() == 0) { + throw new ProjectGenerationException("No type found with build '" + this.build + "' and format '" + + this.format + "' check the service capabilities (--list)"); + } + else { + throw new ProjectGenerationException("Multiple types found with build '" + this.build + + "' and format '" + this.format + "' use --type with a more specific value " + types.keySet()); + } + } + ProjectType defaultType = metadata.getDefaultType(); + if (defaultType == null) { + throw new ProjectGenerationException(("No project type is set and no default is defined. " + + "Check the service capabilities (--list)")); + } + return defaultType; + } + + private static void filter(Map projects, String tag, String tagValue) { + for (Iterator> it = projects.entrySet().iterator(); it.hasNext(); ) { + Map.Entry entry = it.next(); + String value = entry.getValue().getTags().get(tag); + if (!tagValue.equals(value)) { + it.remove(); + } + } + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationResponse.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationResponse.java new file mode 100644 index 0000000000..d0c7b44e99 --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectGenerationResponse.java @@ -0,0 +1,72 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import org.apache.http.entity.ContentType; + +/** + * Represent the response of a {@link ProjectGenerationRequest} + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +class ProjectGenerationResponse { + + private ContentType contentType; + + private byte[] content; + + private String fileName; + + ProjectGenerationResponse() { + } + + /** + * Return the {@link ContentType} of this instance + */ + public ContentType getContentType() { + return this.contentType; + } + + public void setContentType(ContentType contentType) { + this.contentType = contentType; + } + + /** + * The generated project archive or file. + */ + public byte[] getContent() { + return content; + } + + public void setContent(byte[] content) { + this.content = content; + } + + /** + * The preferred file name to use to store the entity on disk or {@code null} + * if no preferred value has been set. + */ + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectType.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectType.java new file mode 100644 index 0000000000..e4f0aea3db --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/init/ProjectType.java @@ -0,0 +1,70 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Represent a project type that is supported by a service. + * + * @author Stephane Nicoll + * @since 1.2.0 + */ +class ProjectType { + + private final String id; + + private final String name; + + private final String action; + + private final boolean defaultType; + + private final Map tags = new HashMap(); + + public ProjectType(String id, String name, String action, boolean defaultType, Map tags) { + this.id = id; + this.name = name; + this.action = action; + this.defaultType = defaultType; + if (tags != null) { + this.tags.putAll(tags); + } + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getAction() { + return action; + } + + public boolean isDefaultType() { + return defaultType; + } + + public Map getTags() { + return Collections.unmodifiableMap(tags); + } +} diff --git a/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/AbstractHttpClientMockTests.java b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/AbstractHttpClientMockTests.java new file mode 100644 index 0000000000..6e3d54e2e1 --- /dev/null +++ b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/AbstractHttpClientMockTests.java @@ -0,0 +1,183 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHeaders; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicHeader; +import org.hamcrest.Matcher; +import org.json.JSONObject; +import org.mockito.ArgumentMatcher; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.StreamUtils; + +import static org.mockito.Mockito.*; + +/** + * + * @author Stephane Nicoll + */ +public abstract class AbstractHttpClientMockTests { + + protected final CloseableHttpClient httpClient = mock(CloseableHttpClient.class); + + protected void mockSuccessfulMetadataGet() throws IOException { + mockSuccessfulMetadataGet("1.1.0"); + } + + protected void mockSuccessfulMetadataGet(String version) throws IOException { + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + Resource resource = new ClassPathResource("metadata/service-metadata-" + version + ".json"); + byte[] content = StreamUtils.copyToByteArray(resource.getInputStream()); + mockHttpEntity(response, content, "application/json"); + mockStatus(response, 200); + when(httpClient.execute(argThat(getForJsonData()))).thenReturn(response); + } + + protected void mockSuccessfulProjectGeneration(MockHttpProjectGenerationRequest request) throws IOException { + // Required for project generation as the metadata is read first + mockSuccessfulMetadataGet(); + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + mockHttpEntity(response, request.content, request.contentType); + mockStatus(response, 200); + + String header = request.fileName != null ? contentDispositionValue(request.fileName) : null; + mockHttpHeader(response, "Content-Disposition", header); + when(httpClient.execute(argThat(getForNonJsonData()))).thenReturn(response); + } + + protected void mockProjectGenerationError(int status, String message) throws IOException { + // Required for project generation as the metadata is read first + mockSuccessfulMetadataGet(); + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + mockHttpEntity(response, createJsonError(status, message).getBytes(), "application/json"); + mockStatus(response, status); + when(httpClient.execute(isA(HttpGet.class))).thenReturn(response); + } + + protected void mockMetadataGetError(int status, String message) throws IOException { + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + mockHttpEntity(response, createJsonError(status, message).getBytes(), "application/json"); + mockStatus(response, status); + when(httpClient.execute(isA(HttpGet.class))).thenReturn(response); + } + + protected HttpEntity mockHttpEntity(CloseableHttpResponse response, byte[] content, String contentType) { + try { + HttpEntity entity = mock(HttpEntity.class); + when(entity.getContent()).thenReturn(new ByteArrayInputStream(content)); + Header contentTypeHeader = contentType != null ? new BasicHeader("Content-Type", contentType) : null; + when(entity.getContentType()).thenReturn(contentTypeHeader); + when(response.getEntity()).thenReturn(entity); + return entity; + } + catch (IOException e) { + throw new IllegalStateException("Should not happen", e); + } + } + + protected void mockStatus(CloseableHttpResponse response, int status) { + StatusLine statusLine = mock(StatusLine.class); + when(statusLine.getStatusCode()).thenReturn(status); + when(response.getStatusLine()).thenReturn(statusLine); + } + + protected void mockHttpHeader(CloseableHttpResponse response, String headerName, String value) { + Header header = value != null ? new BasicHeader(headerName, value) : null; + when(response.getFirstHeader(headerName)).thenReturn(header); + } + + protected Matcher getForJsonData() { + return new HasAcceptHeader("application/json", true); + } + + protected Matcher getForNonJsonData() { + return new HasAcceptHeader("application/json", false); + } + + private String contentDispositionValue(String fileName) { + return "attachment; filename=\"" + fileName + "\""; + } + + private String createJsonError(int status, String message) { + JSONObject json = new JSONObject(); + json.put("status", status); + if (message != null) { + json.put("message", message); + } + return json.toString(); + } + + protected static class MockHttpProjectGenerationRequest { + + String contentType; + + String fileName; + + byte[] content = new byte[] {0, 0, 0, 0}; + + public MockHttpProjectGenerationRequest(String contentType, String fileName, byte[] content) { + this.contentType = contentType; + this.fileName = fileName; + this.content = content; + } + + public MockHttpProjectGenerationRequest(String contentType, String fileName) { + this(contentType, fileName, new byte[] {0, 0, 0, 0}); + } + } + + private static class HasAcceptHeader extends ArgumentMatcher { + + private final String value; + + private final boolean shouldMatch; + + public HasAcceptHeader(String value, boolean shouldMatch) { + this.value = value; + this.shouldMatch = shouldMatch; + } + + @Override + public boolean matches(Object argument) { + if (!(argument instanceof HttpGet)) { + return false; + } + HttpGet get = (HttpGet) argument; + Header acceptHeader = get.getFirstHeader(HttpHeaders.ACCEPT); + if (shouldMatch) { + return acceptHeader != null && value.equals(acceptHeader.getValue()); + } + else { + return acceptHeader == null || !value.equals(acceptHeader.getValue()); + } + } + } + +} diff --git a/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitCommandTests.java b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitCommandTests.java new file mode 100644 index 0000000000..cf91898abb --- /dev/null +++ b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitCommandTests.java @@ -0,0 +1,296 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import joptsimple.OptionSet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import org.springframework.boot.cli.command.status.ExitStatus; + +import static org.junit.Assert.*; + +/** + * Tests for {@link InitCommand} + * + * @author Stephane Nicoll + */ +public class InitCommandTests extends AbstractHttpClientMockTests { + + @Rule + public final TemporaryFolder folder = new TemporaryFolder(); + + private final TestableInitCommandOptionHandler handler = new TestableInitCommandOptionHandler(httpClient); + + private final InitCommand command = new InitCommand(handler); + + @Test + public void listServiceCapabilities() throws Exception { + mockSuccessfulMetadataGet(); + command.run("--list", "--target=http://fake-service"); + } + + @Test + public void generateProject() throws Exception { + String fileName = UUID.randomUUID().toString() + ".zip"; + File f = new File(fileName); + assertFalse("file should not exist", f.exists()); + + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/zip", fileName); + mockSuccessfulProjectGeneration(mockHttpRequest); + + try { + assertEquals(ExitStatus.OK, command.run()); + assertTrue("file should have been created", f.exists()); + } + finally { + assertTrue("failed to delete test file", f.delete()); + } + } + + @Test + public void generateProjectNoFileNameAvailable() throws Exception { + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/zip", null); + mockSuccessfulProjectGeneration(mockHttpRequest); + assertEquals(ExitStatus.ERROR, command.run()); + } + + @Test + public void generateProjectAndExtract() throws Exception { + File f = folder.newFolder(); + + byte[] archive = createFakeZipArchive("test.txt", "Fake content"); + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/zip", "demo.zip", archive); + mockSuccessfulProjectGeneration(mockHttpRequest); + + assertEquals(ExitStatus.OK, command.run("--extract", "--output=" + f.getAbsolutePath())); + File archiveFile = new File(f, "test.txt"); + assertTrue("Archive not extracted properly " + f.getAbsolutePath() + " not found", archiveFile.exists()); + } + + @Test + public void generateProjectAndExtractUnsupportedArchive() throws Exception { + File f = folder.newFolder(); + String fileName = UUID.randomUUID().toString() + ".zip"; + File archiveFile = new File(fileName); + assertFalse("file should not exist", archiveFile.exists()); + + try { + byte[] archive = createFakeZipArchive("test.txt", "Fake content"); + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/foobar", fileName, archive); + mockSuccessfulProjectGeneration(mockHttpRequest); + + assertEquals(ExitStatus.OK, command.run("--extract", "--output=" + f.getAbsolutePath())); + assertTrue("file should have been saved instead", archiveFile.exists()); + } + finally { + assertTrue("failed to delete test file", archiveFile.delete()); + } + } + + @Test + public void generateProjectAndExtractUnknownContentType() throws Exception { + File f = folder.newFolder(); + String fileName = UUID.randomUUID().toString() + ".zip"; + File archiveFile = new File(fileName); + assertFalse("file should not exist", archiveFile.exists()); + + try { + byte[] archive = createFakeZipArchive("test.txt", "Fake content"); + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest(null, fileName, archive); + mockSuccessfulProjectGeneration(mockHttpRequest); + + assertEquals(ExitStatus.OK, command.run("--extract", "--output=" + f.getAbsolutePath())); + assertTrue("file should have been saved instead", archiveFile.exists()); + } + finally { + assertTrue("failed to delete test file", archiveFile.delete()); + } + } + + @Test + public void fileNotOverwrittenByDefault() throws Exception { + File f = folder.newFile(); + long fileLength = f.length(); + + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/zip", f.getAbsolutePath()); + mockSuccessfulProjectGeneration(mockHttpRequest); + + assertEquals("Should have failed", ExitStatus.ERROR, command.run()); + assertEquals("File should not have changed", fileLength, f.length()); + } + + @Test + public void overwriteFile() throws Exception { + File f = folder.newFile(); + long fileLength = f.length(); + + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/zip", f.getAbsolutePath()); + mockSuccessfulProjectGeneration(mockHttpRequest); + assertEquals("Should not have failed", ExitStatus.OK, command.run("--force")); + assertTrue("File should have changed", fileLength != f.length()); + } + + @Test + public void fileInArchiveNotOverwrittenByDefault() throws Exception { + File f = folder.newFolder(); + File conflict = new File(f, "test.txt"); + assertTrue("Should have been able to create file", conflict.createNewFile()); + long fileLength = conflict.length(); + + // also contains test.txt + byte[] archive = createFakeZipArchive("test.txt", "Fake content"); + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/zip", "demo.zip", archive); + mockSuccessfulProjectGeneration(mockHttpRequest); + + assertEquals(ExitStatus.ERROR, command.run("--extract", "--output=" + f.getAbsolutePath())); + assertEquals("File should not have changed", fileLength, conflict.length()); + } + + @Test + public void overwriteFileInArchive() throws Exception { + File f = folder.newFolder(); + File conflict = new File(f, "test.txt"); + assertTrue("Should have been able to create file", conflict.createNewFile()); + long fileLength = conflict.length(); + + // also contains test.txt + byte[] archive = createFakeZipArchive("test.txt", "Fake content"); + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/zip", "demo.zip", archive); + mockSuccessfulProjectGeneration(mockHttpRequest); + + assertEquals(ExitStatus.OK, command.run("--force", "--extract", "--output=" + f.getAbsolutePath())); + assertTrue("File should have changed", fileLength != conflict.length()); + } + + @Test + public void parseProjectOptions() throws Exception { + handler.disableProjectGeneration(); + command.run("-bv=1.2.0.RELEASE", "-d=web,data-jpa", "-jv=1.9", "-p=war", + "--build=grunt", "--format=web", "-t=ant-project"); + + assertEquals("1.2.0.RELEASE", handler.lastRequest.getBootVersion()); + List dependencies = handler.lastRequest.getDependencies(); + assertEquals(2, dependencies.size()); + assertTrue(dependencies.contains("web")); + assertTrue(dependencies.contains("data-jpa")); + assertEquals("1.9", handler.lastRequest.getJavaVersion()); + assertEquals("war", handler.lastRequest.getPackaging()); + assertEquals("grunt", handler.lastRequest.getBuild()); + assertEquals("web", handler.lastRequest.getFormat()); + assertEquals("ant-project", handler.lastRequest.getType()); + } + + @Test + public void parseTypeOnly() throws Exception { + handler.disableProjectGeneration(); + command.run("-t=ant-project"); + assertEquals("maven", handler.lastRequest.getBuild()); + assertEquals("project", handler.lastRequest.getFormat()); + assertFalse(handler.lastRequest.isDetectType()); + assertEquals("ant-project", handler.lastRequest.getType()); + } + + @Test + public void parseBuildOnly() throws Exception { + handler.disableProjectGeneration(); + command.run("--build=ant"); + assertEquals("ant", handler.lastRequest.getBuild()); + assertEquals("project", handler.lastRequest.getFormat()); + assertTrue(handler.lastRequest.isDetectType()); + assertNull(handler.lastRequest.getType()); + } + + @Test + public void parseFormatOnly() throws Exception { + handler.disableProjectGeneration(); + command.run("--format=web"); + assertEquals("maven", handler.lastRequest.getBuild()); + assertEquals("web", handler.lastRequest.getFormat()); + assertTrue(handler.lastRequest.isDetectType()); + assertNull(handler.lastRequest.getType()); + } + + @Test + public void parseOutput() throws Exception { + handler.disableProjectGeneration(); + command.run("--output=foobar.zip"); + + assertEquals("foobar.zip", handler.lastRequest.getOutput()); + } + + private byte[] createFakeZipArchive(String fileName, String content) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(out); + try { + ZipEntry entry = new ZipEntry(fileName); + + zos.putNextEntry(entry); + zos.write(content.getBytes()); + zos.closeEntry(); + } + finally { + out.close(); + } + return out.toByteArray(); + } + + + private static class TestableInitCommandOptionHandler extends InitCommandOptionHandler { + + private boolean disableProjectGeneration; + + ProjectGenerationRequest lastRequest; + + TestableInitCommandOptionHandler(CloseableHttpClient httpClient) { + super(httpClient); + } + + void disableProjectGeneration() { + disableProjectGeneration = true; + } + + @Override + protected ExitStatus generateProject(OptionSet options, CloseableHttpClient httpClient) { + lastRequest = createProjectGenerationRequest(options); + if (!disableProjectGeneration) { + return super.generateProject(options, httpClient); + } + return ExitStatus.OK; + } + } + +} diff --git a/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitializrServiceHttpInvokerTests.java b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitializrServiceHttpInvokerTests.java new file mode 100644 index 0000000000..8a35497a6c --- /dev/null +++ b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitializrServiceHttpInvokerTests.java @@ -0,0 +1,177 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.IOException; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static junit.framework.TestCase.assertNotNull; +import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Matchers.isA; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link InitializrServiceHttpInvoker} + * + * @author Stephane Nicoll + */ +public class InitializrServiceHttpInvokerTests extends AbstractHttpClientMockTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final InitializrServiceHttpInvoker invoker = new InitializrServiceHttpInvoker(httpClient); + + @Test + public void loadMetadata() throws IOException { + mockSuccessfulMetadataGet(); + InitializrServiceMetadata metadata = invoker.loadMetadata("http://foo/bar"); + assertNotNull(metadata); + } + + @Test + public void generateSimpleProject() throws IOException { + ProjectGenerationRequest request = new ProjectGenerationRequest(); + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/xml", "foo.zip"); + ProjectGenerationResponse entity = generateProject(request, mockHttpRequest); + assertProjectEntity(entity, mockHttpRequest.contentType, mockHttpRequest.fileName); + } + + @Test + public void generateProjectCustomTargetFilename() throws IOException { + ProjectGenerationRequest request = new ProjectGenerationRequest(); + request.setOutput("bar.zip"); + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/xml", null); + ProjectGenerationResponse entity = generateProject(request, mockHttpRequest); + assertProjectEntity(entity, mockHttpRequest.contentType, null); + } + + @Test + public void generateProjectNoDefaultFileName() throws IOException { + ProjectGenerationRequest request = new ProjectGenerationRequest(); + MockHttpProjectGenerationRequest mockHttpRequest = + new MockHttpProjectGenerationRequest("application/xml", null); + ProjectGenerationResponse entity = generateProject(request, mockHttpRequest); + assertProjectEntity(entity, mockHttpRequest.contentType, null); + } + + @Test + public void generateProjectBadRequest() throws IOException { + String jsonMessage = "Unknown dependency foo:bar"; + mockProjectGenerationError(400, jsonMessage); + ProjectGenerationRequest request = new ProjectGenerationRequest(); + request.getDependencies().add("foo:bar"); + + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage(jsonMessage); + invoker.generate(request); + } + + @Test + public void generateProjectBadRequestNoExtraMessage() throws IOException { + mockProjectGenerationError(400, null); + + ProjectGenerationRequest request = new ProjectGenerationRequest(); + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage("unexpected 400 error"); + invoker.generate(request); + } + + @Test + public void generateProjectNoContent() throws IOException { + mockSuccessfulMetadataGet(); + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + mockStatus(response, 500); + when(httpClient.execute(isA(HttpGet.class))).thenReturn(response); + + ProjectGenerationRequest request = new ProjectGenerationRequest(); + + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage("No content received from server"); + invoker.generate(request); + } + + + @Test + public void loadMetadataBadRequest() throws IOException { + String jsonMessage = "whatever error on the server"; + mockMetadataGetError(500, jsonMessage); + ProjectGenerationRequest request = new ProjectGenerationRequest(); + + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage(jsonMessage); + invoker.generate(request); + } + + @Test + public void loadMetadataInvalidJson() throws IOException { + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + mockHttpEntity(response, "Foo-Bar-Not-JSON".getBytes(), "application/json"); + mockStatus(response, 200); + when(httpClient.execute(isA(HttpGet.class))).thenReturn(response); + + ProjectGenerationRequest request = new ProjectGenerationRequest(); + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage("Invalid content received from server"); + invoker.generate(request); + } + + @Test + public void loadMetadataNoContent() throws IOException { + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + mockStatus(response, 500); + when(httpClient.execute(isA(HttpGet.class))).thenReturn(response); + + ProjectGenerationRequest request = new ProjectGenerationRequest(); + + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage("No content received from server"); + invoker.generate(request); + } + + + + + private ProjectGenerationResponse generateProject(ProjectGenerationRequest request, + MockHttpProjectGenerationRequest mockRequest) throws IOException { + mockSuccessfulProjectGeneration(mockRequest); + ProjectGenerationResponse entity = invoker.generate(request); + assertArrayEquals("wrong body content", mockRequest.content, entity.getContent()); + return entity; + } + + private static void assertProjectEntity(ProjectGenerationResponse entity, String mimeType, String fileName) { + if (mimeType == null) { + assertNull("No content type expected", entity.getContentType()); + } + else { + assertEquals("wrong mime type", mimeType, entity.getContentType().getMimeType()); + } + assertEquals("wrong filename", fileName, entity.getFileName()); + } + +} diff --git a/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitializrServiceMetadataTests.java b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitializrServiceMetadataTests.java new file mode 100644 index 0000000000..a7eeb9ff2d --- /dev/null +++ b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/InitializrServiceMetadataTests.java @@ -0,0 +1,101 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; + +import org.json.JSONObject; +import org.junit.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.StreamUtils; + +import static org.junit.Assert.*; + +/** + * Tests for {@link InitializrServiceMetadata} + * + * @author Stephane Nicoll + */ +public class InitializrServiceMetadataTests { + + + @Test + public void parseDefaults() { + InitializrServiceMetadata metadata = createInstance("1.0.0"); + assertEquals("maven-project", metadata.getDefaults().get("type")); + assertEquals("jar", metadata.getDefaults().get("packaging")); + assertEquals("java", metadata.getDefaults().get("language")); + } + + @Test + public void parseDependencies() { + InitializrServiceMetadata metadata = createInstance("1.0.0"); + assertEquals(5, metadata.getDependencies().size()); + + // Security description + assertEquals("AOP", metadata.getDependency("aop").getName()); + assertEquals("Security", metadata.getDependency("security").getName()); + assertEquals("Security description", metadata.getDependency("security").getDescription()); + assertEquals("JDBC", metadata.getDependency("jdbc").getName()); + assertEquals("JPA", metadata.getDependency("data-jpa").getName()); + assertEquals("MongoDB", metadata.getDependency("data-mongodb").getName()); + } + + @Test + public void parseTypesNoTag() { + InitializrServiceMetadata metadata = createInstance("1.0.0"); + ProjectType projectType = metadata.getProjectTypes().get("maven-project"); + assertNotNull(projectType); + assertEquals(0, projectType.getTags().size()); + } + + @Test + public void parseTypesWithTags() { + InitializrServiceMetadata metadata = createInstance("1.1.0"); + ProjectType projectType = metadata.getProjectTypes().get("maven-project"); + assertNotNull(projectType); + assertEquals("maven", projectType.getTags().get("build")); + assertEquals("project", projectType.getTags().get("format")); + } + + + private static InitializrServiceMetadata createInstance(String version) { + try { + return new InitializrServiceMetadata(readJson(version)); + } + catch (IOException e) { + throw new IllegalStateException("Failed to read json", e); + } + } + + private static JSONObject readJson(String version) throws IOException { + Resource resource = new ClassPathResource("metadata/service-metadata-" + version + ".json"); + InputStream stream = resource.getInputStream(); + try { + String json = StreamUtils.copyToString(stream, Charset.forName("UTF-8")); + return new JSONObject(json); + } + finally { + stream.close(); + } + } + +} diff --git a/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/ListMetadataCommandTests.java b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/ListMetadataCommandTests.java new file mode 100644 index 0000000000..dea940a799 --- /dev/null +++ b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/ListMetadataCommandTests.java @@ -0,0 +1,45 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.IOException; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Tests for {@link ListMetadataCommand} + * + * @author Stephane Nicoll + */ +public class ListMetadataCommandTests extends AbstractHttpClientMockTests { + + private final ListMetadataCommand command = new ListMetadataCommand(httpClient); + + @Test + public void listMetadata() throws IOException { + mockSuccessfulMetadataGet(); + String content = command.generateReport("http://localhost"); + + assertTrue(content.contains("aop - AOP")); + assertTrue(content.contains("security - Security: Security description")); + assertTrue(content.contains("type: maven-project")); + assertTrue(content.contains("packaging: jar")); + } + +} diff --git a/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/ProjectGenerationRequestTests.java b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/ProjectGenerationRequestTests.java new file mode 100644 index 0000000000..9c8f9e625a --- /dev/null +++ b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/init/ProjectGenerationRequestTests.java @@ -0,0 +1,194 @@ +/* + * 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 org.springframework.boot.cli.command.init; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.Map; + +import org.json.JSONObject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.StreamUtils; + +import static org.junit.Assert.*; + +/** + * Tests for {@link ProjectGenerationRequest} + * + * @author Stephane Nicoll + */ +public class ProjectGenerationRequestTests { + + public static final Map EMPTY_TAGS = Collections.emptyMap(); + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final ProjectGenerationRequest request = new ProjectGenerationRequest(); + + @Test + public void defaultSettings() { + assertEquals(createDefaultUrl("?type=test-type"), request.generateUrl(createDefaultMetadata())); + } + + @Test + public void customServer() throws URISyntaxException { + String customServerUrl = "http://foo:8080/initializr"; + request.setServiceUrl(customServerUrl); + request.getDependencies().add("security"); + assertEquals(new URI(customServerUrl + "/starter.zip?style=security&type=test-type"), + request.generateUrl(createDefaultMetadata())); + } + + @Test + public void customBootVersion() { + request.setBootVersion("1.2.0.RELEASE"); + assertEquals(createDefaultUrl("?bootVersion=1.2.0.RELEASE&type=test-type"), + request.generateUrl(createDefaultMetadata())); + } + + @Test + public void singleDependency() { + request.getDependencies().add("web"); + assertEquals(createDefaultUrl("?style=web&type=test-type"), + request.generateUrl(createDefaultMetadata())); + } + + @Test + public void multipleDependencies() { + request.getDependencies().add("web"); + request.getDependencies().add("data-jpa"); + assertEquals(createDefaultUrl("?style=web&style=data-jpa&type=test-type"), + request.generateUrl(createDefaultMetadata())); + } + + @Test + public void customJavaVersion() { + request.setJavaVersion("1.8"); + assertEquals(createDefaultUrl("?javaVersion=1.8&type=test-type"), + request.generateUrl(createDefaultMetadata())); + } + + @Test + public void customPackaging() { + request.setPackaging("war"); + assertEquals(createDefaultUrl("?packaging=war&type=test-type"), + request.generateUrl(createDefaultMetadata())); + } + + @Test + public void customType() throws URISyntaxException { + ProjectType projectType = new ProjectType("custom", "Custom Type", "/foo", true, EMPTY_TAGS); + InitializrServiceMetadata metadata = new InitializrServiceMetadata(projectType); + + request.setType("custom"); + request.getDependencies().add("data-rest"); + assertEquals(new URI(ProjectGenerationRequest.DEFAULT_SERVICE_URL + "/foo?style=data-rest&type=custom"), + request.generateUrl(metadata)); + } + + @Test + public void buildNoMatch() { + InitializrServiceMetadata metadata = readMetadata(); + setBuildAndFormat("does-not-exist", null); + + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage("does-not-exist"); + request.generateUrl(metadata); + } + + @Test + public void buildMultipleMatch() { + InitializrServiceMetadata metadata = readMetadata("types-conflict"); + setBuildAndFormat("gradle", null); + + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage("gradle-project"); + thrown.expectMessage("gradle-project-2"); + request.generateUrl(metadata); + } + + @Test + public void buildOneMatch() { + InitializrServiceMetadata metadata = readMetadata(); + setBuildAndFormat("gradle", null); + + assertEquals(createDefaultUrl("?type=gradle-project"), request.generateUrl(metadata)); + } + + @Test + public void invalidType() throws URISyntaxException { + request.setType("does-not-exist"); + + thrown.expect(ProjectGenerationException.class); + request.generateUrl(createDefaultMetadata()); + } + + @Test + public void noTypeAndNoDefault() throws URISyntaxException { + + thrown.expect(ProjectGenerationException.class); + thrown.expectMessage("no default is defined"); + request.generateUrl(readMetadata("types-conflict")); + } + + + private static URI createDefaultUrl(String param) { + try { + return new URI(ProjectGenerationRequest.DEFAULT_SERVICE_URL + "/starter.zip" + param); + } + catch (URISyntaxException e) { + throw new IllegalStateException(e); + } + } + + public void setBuildAndFormat(String build, String format) { + request.setBuild(build != null ? build : "maven"); + request.setFormat(format != null ? format : "project"); + request.setDetectType(true); + } + + private static InitializrServiceMetadata createDefaultMetadata() { + ProjectType projectType = new ProjectType("test-type", "The test type", "/starter.zip", true, EMPTY_TAGS); + return new InitializrServiceMetadata(projectType); + } + + private static InitializrServiceMetadata readMetadata() { + return readMetadata("1.1.0"); + } + + private static InitializrServiceMetadata readMetadata(String version) { + try { + Resource resource = new ClassPathResource("metadata/service-metadata-" + version + ".json"); + String content = StreamUtils.copyToString(resource.getInputStream(), Charset.forName("UTF-8")); + JSONObject json = new JSONObject(content); + return new InitializrServiceMetadata(json); + } + catch (IOException e) { + throw new IllegalStateException("Failed to read metadata", e); + } + } + +} diff --git a/spring-boot-cli/src/test/resources/metadata/service-metadata-1.0.0.json b/spring-boot-cli/src/test/resources/metadata/service-metadata-1.0.0.json new file mode 100644 index 0000000000..8f53480af1 --- /dev/null +++ b/spring-boot-cli/src/test/resources/metadata/service-metadata-1.0.0.json @@ -0,0 +1,134 @@ +{"dependencies": [ + { + "name": "Core", + "content": [ + { + "name": "Security", + "id": "security", + "description": "Security description" + }, + { + "name": "AOP", + "id": "aop" + } + ] + }, + { + "name": "Data", + "content": [ + { + "name": "JDBC", + "id": "jdbc" + }, + { + "name": "JPA", + "id": "data-jpa" + }, + { + "name": "MongoDB", + "id": "data-mongodb" + } + ] + } +], "types": [ + { + "name": "Maven POM", + "id": "maven-build", + "action": "/pom.xml", + "default": false + }, + { + "name": "Maven Project", + "id": "maven-project", + "action": "/starter.zip", + "default": true + }, + { + "name": "Gradle Config", + "id": "gradle-build", + "action": "/build.gradle", + "default": false + }, + { + "name": "Gradle Project", + "id": "gradle-project", + "action": "/starter.zip", + "default": false + } +], "packagings": [ + { + "name": "Jar", + "id": "jar", + "default": true + }, + { + "name": "War", + "id": "war", + "default": false + } +], "javaVersions": [ + { + "name": "1.6", + "id": "1.6", + "default": false + }, + { + "name": "1.7", + "id": "1.7", + "default": true + }, + { + "name": "1.8", + "id": "1.8", + "default": false + } +], "languages": [ + { + "name": "Groovy", + "id": "groovy", + "default": false + }, + { + "name": "Java", + "id": "java", + "default": true + } +], "bootVersions": [ + { + "name": "1.2.0 M2", + "id": "1.2.0.M2", + "default": false + }, + { + "name": "1.2.0 (SNAPSHOT)", + "id": "1.2.0.BUILD-SNAPSHOT", + "default": false + }, + { + "name": "1.1.8", + "id": "1.1.8.RELEASE", + "default": true + }, + { + "name": "1.1.8 (SNAPSHOT)", + "id": "1.1.8.BUILD-SNAPSHOT", + "default": false + }, + { + "name": "1.0.2", + "id": "1.0.2.RELEASE", + "default": false + } +], "defaults": { + "groupId": "org.test", + "artifactId": "demo", + "version": "0.0.1-SNAPSHOT", + "name": "demo", + "description": "Demo project for Spring Boot", + "packageName": "demo", + "type": "maven-project", + "packaging": "jar", + "javaVersion": "1.7", + "language": "java", + "bootVersion": "1.1.8.RELEASE" +}} \ No newline at end of file diff --git a/spring-boot-cli/src/test/resources/metadata/service-metadata-1.1.0.json b/spring-boot-cli/src/test/resources/metadata/service-metadata-1.1.0.json new file mode 100644 index 0000000000..e76a7f0f39 --- /dev/null +++ b/spring-boot-cli/src/test/resources/metadata/service-metadata-1.1.0.json @@ -0,0 +1,150 @@ +{"dependencies": [ + { + "name": "Core", + "content": [ + { + "name": "Security", + "id": "security", + "description": "Security description" + }, + { + "name": "AOP", + "id": "aop" + } + ] + }, + { + "name": "Data", + "content": [ + { + "name": "JDBC", + "id": "jdbc" + }, + { + "name": "JPA", + "id": "data-jpa" + }, + { + "name": "MongoDB", + "id": "data-mongodb" + } + ] + } +], "types": [ + { + "name": "Maven POM", + "id": "maven-build", + "action": "/pom.xml", + "tags": { + "build": "maven", + "format": "build" + }, + "default": false + }, + { + "name": "Maven Project", + "id": "maven-project", + "action": "/starter.zip", + "tags": { + "build": "maven", + "format": "project" + }, + "default": true + }, + { + "name": "Gradle Config", + "id": "gradle-build", + "action": "/build.gradle", + "tags": { + "build": "gradle", + "format": "build" + }, + "default": false + }, + { + "name": "Gradle Project", + "id": "gradle-project", + "action": "/starter.zip", + "tags": { + "build": "gradle", + "format": "project" + }, + "default": false + } +], "packagings": [ + { + "name": "Jar", + "id": "jar", + "default": true + }, + { + "name": "War", + "id": "war", + "default": false + } +], "javaVersions": [ + { + "name": "1.6", + "id": "1.6", + "default": false + }, + { + "name": "1.7", + "id": "1.7", + "default": true + }, + { + "name": "1.8", + "id": "1.8", + "default": false + } +], "languages": [ + { + "name": "Groovy", + "id": "groovy", + "default": false + }, + { + "name": "Java", + "id": "java", + "default": true + } +], "bootVersions": [ + { + "name": "1.2.0 M2", + "id": "1.2.0.M2", + "default": false + }, + { + "name": "1.2.0 (SNAPSHOT)", + "id": "1.2.0.BUILD-SNAPSHOT", + "default": false + }, + { + "name": "1.1.8", + "id": "1.1.8.RELEASE", + "default": true + }, + { + "name": "1.1.8 (SNAPSHOT)", + "id": "1.1.8.BUILD-SNAPSHOT", + "default": false + }, + { + "name": "1.0.2", + "id": "1.0.2.RELEASE", + "default": false + } +], "defaults": { + "groupId": "org.test", + "artifactId": "demo", + "version": "0.0.1-SNAPSHOT", + "name": "demo", + "description": "Demo project for Spring Boot", + "packageName": "demo", + "type": "maven-project", + "packaging": "jar", + "javaVersion": "1.7", + "language": "java", + "bootVersion": "1.1.8.RELEASE" +}} \ No newline at end of file diff --git a/spring-boot-cli/src/test/resources/metadata/service-metadata-types-conflict.json b/spring-boot-cli/src/test/resources/metadata/service-metadata-types-conflict.json new file mode 100644 index 0000000000..c9f593a806 --- /dev/null +++ b/spring-boot-cli/src/test/resources/metadata/service-metadata-types-conflict.json @@ -0,0 +1,169 @@ +{"dependencies": [ + { + "name": "Core", + "content": [ + { + "name": "Security", + "id": "security", + "description": "Security description" + }, + { + "name": "AOP", + "id": "aop" + } + ] + }, + { + "name": "Data", + "content": [ + { + "name": "JDBC", + "id": "jdbc" + }, + { + "name": "JPA", + "id": "data-jpa" + }, + { + "name": "MongoDB", + "id": "data-mongodb" + } + ] + } +], "types": [ + { + "name": "Maven POM", + "id": "maven-build", + "action": "/pom.xml", + "tags": { + "build": "maven", + "format": "build" + }, + "default": false + }, + { + "name": "Maven Project", + "id": "maven-project", + "action": "/starter.zip", + "tags": { + "build": "maven", + "format": "project" + }, + "default": false + }, + { + "name": "Another Maven Project", + "id": "maven-project-2", + "action": "/starter.zip", + "tags": { + "build": "maven", + "format": "project" + }, + "default": false + }, + { + "name": "Gradle Config", + "id": "gradle-build", + "action": "/build.gradle", + "tags": { + "build": "gradle", + "format": "build" + }, + "default": false + }, + { + "name": "Gradle Project", + "id": "gradle-project", + "action": "/starter.zip", + "tags": { + "build": "gradle", + "format": "project" + }, + "default": false + }, + { + "name": "Another gradle Project", + "id": "gradle-project-2", + "action": "/starter.zip", + "tags": { + "build": "gradle", + "format": "project" + }, + "default": false + } +], "packagings": [ + { + "name": "Jar", + "id": "jar", + "default": true + }, + { + "name": "War", + "id": "war", + "default": false + } +], "javaVersions": [ + { + "name": "1.6", + "id": "1.6", + "default": false + }, + { + "name": "1.7", + "id": "1.7", + "default": true + }, + { + "name": "1.8", + "id": "1.8", + "default": false + } +], "languages": [ + { + "name": "Groovy", + "id": "groovy", + "default": false + }, + { + "name": "Java", + "id": "java", + "default": true + } +], "bootVersions": [ + { + "name": "1.2.0 M2", + "id": "1.2.0.M2", + "default": false + }, + { + "name": "1.2.0 (SNAPSHOT)", + "id": "1.2.0.BUILD-SNAPSHOT", + "default": false + }, + { + "name": "1.1.8", + "id": "1.1.8.RELEASE", + "default": true + }, + { + "name": "1.1.8 (SNAPSHOT)", + "id": "1.1.8.BUILD-SNAPSHOT", + "default": false + }, + { + "name": "1.0.2", + "id": "1.0.2.RELEASE", + "default": false + } +], "defaults": { + "groupId": "org.test", + "artifactId": "demo", + "version": "0.0.1-SNAPSHOT", + "name": "demo", + "description": "Demo project for Spring Boot", + "packageName": "demo", + "packaging": "jar", + "javaVersion": "1.7", + "language": "java", + "bootVersion": "1.1.8.RELEASE" +}} \ No newline at end of file diff --git a/spring-boot-dependencies/pom.xml b/spring-boot-dependencies/pom.xml index bf807de5ff..7c0190e0eb 100644 --- a/spring-boot-dependencies/pom.xml +++ b/spring-boot-dependencies/pom.xml @@ -91,6 +91,7 @@ 2.13 2.4 1.2.2 + 20140107 0.9.1 1.2 4.11 @@ -987,6 +988,11 @@ jdom2 ${jdom2.version} + + org.json + json + ${json.version} + org.liquibase liquibase-core