From 96b9a7047098aeb54b9b702b31d31237a7fada8d Mon Sep 17 00:00:00 2001 From: Vasily Pelikh Date: Tue, 6 Oct 2026 06:59:23 +0300 Subject: [PATCH] Add shared generator worker module for springdoc-openapi Gradle + Maven plugins - springdoc-openapi-generator-worker: shared thin worker whose entry point GeneratorWorkerMain detects the app's web stack from the fork classpath and dispatches to GeneratorWorkerWebFlux (REACTIVE, no port bound via a no-op ReactiveWebServerFactory) or GeneratorWorkerWebMvc (SERVLET, ephemeral port 0, shut down immediately). Writes the JSON/YAML atomically and fails fast on an unsupported format. - Registered in springdoc-openapi-bom; added gitignore. - GeneratorWorkerMain exits explicitly once generation finishes (or fails) so a leaked non-daemon thread in the target app (e.g. a Vert.x instance or custom thread pool) cannot hang the forked worker JVM after the spec is written. The parent build waits for the process to exit and for its stdout to reach EOF, so without this the whole build could hang. System.exit runs shutdown hooks and flushes System.out, so worker logs still reach the calling build. --- pom.xml | 1 + springdoc-openapi-bom/pom.xml | 5 + springdoc-openapi-generator-worker/.gitignore | 144 ++++++++++++++++++ springdoc-openapi-generator-worker/pom.xml | 56 +++++++ .../generator/GeneratorWorkerMain.java | 71 +++++++++ .../generator/GeneratorWorkerWebFlux.java | 93 +++++++++++ .../generator/GeneratorWorkerWebMvc.java | 86 +++++++++++ .../NoOpReactiveWebServerFactory.java | 42 +++++ .../org/springdoc/generator/WriteUtils.java | 49 ++++++ 9 files changed, 547 insertions(+) create mode 100644 springdoc-openapi-generator-worker/.gitignore create mode 100644 springdoc-openapi-generator-worker/pom.xml create mode 100644 springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerMain.java create mode 100644 springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebFlux.java create mode 100644 springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebMvc.java create mode 100644 springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/NoOpReactiveWebServerFactory.java create mode 100644 springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/WriteUtils.java 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