diff --git a/pom.xml b/pom.xml index 604dca795..4e9fc8680 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ springdoc-openapi-starter-common-mcp springdoc-openapi-starter-webmvc-mcp springdoc-openapi-starter-webflux-mcp + springdoc-openapi-generator-worker springdoc-openapi-bom springdoc-openapi-tests diff --git a/springdoc-openapi-bom/pom.xml b/springdoc-openapi-bom/pom.xml index 21e1a611e..e997e7311 100644 --- a/springdoc-openapi-bom/pom.xml +++ b/springdoc-openapi-bom/pom.xml @@ -60,6 +60,11 @@ springdoc-openapi-starter-webflux-mcp ${project.version} + + io.github.vpelikh + springdoc-openapi-generator-worker + ${project.version} + io.swagger.core.v3 diff --git a/springdoc-openapi-generator-worker/.gitignore b/springdoc-openapi-generator-worker/.gitignore new file mode 100644 index 000000000..ab21548c1 --- /dev/null +++ b/springdoc-openapi-generator-worker/.gitignore @@ -0,0 +1,144 @@ +###################### +# Project Specific +###################### +/target/www/** +/src/test/javascript/coverage/ + +###################### +# Node +###################### +/node/ +node_tmp/ +node_modules/ +npm-debug.log.* +/.awcache/* +/.cache-loader/* + +###################### +# SASS +###################### +.sass-cache/ + +###################### +# Eclipse +###################### +*.pydevproject +.project +.metadata +tmp/ +tmp/**/* +*.tmp +*.bak +*.swp +*~.nib +local.properties +.classpath +.settings/ +.loadpath +.factorypath +/src/main/resources/rebel.xml + +# External tool builders +.externalToolBuilders/** + +# Locally stored "Eclipse launch configurations" +*.launch + +# CDT-specific +.cproject + +# PDT-specific +.buildpath + +###################### +# Intellij +###################### +.idea/ +*.iml +*.iws +*.ipr +*.ids +*.orig +classes/ +out/ + +###################### +# Visual Studio Code +###################### +.vscode/ + +###################### +# Maven +###################### +/log/ +/target/ + +###################### +# Gradle +###################### +.gradle/ +/build/ + +###################### +# Package Files +###################### +*.jar +*.war +*.ear +*.db + +###################### +# Windows +###################### +# Windows image file caches +Thumbs.db + +# Folder config file +Desktop.ini + +###################### +# Mac OSX +###################### +.DS_Store +.svn + +# Thumbnails +._* + +# Files that might appear on external disk +.Spotlight-V100 +.Trashes + +###################### +# Directories +###################### +/bin/ +/deploy/ + +###################### +# Logs +###################### +*.log* + +###################### +# Others +###################### +*.class +*.*~ +*~ +.merge_file* + +###################### +# Gradle Wrapper +###################### +!gradle/wrapper/gradle-wrapper.jar + +###################### +# Maven Wrapper +###################### +!.mvn/wrapper/maven-wrapper.jar + +###################### +# ESLint +###################### +.eslintcache \ No newline at end of file diff --git a/springdoc-openapi-generator-worker/pom.xml b/springdoc-openapi-generator-worker/pom.xml new file mode 100644 index 000000000..6ec16d477 --- /dev/null +++ b/springdoc-openapi-generator-worker/pom.xml @@ -0,0 +1,56 @@ + + 4.0.0 + + org.springdoc + springdoc-openapi + 3.1.2-SNAPSHOT + + springdoc-openapi-generator-worker + ${project.artifactId} + Shared forked-JVM worker that boots a Spring Boot reactive or servlet context, runs springdoc-openapi, and writes the OpenAPI document. Used by the Gradle and Maven generator plugins. + + + + + org.springdoc + springdoc-openapi-starter-webflux-api + ${project.version} + provided + + + org.springdoc + springdoc-openapi-starter-webmvc-api + ${project.version} + provided + + + org.springframework.boot + spring-boot + provided + + + org.springframework.boot + spring-boot-web-server + provided + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + org.springframework + spring-test + + + \ No newline at end of file diff --git a/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerMain.java b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerMain.java new file mode 100644 index 000000000..1c1790ade --- /dev/null +++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerMain.java @@ -0,0 +1,71 @@ +package org.springdoc.generator; + +/** + * Entry point for the generator worker, run in a forked JVM by the Gradle and Maven plugins. + *

