diff --git a/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/ComposeProjectContributor.java b/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/ComposeProjectContributor.java new file mode 100644 index 00000000..16f728b6 --- /dev/null +++ b/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/ComposeProjectContributor.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.spring.container.docker.compose; + +import java.io.IOException; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; + +import io.spring.initializr.generator.container.docker.compose.ComposeFile; +import io.spring.initializr.generator.container.docker.compose.ComposeFileWriter; +import io.spring.initializr.generator.io.IndentingWriter; +import io.spring.initializr.generator.io.IndentingWriterFactory; +import io.spring.initializr.generator.project.contributor.ProjectContributor; + +/** + * A {@link ProjectContributor} that creates a 'compose.yaml' file through a + * {@link ComposeFile}. + * + * @author Moritz Halbritter + * @author Stephane Nicoll + */ +public class ComposeProjectContributor implements ProjectContributor { + + private final ComposeFile composeFile; + + private final IndentingWriterFactory indentingWriterFactory; + + private final ComposeFileWriter composeFileWriter; + + public ComposeProjectContributor(ComposeFile composeFile, IndentingWriterFactory indentingWriterFactory) { + this.composeFile = composeFile; + this.indentingWriterFactory = indentingWriterFactory; + this.composeFileWriter = new ComposeFileWriter(); + } + + @Override + public void contribute(Path projectRoot) throws IOException { + Path file = Files.createFile(projectRoot.resolve("compose.yaml")); + writeComposeFile(Files.newBufferedWriter(file)); + } + + void writeComposeFile(Writer out) throws IOException { + try (IndentingWriter writer = this.indentingWriterFactory.createIndentingWriter("yaml", out)) { + this.composeFileWriter.writeTo(writer, this.composeFile); + } + } + +} diff --git a/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/DockerComposeHelpDocumentCustomizer.java b/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/DockerComposeHelpDocumentCustomizer.java new file mode 100644 index 00000000..9a2a044b --- /dev/null +++ b/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/DockerComposeHelpDocumentCustomizer.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.spring.container.docker.compose; + +import java.util.HashMap; +import java.util.Map; + +import io.spring.initializr.generator.container.docker.compose.ComposeFile; +import io.spring.initializr.generator.container.docker.compose.ComposeService; +import io.spring.initializr.generator.spring.container.docker.compose.Markdown.MarkdownTable; +import io.spring.initializr.generator.spring.documentation.HelpDocument; +import io.spring.initializr.generator.spring.documentation.HelpDocumentCustomizer; + +/** + * A {@link HelpDocumentCustomizer} that provide additional information about the + * {@link ComposeService compose services} that are defined for the project. + * + * @author Moritz Halbritter + */ +public class DockerComposeHelpDocumentCustomizer implements HelpDocumentCustomizer { + + private final ComposeFile composeFile; + + public DockerComposeHelpDocumentCustomizer(ComposeFile composeFile) { + this.composeFile = composeFile; + } + + @Override + public void customize(HelpDocument document) { + Map model = new HashMap<>(); + if (this.composeFile.services().isEmpty()) { + model.put("serviceTable", null); + document.getWarnings() + .addItem( + "No Docker Compose services found. As of now, the application won't start! Please add at least one service to the `compose.yaml` file."); + } + else { + MarkdownTable serviceTable = Markdown.table("Service name", "Image", "Tag", "Website"); + this.composeFile.services() + .values() + .forEach((service) -> serviceTable.addRow(service.getName(), Markdown.code(service.getImage()), + Markdown.code(service.getImageTag()), Markdown.link("Website", service.getImageWebsite()))); + model.put("serviceTable", serviceTable.toMarkdown()); + } + document.addSection("documentation/docker-compose", model); + } + +} diff --git a/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/Markdown.java b/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/Markdown.java new file mode 100644 index 00000000..6100c0d7 --- /dev/null +++ b/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/Markdown.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.spring.container.docker.compose; + +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class for Markdown. + * + * @author Moritz Halbritter + */ +final class Markdown { + + private Markdown() { + // Static class + } + + /** + * Formats the given string as code. + * @param code the input string + * @return string formatted as code + */ + static String code(String code) { + return "`%s`".formatted(code); + } + + /** + * Creates a Markdown link. + * @param text text of the link + * @param url url of the link + * @return the formatted link in Markdown + */ + static String link(String text, String url) { + return "[%s](%s)".formatted(text, url); + } + + /** + * Creates a Markdown table. + * @param headerCaptions captions of the header + * @return the Markdown table + */ + static MarkdownTable table(String... headerCaptions) { + return new MarkdownTable(headerCaptions); + } + + /** + * A Markdown table. + *

+ * The formatted table is pretty-printed, all the columns are padded with spaces to + * have a consistent look. + * + * @author Moritz Halbritter + */ + static class MarkdownTable { + + private final List headerCaptions; + + private final List> rows; + + /** + * Creates a new table with the given header captions. + * @param headerCaptions the header captions + */ + MarkdownTable(String... headerCaptions) { + this.headerCaptions = List.of(headerCaptions); + this.rows = new ArrayList<>(); + } + + /** + * Adds a new row with the given cells. + * @param cells the cells to add + * @throws IllegalArgumentException if the cell size doesn't match the number of + * header captions + */ + void addRow(String... cells) { + if (cells.length != this.headerCaptions.size()) { + throw new IllegalArgumentException( + "Expected %d cells, got %d".formatted(this.headerCaptions.size(), cells.length)); + } + this.rows.add(List.of(cells)); + } + + /** + * Formats the whole table as Markdown. + * @return the table formatted as Markdown. + */ + String toMarkdown() { + int[] columnMaxLengths = calculateMaxColumnLengths(); + StringBuilder result = new StringBuilder(); + writeHeader(result, columnMaxLengths); + writeHeaderSeparator(result, columnMaxLengths); + writeRows(result, columnMaxLengths); + return result.toString(); + } + + private void writeHeader(StringBuilder result, int[] columnMaxLengths) { + for (int i = 0; i < this.headerCaptions.size(); i++) { + result.append((i > 0) ? " " : "| ") + .append(pad(this.headerCaptions.get(i), columnMaxLengths[i])) + .append(" |"); + } + result.append(System.lineSeparator()); + } + + private void writeHeaderSeparator(StringBuilder result, int[] columnMaxLengths) { + for (int i = 0; i < this.headerCaptions.size(); i++) { + result.append((i > 0) ? " " : "| ").append("-".repeat(columnMaxLengths[i])).append(" |"); + } + result.append(System.lineSeparator()); + } + + private void writeRows(StringBuilder result, int[] columnMaxLengths) { + for (List row : this.rows) { + for (int i = 0; i < row.size(); i++) { + result.append((i > 0) ? " " : "| ").append(pad(row.get(i), columnMaxLengths[i])).append(" |"); + } + result.append(System.lineSeparator()); + } + } + + private int[] calculateMaxColumnLengths() { + int[] columnMaxLengths = new int[this.headerCaptions.size()]; + for (int i = 0; i < this.headerCaptions.size(); i++) { + columnMaxLengths[i] = this.headerCaptions.get(i).length(); + } + for (List row : this.rows) { + for (int i = 0; i < row.size(); i++) { + String cell = row.get(i); + if (cell.length() > columnMaxLengths[i]) { + columnMaxLengths[i] = cell.length(); + } + } + } + return columnMaxLengths; + } + + private String pad(String input, int length) { + return input + " ".repeat(length - input.length()); + } + + } + +} diff --git a/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/package-info.java b/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/package-info.java new file mode 100644 index 00000000..5ca62430 --- /dev/null +++ b/initializr-generator-spring/src/main/java/io/spring/initializr/generator/spring/container/docker/compose/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 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 + * + * https://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. + */ + +/** + * Support for Docker Compose. + */ +package io.spring.initializr.generator.spring.container.docker.compose; diff --git a/initializr-generator-spring/src/main/resources/templates/documentation/docker-compose.mustache b/initializr-generator-spring/src/main/resources/templates/documentation/docker-compose.mustache new file mode 100644 index 00000000..5833adcb --- /dev/null +++ b/initializr-generator-spring/src/main/resources/templates/documentation/docker-compose.mustache @@ -0,0 +1,16 @@ +### Docker Compose support + +This project contains a Docker Compose file named `compose.yaml`. + +{{#serviceTable}} +In this file, the following services have been defined: + +{{serviceTable}} + +Please review the tags of the used images and set them to the same as you're running in production. +{{/serviceTable}} +{{^serviceTable}} +However, no services were found. As of now, the application won't start! + +Please make sure to add at least one service in the `compose.yaml` file. +{{/serviceTable}} diff --git a/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/ComposeProjectContributorTests.java b/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/ComposeProjectContributorTests.java new file mode 100644 index 00000000..c0fff691 --- /dev/null +++ b/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/ComposeProjectContributorTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.spring.container.docker.compose; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Path; + +import io.spring.initializr.generator.container.docker.compose.ComposeFile; +import io.spring.initializr.generator.io.IndentingWriterFactory; +import io.spring.initializr.generator.io.SimpleIndentStrategy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ComposeProjectContributor}. + * + * @author Moritz Halbritter + * @author Stephane Nicoll + */ +class ComposeProjectContributorTests { + + @Test + void composeFileIsContributedInProjectStructure(@TempDir Path projectDir) throws IOException { + ComposeFile compose = new ComposeFile(); + compose.services().add("test", (service) -> service.image("my-image:1.2.3")); + new ComposeProjectContributor(compose, IndentingWriterFactory.withDefaultSettings()).contribute(projectDir); + Path composeFile = projectDir.resolve("compose.yaml"); + assertThat(composeFile).isRegularFile(); + } + + @Test + void composeFileIsContributedUsingYamlContentId() throws IOException { + IndentingWriterFactory indentingWriterFactory = IndentingWriterFactory.create(new SimpleIndentStrategy(" "), + (factory) -> factory.indentingStrategy("yaml", new SimpleIndentStrategy("\t"))); + ComposeFile composeFile = new ComposeFile(); + composeFile.services() + .add("test", (service) -> service.imageAndTag("image:1.3.3").environment("a", "aa").environment("b", "bb")); + assertThat(generateComposeFile(composeFile, indentingWriterFactory)).isEqualToIgnoringNewLines(""" + services: + test: + image: 'image:1.3.3' + environment: + - 'a=aa' + - 'b=bb' + """); + + } + + private String generateComposeFile(ComposeFile composeFile, IndentingWriterFactory indentingWriterFactory) + throws IOException { + StringWriter writer = new StringWriter(); + new ComposeProjectContributor(composeFile, indentingWriterFactory).writeComposeFile(writer); + return writer.toString(); + } + +} diff --git a/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/DockerComposeHelpDocumentCustomizerTests.java b/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/DockerComposeHelpDocumentCustomizerTests.java new file mode 100644 index 00000000..401e68d1 --- /dev/null +++ b/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/DockerComposeHelpDocumentCustomizerTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.spring.container.docker.compose; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +import io.spring.initializr.generator.container.docker.compose.ComposeFile; +import io.spring.initializr.generator.io.template.MustacheTemplateRenderer; +import io.spring.initializr.generator.io.text.MustacheSection; +import io.spring.initializr.generator.io.text.Section; +import io.spring.initializr.generator.spring.documentation.HelpDocument; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DockerComposeHelpDocumentCustomizer}. + * + * @author Moritz Halbritter + */ +class DockerComposeHelpDocumentCustomizerTests { + + private DockerComposeHelpDocumentCustomizer customizer; + + private ComposeFile dockerComposeFile; + + @BeforeEach + void setUp() { + this.dockerComposeFile = new ComposeFile(); + this.customizer = new DockerComposeHelpDocumentCustomizer(this.dockerComposeFile); + } + + @Test + void addsDockerComposeSection() throws IOException { + this.dockerComposeFile.services() + .add("test", (service) -> service.imageAndTag("image-1:1.2.3").imageWebsite("https:/example.com/image-1")); + HelpDocument helpDocument = helpDocument(); + this.customizer.customize(helpDocument); + assertThat(helpDocument.getSections()).hasSize(1); + Section section = helpDocument.getSections().get(0); + assertThat(section).isInstanceOf(MustacheSection.class); + StringWriter stringWriter = new StringWriter(); + helpDocument.write(new PrintWriter(stringWriter)); + assertThat(stringWriter.toString()).isEqualToIgnoringNewLines(""" + ### Docker Compose support + + This project contains a Docker Compose file named `compose.yaml`. + + In this file, the following services have been defined: + + | Service name | Image | Tag | Website | + | ------------ | --------- | ------- | ------------------------------------- | + | test | `image-1` | `1.2.3` | [Website](https:/example.com/image-1) | + + + Please review the tags of the used images and set them to the same as you're running in production."""); + } + + @Test + void addsWarningIfNoServicesAreDefined() throws IOException { + HelpDocument helpDocument = helpDocument(); + this.customizer.customize(helpDocument); + assertThat(helpDocument.getWarnings().getItems()).containsExactly( + "No Docker Compose services found. As of now, the application won't start! Please add at least one service to the `compose.yaml` file."); + StringWriter stringWriter = new StringWriter(); + helpDocument.write(new PrintWriter(stringWriter)); + assertThat(stringWriter.toString()).isEqualToIgnoringNewLines( + """ + # Read Me First + The following was discovered as part of building this project: + + * No Docker Compose services found. As of now, the application won't start! Please add at least one service to the `compose.yaml` file. + + ### Docker Compose support + + This project contains a Docker Compose file named `compose.yaml`. + + However, no services were found. As of now, the application won't start! + + Please make sure to add at least one service in the `compose.yaml` file."""); + } + + private static HelpDocument helpDocument() { + return new HelpDocument(new MustacheTemplateRenderer("/templates")); + } + +} diff --git a/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/MarkdownTests.java b/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/MarkdownTests.java new file mode 100644 index 00000000..899323eb --- /dev/null +++ b/initializr-generator-spring/src/test/java/io/spring/initializr/generator/spring/container/docker/compose/MarkdownTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.spring.container.docker.compose; + +import io.spring.initializr.generator.spring.container.docker.compose.Markdown.MarkdownTable; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link Markdown}. + * + * @author Moritz Halbritter + */ +class MarkdownTests { + + @Test + void shouldFormatCode() { + String code = Markdown.code("c = a + b"); + assertThat(code).isEqualTo("`c = a + b`"); + } + + @Test + void shouldFormatLink() { + String link = Markdown.link("Spring Website", "https://spring.io/"); + assertThat(link).isEqualTo("[Spring Website](https://spring.io/)"); + } + + @Test + void shouldFormatCorrectly() { + MarkdownTable table = new MarkdownTable("a", "b1", "c22", "d333"); + table.addRow("0", "1", "2", "3"); + table.addRow("4", "5", "6", "7"); + String markdown = table.toMarkdown(); + assertThat(markdown).isEqualToIgnoringNewLines(""" + | a | b1 | c22 | d333 | + | - | -- | --- | ---- | + | 0 | 1 | 2 | 3 | + | 4 | 5 | 6 | 7 | + """); + } + + @Test + void rowIsBiggerThanHeading() { + MarkdownTable table = new MarkdownTable("a", "b", "c", "d"); + table.addRow("0.0", "1.1", "2.2", "3.3"); + table.addRow("4.4", "5.5", "6.6", "7.7"); + String markdown = table.toMarkdown(); + assertThat(markdown).isEqualToIgnoringNewLines(""" + | a | b | c | d | + | --- | --- | --- | --- | + | 0.0 | 1.1 | 2.2 | 3.3 | + | 4.4 | 5.5 | 6.6 | 7.7 | + """); + } + + @Test + void throwsIfCellsDifferFromHeader() { + MarkdownTable table = new MarkdownTable("a", "b", "c", "d"); + assertThatThrownBy(() -> table.addRow("1")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Expected 4 cells, got 1"); + } + +} diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeFile.java b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeFile.java new file mode 100644 index 00000000..959428f3 --- /dev/null +++ b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeFile.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.container.docker.compose; + +/** + * Model for a Docker Compose file. + * + * @author Moritz Halbritter + * @author Stephane Nicoll + */ +public class ComposeFile { + + private final ComposeServiceContainer services = new ComposeServiceContainer(); + + /** + * Return the {@linkplain ComposeServiceContainer service container} to use to + * configure services. + * @return the {@link ComposeServiceContainer} + */ + public ComposeServiceContainer services() { + return this.services; + } + +} diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeFileWriter.java b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeFileWriter.java new file mode 100644 index 00000000..944e2278 --- /dev/null +++ b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeFileWriter.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.container.docker.compose; + +import java.util.Comparator; +import java.util.Map; +import java.util.Set; + +import io.spring.initializr.generator.io.IndentingWriter; + +/** + * A {@link ComposeFile} writer for {@code compose.yaml}. + * + * @author Stephane Nicoll + * @author Moritz Halbritter + */ +public class ComposeFileWriter { + + /** + * Write a {@linkplain ComposeFile compose.yaml} using the specified + * {@linkplain IndentingWriter writer}. + * @param writer the writer to use + * @param compose the compose file to write + */ + public void writeTo(IndentingWriter writer, ComposeFile compose) { + writer.println("services:"); + compose.services() + .values() + .sorted(Comparator.comparing(ComposeService::getName)) + .forEach((service) -> writeService(writer, service)); + } + + private void writeService(IndentingWriter writer, ComposeService service) { + writer.indented(() -> { + writer.println(service.getName() + ":"); + writer.indented(() -> { + writer.println("image: '%s:%s'".formatted(service.getImage(), service.getImageTag())); + writerServiceEnvironment(writer, service.getEnvironment()); + writerServicePorts(writer, service.getPorts()); + }); + }); + } + + private void writerServiceEnvironment(IndentingWriter writer, Map environment) { + if (environment.isEmpty()) { + return; + } + writer.println("environment:"); + writer.indented(() -> { + for (Map.Entry env : environment.entrySet()) { + writer.println("- '%s=%s'".formatted(env.getKey(), env.getValue())); + } + }); + } + + private void writerServicePorts(IndentingWriter writer, Set ports) { + if (ports.isEmpty()) { + return; + } + writer.println("ports:"); + writer.indented(() -> { + for (Integer port : ports) { + writer.println("- '%d'".formatted(port)); + } + }); + } + +} diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeService.java b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeService.java new file mode 100644 index 00000000..8d7b91f7 --- /dev/null +++ b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeService.java @@ -0,0 +1,151 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.container.docker.compose; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * A service to be declared in a Docker Compose file. + * + * @author Moritz Halbritter + * @author Stephane Nicoll + */ +public final class ComposeService { + + private final String name; + + private final String image; + + private final String imageTag; + + private final String imageWebsite; + + private final Map environment; + + private final Set ports; + + private ComposeService(Builder builder) { + this.name = builder.name; + this.image = builder.image; + this.imageTag = builder.imageTag; + this.imageWebsite = builder.imageWebsite; + this.environment = Collections.unmodifiableMap(new TreeMap<>(builder.environment)); + this.ports = Collections.unmodifiableSet(new TreeSet<>(builder.ports)); + } + + public String getName() { + return this.name; + } + + public String getImage() { + return this.image; + } + + public String getImageTag() { + return this.imageTag; + } + + public String getImageWebsite() { + return this.imageWebsite; + } + + public Map getEnvironment() { + return this.environment; + } + + public Set getPorts() { + return this.ports; + } + + /** + * Builder for {@link ComposeService}. + */ + public static class Builder { + + private final String name; + + private String image; + + private String imageTag = "latest"; + + private String imageWebsite; + + private final Map environment = new TreeMap<>(); + + private final Set ports = new TreeSet<>(); + + protected Builder(String name) { + this.name = name; + } + + public Builder imageAndTag(String imageAndTag) { + String[] split = imageAndTag.split(":", 2); + String tag = (split.length == 1) ? "latest" : split[1]; + return image(split[0]).imageTag(tag); + } + + public Builder image(String image) { + this.image = image; + return this; + } + + public Builder imageTag(String imageTag) { + this.imageTag = imageTag; + return this; + } + + public Builder imageWebsite(String imageWebsite) { + this.imageWebsite = imageWebsite; + return this; + } + + public Builder environment(String key, String value) { + this.environment.put(key, value); + return this; + } + + public Builder environment(Map environment) { + this.environment.putAll(environment); + return this; + } + + public Builder ports(Collection ports) { + this.ports.addAll(ports); + return this; + } + + public Builder ports(int... ports) { + return ports(Arrays.stream(ports).boxed().toList()); + } + + /** + * Builds the {@link ComposeService} instance. + * @return the built instance + */ + public ComposeService build() { + return new ComposeService(this); + } + + } + +} diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeServiceContainer.java b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeServiceContainer.java new file mode 100644 index 00000000..f1376613 --- /dev/null +++ b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/ComposeServiceContainer.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.container.docker.compose; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import io.spring.initializr.generator.container.docker.compose.ComposeService.Builder; + +/** + * A container for {@linkplain ComposeService Docker Compose services}. + * + * @author Stephane Nicoll + */ +public class ComposeServiceContainer { + + private final Map services = new LinkedHashMap<>(); + + /** + * Specify if this container is empty. + * @return {@code true} if no service is registered + */ + public boolean isEmpty() { + return this.services.isEmpty(); + } + + /** + * Specify if this container has a service customization with the specified + * {@code name}. + * @param name the name of a service + * @return {@code true} if a customization for a service with the specified + * {@code name} exists + */ + public boolean has(String name) { + return this.services.containsKey(name); + } + + /** + * Return the {@link ComposeService services} to customize. + * @return the compose services + */ + public Stream values() { + return this.services.values().stream().map(Builder::build); + } + + /** + * Add a {@link ComposeService} with the specified name and {@link Consumer} to + * customize the object. If the service has already been added, the consumer can be + * used to further tune the existing service configuration. + * @param name the name of the service + * @param service a {@link Consumer} to customize the {@link ComposeService} + */ + public void add(String name, Consumer service) { + service.accept(this.services.computeIfAbsent(name, Builder::new)); + } + + /** + * Remove the service with the specified {@code name}. + * @param name the name of the service + * @return {@code true} if such a service was registered, {@code false} otherwise + */ + public boolean remove(String name) { + return this.services.remove(name) != null; + } + +} diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/package-info.java b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/package-info.java new file mode 100644 index 00000000..45ebcd5c --- /dev/null +++ b/initializr-generator/src/main/java/io/spring/initializr/generator/container/docker/compose/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 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 + * + * https://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. + */ + +/** + * Docker Compose support. + */ +package io.spring.initializr.generator.container.docker.compose; diff --git a/initializr-generator/src/test/java/io/spring/initializr/generator/container/docker/compose/ComposeFileWriterTests.java b/initializr-generator/src/test/java/io/spring/initializr/generator/container/docker/compose/ComposeFileWriterTests.java new file mode 100644 index 00000000..04b3a01a --- /dev/null +++ b/initializr-generator/src/test/java/io/spring/initializr/generator/container/docker/compose/ComposeFileWriterTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.container.docker.compose; + +import java.io.StringWriter; +import java.util.function.Consumer; + +import io.spring.initializr.generator.container.docker.compose.ComposeService.Builder; +import io.spring.initializr.generator.io.IndentingWriter; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ComposeFile}. + * + * @author Moritz Halbritter + * @author Stephane Nicoll + */ +class ComposeFileWriterTests { + + @Test + void writeBasicServices() { + ComposeFile file = new ComposeFile(); + file.services().add("first", withSuffix(1)); + file.services().add("second", withSuffix(2)); + assertThat(write(file)).isEqualToIgnoringNewLines(""" + services: + first: + image: 'image-1:image-tag-1' + second: + image: 'image-2:image-tag-2' + """); + } + + @Test + void writeDetailedService() { + ComposeFile file = new ComposeFile(); + file.services() + .add("elasticsearch", + (builder) -> builder.image("elasticsearch") + .imageTag("8.6.1") + .imageWebsite("https://www.docker.elastic.co/r/elasticsearch") + .environment("ELASTIC_PASSWORD", "secret") + .environment("discovery.type", "single-node") + .ports(9200, 9300)); + assertThat(write(file)).isEqualToIgnoringNewLines(""" + services: + elasticsearch: + image: 'elasticsearch:8.6.1' + environment: + - 'ELASTIC_PASSWORD=secret' + - 'discovery.type=single-node' + ports: + - '9200' + - '9300' + """); + } + + @Test + void servicesAreOrderedByName() { + ComposeFile file = new ComposeFile(); + file.services().add("b", withSuffix(2)); + file.services().add("a", withSuffix(1)); + assertThat(write(file)).isEqualToIgnoringNewLines(""" + services: + a: + image: 'image-1:image-tag-1' + b: + image: 'image-2:image-tag-2' + """); + } + + private Consumer withSuffix(int suffix) { + return (builder) -> builder.image("image-" + suffix).imageTag("image-tag-" + suffix); + } + + private String write(ComposeFile file) { + StringWriter out = new StringWriter(); + IndentingWriter writer = new IndentingWriter(out, "\t"::repeat); + new ComposeFileWriter().writeTo(writer, file); + return out.toString(); + } + +} diff --git a/initializr-generator/src/test/java/io/spring/initializr/generator/container/docker/compose/ComposeServiceContainerTests.java b/initializr-generator/src/test/java/io/spring/initializr/generator/container/docker/compose/ComposeServiceContainerTests.java new file mode 100644 index 00000000..c8e51b08 --- /dev/null +++ b/initializr-generator/src/test/java/io/spring/initializr/generator/container/docker/compose/ComposeServiceContainerTests.java @@ -0,0 +1,182 @@ +/* + * Copyright 2012-2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.container.docker.compose; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link ComposeServiceContainer}. + * + * @author Stephane Nicoll + */ +class ComposeServiceContainerTests { + + @Test + void isEmptyWithEmptyContainer() { + ComposeServiceContainer container = new ComposeServiceContainer(); + assertThat(container.isEmpty()).isTrue(); + } + + @Test + void isEmptyWithService() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.image("my-image")); + assertThat(container.isEmpty()).isFalse(); + } + + @Test + void hasWithMatchingService() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.image("my-image")); + assertThat(container.has("test")).isTrue(); + } + + @Test + void hasWithNonMatchingName() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.image("my-image")); + assertThat(container.has("another")).isFalse(); + } + + @Test + void tagIsSetToLatestIfNotGiven() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.image("my-image")); + assertThat(container.values()).singleElement().satisfies((service) -> { + assertThat(service.getImage()).isEqualTo("my-image"); + assertThat(service.getImageTag()).isEqualTo("latest"); + }); + } + + @Test + void tagIsSetToLatestIfNotGivenInImageTag() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.imageAndTag("my-image")); + assertThat(container.values()).singleElement().satisfies((service) -> { + assertThat(service.getImage()).isEqualTo("my-image"); + assertThat(service.getImageTag()).isEqualTo("latest"); + }); + } + + @Test + void tagIsSetToGivenInImageTag() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.imageAndTag("my-image:1.2.3")); + assertThat(container.values()).singleElement().satisfies((service) -> { + assertThat(service.getImage()).isEqualTo("my-image"); + assertThat(service.getImageTag()).isEqualTo("1.2.3"); + }); + } + + @Test + void portsAreSorted() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.imageAndTag("my-image").ports(8080)); + container.add("test", (service) -> service.ports(7070)); + assertThat(container.values()).singleElement() + .satisfies((service) -> assertThat(service.getPorts()).containsExactly(7070, 8080)); + } + + @Test + void environmentKeysAreSorted() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.imageAndTag("my-image").environment("z", "zz")); + container.add("test", (service) -> service.environment("a", "aa")); + assertThat(container.values()).singleElement() + .satisfies((service) -> assertThat(service.getEnvironment()).containsExactly(entry("a", "aa"), + entry("z", "zz"))); + } + + @Test + void environmentIsMerged() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.imageAndTag("my-image").environment(Map.of("a", "aa", "z", "zz"))); + container.add("test", (service) -> service.environment(Map.of("a", "aaa", "b", "bb"))); + assertThat(container.values()).singleElement() + .satisfies((service) -> assertThat(service.getEnvironment()).containsExactly(entry("a", "aaa"), + entry("b", "bb"), entry("z", "zz"))); + } + + @Test + void customizeService() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> { + service.image("my-image"); + service.imageTag("my-image-tag"); + service.imageWebsite("https://example.com/my-image"); + service.environment("param", "value"); + service.ports(8080); + }); + assertThat(container.values()).singleElement().satisfies((service) -> { + assertThat(service.getName()).isEqualTo("test"); + assertThat(service.getImage()).isEqualTo("my-image"); + assertThat(service.getImageTag()).isEqualTo("my-image-tag"); + assertThat(service.getImageWebsite()).isEqualTo("https://example.com/my-image"); + assertThat(service.getEnvironment()).containsOnly(entry("param", "value")); + assertThat(service.getPorts()).containsOnly(8080); + }); + } + + @Test + void customizeTaskSeveralTimeReuseConfiguration() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> { + service.image("my-image"); + service.imageTag("my-image-tag"); + service.imageWebsite("https://example.com/my-image"); + service.environment("param", "value"); + service.ports(7070); + }); + container.add("test", (service) -> { + service.image("my-image"); + service.imageTag("my-image-tag"); + service.imageWebsite("https://example.com/my-image"); + service.environment("param", "value2"); + service.ports(8080); + }); + assertThat(container.values()).singleElement().satisfies((service) -> { + assertThat(service.getName()).isEqualTo("test"); + assertThat(service.getImage()).isEqualTo("my-image"); + assertThat(service.getImageTag()).isEqualTo("my-image-tag"); + assertThat(service.getImageWebsite()).isEqualTo("https://example.com/my-image"); + assertThat(service.getEnvironment()).containsOnly(entry("param", "value2")); + assertThat(service.getPorts()).containsOnly(7070, 8080); + }); + } + + @Test + void removeWithMatchingService() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.image("my-image")); + assertThat(container.remove("test")).isTrue(); + assertThat(container.isEmpty()).isTrue(); + } + + @Test + void removeWithNonMatchingName() { + ComposeServiceContainer container = new ComposeServiceContainer(); + container.add("test", (service) -> service.image("my-image")); + assertThat(container.remove("another")).isFalse(); + assertThat(container.isEmpty()).isFalse(); + } + +} diff --git a/initializr-web/src/main/java/io/spring/initializr/web/autoconfigure/InitializrAutoConfiguration.java b/initializr-web/src/main/java/io/spring/initializr/web/autoconfigure/InitializrAutoConfiguration.java index 7c46a38c..d45f637d 100644 --- a/initializr-web/src/main/java/io/spring/initializr/web/autoconfigure/InitializrAutoConfiguration.java +++ b/initializr-web/src/main/java/io/spring/initializr/web/autoconfigure/InitializrAutoConfiguration.java @@ -87,7 +87,8 @@ public class InitializrAutoConfiguration { @Bean @ConditionalOnMissingBean public IndentingWriterFactory indentingWriterFactory() { - return IndentingWriterFactory.create(new SimpleIndentStrategy("\t")); + return IndentingWriterFactory.create(new SimpleIndentStrategy("\t"), + (builder) -> builder.indentingStrategy("yaml", new SimpleIndentStrategy(" "))); } @Bean