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:
+ *
+ * - WebMvc (servlet)
+ * - WebFlux (reactive)
+ *
+ * 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