+ * It detects the target application's web stack from the fork classpath (the app's own + * dependencies are on it) and delegates to the matching worker: + *

+ * The two worker classes are only loaded when selected. Because the JVM resolves constant-pool + * references lazily, the non-selected worker is never loaded on a fork that lacks that stack, so + * this works on WebFlux-only and WebMvc-only classpaths alike. + *

+ * When both stacks are on the classpath (a mixed application), WebMvc (servlet) wins, matching + * {@code SpringApplication}'s {@code WebApplicationType.deduceFromClasspath}. + *

+ * Arguments: {@code [outputFileName] [format]} + */ +public final class GeneratorWorkerMain { + + private GeneratorWorkerMain() { + } + + public static void main(String[] args) { + int exitCode = 0; + try { + run(args); + } + catch (Throwable t) { + // Print to stderr so the failure is visible even if the app reconfigured logging. + t.printStackTrace(System.err); + exitCode = 1; + } + // The worker is a one-shot forked JVM. The booted application may leave non-daemon + // threads running (e.g. a Vert.x instance or thread pool that is not shut down when the + // application context closes), which would otherwise keep this JVM alive indefinitely + // after the spec has been written. The parent build waits for the process to exit (and + // for its stdout to reach EOF), so exit explicitly instead of relying on natural exit. + System.exit(exitCode); + } + + private static void run(String[] args) throws Exception { + if (args.length < 2) { + throw new IllegalArgumentException( + "Usage: GeneratorWorkerMain [outputFileName] [format]"); + } + // Spring Boot prefers servlet when both stacks are present, so check WebMvc first. + if (isOnClasspath("org.springframework.web.servlet.DispatcherServlet")) { + GeneratorWorkerWebMvc.main(args); + } + else if (isOnClasspath("org.springframework.web.reactive.DispatcherHandler")) { + GeneratorWorkerWebFlux.main(args); + } + else { + throw new IllegalStateException( + "Could not detect a WebMvc or WebFlux stack on the application classpath."); + } + } + + private static boolean isOnClasspath(String className) { + try { + Class.forName(className, false, GeneratorWorkerMain.class.getClassLoader()); + return true; + } + catch (ClassNotFoundException e) { + return false; + } + } +} \ No newline at end of file diff --git a/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebFlux.java b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebFlux.java new file mode 100644 index 000000000..1b04cfc15 --- /dev/null +++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebFlux.java @@ -0,0 +1,93 @@ +package org.springdoc.generator; + +import org.springdoc.webflux.api.OpenApiWebfluxResource; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.server.reactive.ReactiveWebServerFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; + +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; + +/** + * Workers that boot a WebFlux (reactive) Spring Boot application, let springdoc-openapi + * build the OpenAPI document, write it to disk, and shut the context down. Runs in a forked JVM. + *

+ * A no-op {@link ReactiveWebServerFactory} is registered so springdoc's + * {@code @ConditionalOnWebApplication} activates without ever binding a port. + */ +public class GeneratorWorkerWebFlux { + + @Configuration + static class NoServerConfiguration { + + @Bean + @ConditionalOnMissingBean(ReactiveWebServerFactory.class) + ReactiveWebServerFactory reactiveWebServerFactory() { + return new NoOpReactiveWebServerFactory(); + } + } + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + throw new IllegalArgumentException( + "Usage: GeneratorWorkerWebFlux [outputFileName] [format]"); + } + String mainClass = args[0]; + String outputDir = args[1]; + String outputFileName = args.length > 2 ? args[2] : "openapi"; + String format = args.length > 3 ? args[3] : "json"; + validateFormat(format); + new GeneratorWorkerWebFlux().generate(mainClass, outputDir, outputFileName, format); + } + + public void generate(String mainClass, String outputDir, String outputFileName, String format) throws Exception { + SpringApplication app = new SpringApplication(Class.forName(mainClass)); + app.setWebApplicationType(WebApplicationType.REACTIVE); + app.addPrimarySources(java.util.List.of(NoServerConfiguration.class)); + app.setDefaultProperties(Map.of("spring.main.banner-mode", "off")); + + try (ConfigurableApplicationContext context = app.run()) { + OpenApiWebfluxResource resource = context.getBean(OpenApiWebfluxResource.class); + ServerHttpRequest request = MockServerHttpRequest.get("http://localhost/v3/api-docs").build(); + String lower = format.toLowerCase(Locale.ROOT); + byte[] bytes; + if (isYaml(lower)) { + bytes = resource.openapiYaml(request, "/v3/api-docs", Locale.ENGLISH).block(); + } else { + bytes = resource.openapiJson(request, "/v3/api-docs", Locale.ENGLISH).block(); + } + if (bytes == null || bytes.length == 0) { + throw new IllegalStateException("OpenAPI generation returned no content"); + } + String ext = isYaml(lower) ? "yaml" : "json"; + Path out = Path.of(outputDir).resolve(outputFileName + "." + ext); + WriteUtils.writeAtomic(out, bytes); + System.out.println("Generated OpenAPI spec at " + out.toAbsolutePath()); + } + } + + private static boolean isYaml(String lower) { + return "yaml".equals(lower) || "yml".equals(lower); + } + + /** + * Validates a user-supplied {@code format} argument. Only {@code json}, {@code yaml}, + * {@code yml} are supported; anything else is rejected rather than silently falling back + * to JSON output (which would produce a document in a format the user did not ask for). + */ + private static void validateFormat(String format) { + String lower = format.toLowerCase(Locale.ROOT); + boolean valid = "json".equals(lower) || "yaml".equals(lower) || "yml".equals(lower); + if (!valid) { + throw new IllegalArgumentException( + "Unsupported format '" + format + "'. Supported formats: json, yaml, yml."); + } + } +} \ No newline at end of file diff --git a/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebMvc.java b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebMvc.java new file mode 100644 index 000000000..d65ee606c --- /dev/null +++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebMvc.java @@ -0,0 +1,86 @@ +package org.springdoc.generator; + +import org.springdoc.webmvc.api.OpenApiWebMvcResource; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; + +/** + * Worker that boots a WebMvc (servlet) Spring Boot application, lets springdoc-openapi + * build the OpenAPI document, write it to disk, and shut the context down. Runs in a forked JVM. + *

+ * Unlike WebFlux, the servlet model requires a real servlet container (the DispatcherServlet must + * be able to register in it). To honor "generate without starting the serial server" as closely as + * the servlet model allows, the embedded container is bound to an ephemeral port (0) and the + * context is shut down immediately after generation, so no port is exposed and nothing stays + * listening. + */ +public class GeneratorWorkerWebMvc { + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + throw new IllegalArgumentException( + "Usage: GeneratorWorkerWebMvc [outputFileName] [format]"); + } + String mainClass = args[0]; + String outputDir = args[1]; + String outputFileName = args.length > 2 ? args[2] : "openapi"; + String format = args.length > 3 ? args[3] : "json"; + validateFormat(format); + new GeneratorWorkerWebMvc().generate(mainClass, outputDir, outputFileName, format); + } + + public void generate(String mainClass, String outputDir, String outputFileName, String format) throws Exception { + SpringApplication app = new SpringApplication(Class.forName(mainClass)); + app.setWebApplicationType(WebApplicationType.SERVLET); + // Bind an ephemeral port; the context stops immediately after generation. + app.setDefaultProperties(Map.of( + "server.port", "0", + "spring.main.banner-mode", "off")); + + try (ConfigurableApplicationContext context = app.run()) { + OpenApiWebMvcResource resource = context.getBean(OpenApiWebMvcResource.class); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v3/api-docs"); + request.setScheme("http"); + request.setServerName("localhost"); + request.setServerPort(80); + String lower = format.toLowerCase(Locale.ROOT); + byte[] bytes; + if (isYaml(lower)) { + bytes = resource.openapiYaml(request, "/v3/api-docs", Locale.ENGLISH); + } else { + bytes = resource.openapiJson(request, "/v3/api-docs", Locale.ENGLISH); + } + if (bytes == null || bytes.length == 0) { + throw new IllegalStateException("OpenAPI generation returned no content"); + } + String ext = isYaml(lower) ? "yaml" : "json"; + Path out = Path.of(outputDir).resolve(outputFileName + "." + ext); + WriteUtils.writeAtomic(out, bytes); + System.out.println("Generated OpenAPI spec at " + out.toAbsolutePath()); + } + } + + private static boolean isYaml(String lower) { + return "yaml".equals(lower) || "yml".equals(lower); + } + + /** + * Validates a user-supplied {@code format} argument. Only {@code json}, {@code yaml}, + * {@code yml} are supported; anything else is rejected rather than silently falling back + * to JSON output (which would produce a document in a format the user did not ask for). + */ + private static void validateFormat(String format) { + String lower = format.toLowerCase(Locale.ROOT); + boolean valid = "json".equals(lower) || "yaml".equals(lower) || "yml".equals(lower); + if (!valid) { + throw new IllegalArgumentException( + "Unsupported format '" + format + "'. Supported formats: json, yaml, yml."); + } + } +} \ No newline at end of file diff --git a/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/NoOpReactiveWebServerFactory.java b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/NoOpReactiveWebServerFactory.java new file mode 100644 index 000000000..a45994d51 --- /dev/null +++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/NoOpReactiveWebServerFactory.java @@ -0,0 +1,42 @@ +package org.springdoc.generator; + +import org.jspecify.annotations.NullMarked; +import org.springframework.boot.web.server.WebServer; +import org.springframework.boot.web.server.WebServerException; +import org.springframework.boot.web.server.reactive.ReactiveWebServerFactory; +import org.springframework.http.server.reactive.HttpHandler; + +/** + * A {@link ReactiveWebServerFactory} that produces a no-op {@link WebServer}. Registering this + * satisfies Spring Boot's reactive web auto-configuration (so {@code @ConditionalOnWebApplication} + * still activates springdoc) while never binding any port: {@code WebServer.start()} is a no-op. + */ +final class NoOpReactiveWebServerFactory implements ReactiveWebServerFactory { + + @Override + @NullMarked + public WebServer getWebServer(HttpHandler httpHandler) { + return new NoOpWebServer(); + } + + /** + * A {@link WebServer} whose lifecycle methods do nothing, so no server ever binds a port. + */ + private static final class NoOpWebServer implements WebServer { + + @Override + public void start() throws WebServerException { + // intentionally do not bind any port + } + + @Override + public void stop() throws WebServerException { + // nothing to stop + } + + @Override + public int getPort() { + return 0; + } + } +} \ No newline at end of file diff --git a/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/WriteUtils.java b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/WriteUtils.java new file mode 100644 index 000000000..f39449428 --- /dev/null +++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/WriteUtils.java @@ -0,0 +1,49 @@ +package org.springdoc.generator; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * File-writing helpers for the generator workers. + */ +final class WriteUtils { + + private WriteUtils() { + } + + /** + * Writes {@code bytes} to {@code target} atomically where the underlying filesystem supports + * it. The bytes are first written to a temporary sibling file, then moved over the target. + * This guarantees the final output path only ever contains a complete document: if the fork is + * killed mid-write (e.g. the plugin's fork timeout) or the write fails, no partial or corrupt + * file is left at {@code target}. + * + * @throws IOException if the write or the move fails + */ + static void writeAtomic(Path target, byte[] bytes) throws IOException { + Path dir = target.getParent(); + if (dir == null) { + dir = Path.of("."); + } + Files.createDirectories(dir); + Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp"); + try { + Files.write(tmp, bytes); + try { + // Atomic within the same directory when the (default) filesystem supports it. + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + catch (AtomicMoveNotSupportedException e) { + // Fall back to a best-effort atomic (same-dir) move for non-atomic filesystems. + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } + finally { + // If anything failed before the move, leave no temp debris behind. + Files.deleteIfExists(tmp); + } + } +} \ No newline at end of file