From b1859ed471defd164155ee13f64eb0f0a9201714 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 28 Jul 2026 16:17:01 -0300 Subject: [PATCH] complement: persist openapi specification - allow to provide multiple files as input - support for copy to directory - refactor move code to OpenAPIGenerator (reuse code from maven/gradle) - ref #3977 --- docs/asciidoc/modules/openapi.adoc | 34 ++++--- .../java/io/jooby/gradle/OpenAPITask.java | 67 ++------------ .../main/java/io/jooby/maven/OpenAPIMojo.java | 56 ++--------- .../java/io/jooby/maven/OpenAPIMojoTest.java | 57 ------------ .../io/jooby/openapi/OpenAPIGenerator.java | 92 +++++++++++++++++-- .../src/test/java/issues/Issue3977.java | 82 +++++++++++++++++ 6 files changed, 203 insertions(+), 185 deletions(-) delete mode 100644 modules/jooby-maven-plugin/src/test/java/io/jooby/maven/OpenAPIMojoTest.java create mode 100644 modules/jooby-openapi/src/test/java/issues/Issue3977.java diff --git a/docs/asciidoc/modules/openapi.adoc b/docs/asciidoc/modules/openapi.adoc index 6e440e3ba4..ee08dafb74 100644 --- a/docs/asciidoc/modules/openapi.adoc +++ b/docs/asciidoc/modules/openapi.adoc @@ -108,6 +108,25 @@ To avoid this behaviour you can specify maven build phase which suits your needs | `${project.dir}` |Set base directory used it for loading openAPI template file name. +|`copyOpenApiSpecTo` +| +|Copy the generated OpenAPI spec to the given file in the project repository. It must be one or +more directories or file names. In case of directory both the `.json` and `.yaml` file are copy. +Example: + +.Maven: + + + ${project.basedir}/spec + ${project.basedir}/docs/openapi.yaml + + +.Gradle: + + { + copyOpenApiSpecTo = ["${project.basedir}/spec", "$projectDir/docs/openapi.yaml"] + } + |`excludes` | |Regular expression used to excludes route. Example: `/web`. @@ -128,21 +147,6 @@ To avoid this behaviour you can specify maven build phase which suits your needs |`openapi.yaml` |Set openAPI template file path. -|`copyOpenApiSpecTo` -| -|Copy the generated OpenAPI spec to the given file in the project repository. The format is -determined by the file extension: `.yaml`, `.yml` or `.json`. Example: - -.Maven: - - ${project.basedir}/docs/openapi.yaml - -.Gradle: - - { - copyOpenApiSpecTo = file("$projectDir/docs/openapi.yaml") - } - |=== === Usage diff --git a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java index 4f802a3143..e56b256373 100644 --- a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java +++ b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/OpenAPITask.java @@ -49,7 +49,7 @@ public class OpenAPITask extends BaseTask { private String javadoc; - private File copyOpenApiSpecTo; + private List copyOpenApiSpecTo; /** * Creates an OpenAPI task. @@ -89,6 +89,7 @@ public void generate() throws Throwable { tool.setClassLoader(classLoader); tool.setOutputDir(outputDir); tool.setSources(sources); + tool.setCopyOpenApiSpecTo(copyOpenApiSpecTo == null ? null : copyOpenApiSpecTo.stream().map(File::toPath).toList()); if (specVersion != null) { tool.setSpecVersion(specVersion); } @@ -101,48 +102,10 @@ public void generate() throws Throwable { OpenAPI result = tool.generate(mainClass); var adocPath = ofNullable(adoc).orElse(List.of()).stream().map(File::toPath).toList(); - var written = new ArrayList(); for (var format : OpenAPIGenerator.Format.values()) { - written.addAll(tool.export(result, format, Map.of("adoc", adocPath))); + tool.export(result, format, Map.of("adoc", adocPath)) + .forEach(output -> getLogger().info(" writing: " + output)); } - written.forEach(output -> getLogger().info(" writing: " + output)); - - if (copyOpenApiSpecTo != null) { - var destination = copyOpenApiSpecTo.toPath(); - copySpec(written, destination); - getLogger().info(" copying: " + destination); - } - } - - static void copySpec(List written, Path destination) throws IOException { - var format = specFormat(destination); - var source = - written.stream() - .filter(path -> path.getFileName().toString().endsWith("." + format.extension())) - .findFirst() - .orElseThrow( - () -> - new IOException( - String.format( - "OpenAPI %s output not found for copyOpenApiSpecTo: %s", - format.name(), destination))); - var parent = destination.getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); - } - - static OpenAPIGenerator.Format specFormat(Path destination) { - var name = destination.getFileName().toString().toLowerCase(Locale.ROOT); - if (name.endsWith(".json")) { - return OpenAPIGenerator.Format.JSON; - } - if (name.endsWith(".yaml") || name.endsWith(".yml")) { - return OpenAPIGenerator.Format.YAML; - } - throw new IllegalArgumentException( - "copyOpenApiSpecTo must end with .yaml, .yml or .json: " + destination); } /** @@ -289,32 +252,22 @@ public void setAdoc(List adoc) { } /** - * Copy the generated OpenAPI spec to the given file. The format is determined by the file - * extension: .yaml, .yml or .json. + * List directories or files where the openAPI spec must be copied. * - * @return Destination file. + * @return List directories or files where the openAPI spec must be copied. */ @Input @org.gradle.api.tasks.Optional - public @Nullable File getCopyOpenApiSpecTo() { + public @Nullable List getCopyOpenApiSpecTo() { return copyOpenApiSpecTo; } /** - * Copy the generated OpenAPI spec to the given file. The format is determined by the file - * extension: .yaml, .yml or .json. - * - *

Example: - * - *

{@code
-   * openAPI {
-   *   copyOpenApiSpecTo = file("$projectDir/docs/openapi.yaml")
-   * }
-   * }
+ * Copy open API files to either an output location or specific file. * - * @param copyOpenApiSpecTo Destination file. + * @param copyOpenApiSpecTo Output directory or file. */ - public void setCopyOpenApiSpecTo(@Nullable File copyOpenApiSpecTo) { + public void setCopyOpenApiSpecTo(@Nullable List copyOpenApiSpecTo) { this.copyOpenApiSpecTo = copyOpenApiSpecTo; } diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java index 2c4be14630..d5eadd175f 100644 --- a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java +++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/OpenAPIMojo.java @@ -10,14 +10,9 @@ import static org.apache.maven.plugins.annotations.ResolutionScope.COMPILE_PLUS_RUNTIME; import java.io.File; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -58,8 +53,7 @@ public class OpenAPIMojo extends BaseMojo { @Parameter private List adoc; - @Parameter(property = "openAPI.copyOpenApiSpecTo") - private File copyOpenApiSpecTo; + @Parameter private List copyOpenApiSpecTo; @Override protected void doExecute(List projects, String mainClass) throws Exception { @@ -83,6 +77,8 @@ protected void doExecute(List projects, String mainClass) throws E tool.setClassLoader(classLoader); tool.setOutputDir(outputDir); tool.setSources(sources); + tool.setCopyOpenApiSpecTo( + copyOpenApiSpecTo == null ? null : copyOpenApiSpecTo.stream().map(File::toPath).toList()); trim(includes).ifPresent(tool::setIncludes); trim(excludes).ifPresent(tool::setExcludes); if (javadoc != null && !javadoc.trim().isEmpty()) { @@ -92,48 +88,10 @@ protected void doExecute(List projects, String mainClass) throws E var result = tool.generate(mainClass); var adocPath = ofNullable(adoc).orElse(List.of()).stream().map(File::toPath).toList(); - var written = new ArrayList(); for (var format : OpenAPIGenerator.Format.values()) { - written.addAll(tool.export(result, format, Map.of("adoc", adocPath))); + tool.export(result, format, Map.of("adoc", adocPath)) + .forEach(output -> getLog().info(" writing: " + output)); } - written.forEach(output -> getLog().info(" writing: " + output)); - - if (copyOpenApiSpecTo != null) { - var destination = copyOpenApiSpecTo.toPath(); - copySpec(written, destination); - getLog().info(" copying: " + destination); - } - } - - public static void copySpec(List written, Path destination) throws IOException { - var format = specFormat(destination); - var source = - written.stream() - .filter(path -> path.getFileName().toString().endsWith("." + format.extension())) - .findFirst() - .orElseThrow( - () -> - new IOException( - String.format( - "OpenAPI %s output not found for copyOpenApiSpecTo: %s", - format.name(), destination))); - var parent = destination.getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); - } - - public static OpenAPIGenerator.Format specFormat(Path destination) { - var name = destination.getFileName().toString().toLowerCase(Locale.ROOT); - if (name.endsWith(".json")) { - return OpenAPIGenerator.Format.JSON; - } - if (name.endsWith(".yaml") || name.endsWith(".yml")) { - return OpenAPIGenerator.Format.YAML; - } - throw new IllegalArgumentException( - "copyOpenApiSpecTo must end with .yaml, .yml or .json: " + destination); } private Optional trim(String value) { @@ -239,7 +197,7 @@ public String getJavadoc() { * * @return Destination file. */ - public @Nullable File getCopyOpenApiSpecTo() { + public @Nullable List getCopyOpenApiSpecTo() { return copyOpenApiSpecTo; } @@ -255,7 +213,7 @@ public String getJavadoc() { * * @param copyOpenApiSpecTo Destination file. */ - public void setCopyOpenApiSpecTo(@Nullable File copyOpenApiSpecTo) { + public void setCopyOpenApiSpecTo(@Nullable List copyOpenApiSpecTo) { this.copyOpenApiSpecTo = copyOpenApiSpecTo; } } diff --git a/modules/jooby-maven-plugin/src/test/java/io/jooby/maven/OpenAPIMojoTest.java b/modules/jooby-maven-plugin/src/test/java/io/jooby/maven/OpenAPIMojoTest.java deleted file mode 100644 index e5c138a431..0000000000 --- a/modules/jooby-maven-plugin/src/test/java/io/jooby/maven/OpenAPIMojoTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.jooby.maven; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import io.jooby.openapi.OpenAPIGenerator; - -public class OpenAPIMojoTest { - - @Test - public void specFormat() { - assertEquals( - OpenAPIGenerator.Format.YAML, OpenAPIMojo.specFormat(Path.of("docs/openapi.yaml"))); - assertEquals( - OpenAPIGenerator.Format.YAML, OpenAPIMojo.specFormat(Path.of("docs/openapi.yml"))); - assertEquals( - OpenAPIGenerator.Format.JSON, OpenAPIMojo.specFormat(Path.of("docs/openapi.json"))); - assertThrows( - IllegalArgumentException.class, () -> OpenAPIMojo.specFormat(Path.of("docs/openapi.txt"))); - } - - @Test - public void copyYamlSpec(@TempDir Path tempDir) throws Exception { - var outputDir = tempDir.resolve("classes/myapp"); - Files.createDirectories(outputDir); - var source = outputDir.resolve("App.yaml"); - Files.writeString(source, "openapi: 3.0.1"); - - var destination = tempDir.resolve("docs/openapi.yml"); - OpenAPIMojo.copySpec(List.of(source), destination); - - assertTrue(Files.isRegularFile(destination)); - assertEquals(Files.readString(source), Files.readString(destination)); - } - - @Test - public void copyJsonSpec(@TempDir Path tempDir) throws Exception { - var outputDir = tempDir.resolve("classes/myapp"); - Files.createDirectories(outputDir); - var source = outputDir.resolve("App.json"); - Files.writeString(source, "{\"openapi\":\"3.0.1\"}"); - - var destination = tempDir.resolve("docs/openapi.json"); - OpenAPIMojo.copySpec(List.of(source), destination); - - assertTrue(Files.isRegularFile(destination)); - assertEquals(Files.readString(source), Files.readString(destination)); - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 103b1a7689..d6e26831f5 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -60,6 +60,11 @@ protected String toString( OpenAPIGenerator tool, OpenAPI result, Map options) { return tool.toYaml(result); } + + @Override + public List extension() { + return List.of("yaml", "yml"); + } }, ADOC { @@ -94,13 +99,36 @@ public List write(OpenAPIGenerator tool, OpenAPI result, Map extension() { + return List.of(name().toLowerCase()); + } + + public boolean matches(Path path) { + return extension().stream() + .anyMatch(ext -> path.getFileName().toString().endsWith("." + ext)); + } + + public static Format from(Path path) { + for (var format : values()) { + if (format.matches(path)) { + return format; + } + } + return null; } /** @@ -151,6 +179,8 @@ public List write(OpenAPIGenerator tool, OpenAPI result, Map copyOpenApiSpecTo; + /** Default constructor. */ public OpenAPIGenerator() {} @@ -174,7 +204,7 @@ public List export(OpenAPI openAPI, Format format, Map opt if (appname.endsWith("Kt")) { appname = appname.substring(0, appname.length() - 2); } - output = output.resolve(appname + "." + format.extension()); + output = output.resolve(appname + "." + format.extension().getFirst()); } else { throw new ClassCastException(openAPI.getClass() + " is not a " + OpenAPIExt.class); } @@ -182,10 +212,40 @@ public List export(OpenAPI openAPI, Format format, Map opt if (!Files.exists(output.getParent())) { Files.createDirectories(output.getParent()); } - var allOptions = new HashMap<>(options); - allOptions.put("output", output); - allOptions.put("outputDir", output.getParent()); - return format.write(this, openAPI, allOptions); + var outputs = new ArrayList(); + outputs.add(output); + if (format.isOpenApiSpec() && copyOpenApiSpecTo != null && !copyOpenApiSpecTo.isEmpty()) { + var defaultFileName = output.getFileName().toString(); + for (var copyTo : copyOpenApiSpecTo) { + if (format.matches(copyTo)) { + outputs.add(copyTo); + } else { + if (Format.from(copyTo) == null) { + // must be a directory + outputs.add(copyTo.resolve(defaultFileName)); + } + } + } + } + var result = new ArrayList(); + for (var outputPath : outputs) { + var outputDir = outputPath.getParent(); + if (!Files.exists(outputDir)) { + Files.createDirectories(outputDir); + } + var allOptions = new HashMap<>(options); + allOptions.put("output", outputPath); + allOptions.put("outputDir", outputDir); + result.addAll(format.write(this, openAPI, allOptions)); + } + return result; + } + + private Path resolveOutputPath(Path path, String defaultFileName) { + if (Files.isDirectory(path)) { + return path.resolve(defaultFileName); + } + return path; } /** @@ -553,6 +613,24 @@ public void setSpecVersion(String version) { } } + /** + * List directories or files where the openAPI spec must be copied. + * + * @return List directories or files where the openAPI spec must be copied. + */ + public List getCopyOpenApiSpecTo() { + return copyOpenApiSpecTo; + } + + /** + * Copy open API files to either an output location or specific file. + * + * @param copyOpenApiSpecTo Output directory or file. + */ + public void setCopyOpenApiSpecTo(List copyOpenApiSpecTo) { + this.copyOpenApiSpecTo = copyOpenApiSpecTo; + } + /** * True/On to enabled. * diff --git a/modules/jooby-openapi/src/test/java/issues/Issue3977.java b/modules/jooby-openapi/src/test/java/issues/Issue3977.java new file mode 100644 index 0000000000..58dc43d758 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/issues/Issue3977.java @@ -0,0 +1,82 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package issues; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.jooby.internal.openapi.OpenAPIExt; +import io.jooby.openapi.OpenAPIGenerator; +import io.swagger.v3.oas.models.info.Info; + +public class Issue3977 { + private Path outDir = Paths.get(System.getProperty("user.dir"), "target", "test-classes"); + + @Test + public void shouldCopyYamlToDir() throws IOException { + var copyDir = Paths.get(System.getProperty("user.dir"), "target", "spec"); + var output = export("App", OpenAPIGenerator.Format.YAML, List.of(copyDir)); + output.forEach(it -> assertTrue(Files.exists(it))); + assertTrue(output.contains(copyDir.resolve("App.yaml"))); + } + + @Test + public void shouldCopyJsonToDir() throws IOException { + var copyDir = Paths.get(System.getProperty("user.dir"), "target", "spec"); + var output = export("App", OpenAPIGenerator.Format.JSON, List.of(copyDir)); + output.forEach(it -> assertTrue(Files.exists(it))); + assertTrue(output.contains(copyDir.resolve("App.json"))); + } + + @Test + public void shouldCopyJsonFile() throws IOException { + var copyFile = Paths.get(System.getProperty("user.dir"), "target", "files", "open-api.json"); + var output = export("App", OpenAPIGenerator.Format.JSON, List.of(copyFile)); + output.forEach(it -> assertTrue(Files.exists(it))); + assertTrue(output.contains(copyFile)); + } + + @Test + public void shouldCopyYamlFile() throws IOException { + var copyFile = Paths.get(System.getProperty("user.dir"), "target", "files", "open-api.yaml"); + var output = export("App", OpenAPIGenerator.Format.YAML, List.of(copyFile)); + output.forEach(it -> assertTrue(Files.exists(it))); + assertTrue(output.contains(copyFile)); + } + + @Test + public void shouldCopyYmlFile() throws IOException { + var copyFile = Paths.get(System.getProperty("user.dir"), "target", "files", "open-api.yml"); + var output = export("App", OpenAPIGenerator.Format.YAML, List.of(copyFile)); + output.forEach(it -> assertTrue(Files.exists(it))); + assertTrue(output.contains(copyFile)); + } + + private List export(String source, OpenAPIGenerator.Format format, List copySpecTo) + throws IOException { + Info info = new Info(); + info.setTitle("API"); + info.setVersion("1.0"); + info.setDescription("API description"); + + OpenAPIExt openAPI = new OpenAPIExt(); + openAPI.setInfo(info); + openAPI.setSource(source); + + OpenAPIGenerator generator = new OpenAPIGenerator(); + generator.setOutputDir(outDir); + generator.setCopyOpenApiSpecTo(copySpecTo); + return generator.export(openAPI, format, Map.of()); + } +}