From a1b6f14d46ecbe1014cd4eea340ba1a99cc7ca58 Mon Sep 17 00:00:00 2001
From: Vasily Pelikh <2010720+vpelikh@users.noreply.github.com>
Date: Tue, 25 Aug 2026 22:41:41 +0300
Subject: [PATCH 1/2] Add springdoc-openapi Gradle + Maven generator plugins
Generate the OpenAPI JSON/YAML spec from a Spring Boot app without keeping a web
server running, supporting WebFlux (reactive) and WebMvc (servlet).
- 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.
- springdoc-openapi-gradle-plugin: standalone Gradle plugin exposing
openApiGenerate { mainClass, outputDir, outputFileName, format, timeoutSeconds,
systemProperties, skip }. Forks the worker with the Java toolchain's launcher
and a configurable fork timeout.
- springdoc-openapi-maven-plugin: 'generate' Mojo (springdoc.skip supported)
that resolves the worker and transitive deps via Aether and forks it against
the project's runtime classpath.
- systemProperties escape hatch for infra-dependent apps (e.g. JPA/Hibernate)
via a generation-time profile/overrides.
- The Gradle fork classpath carries only the worker jar (matching the Maven
Mojo) so a WebMvc app's servlet stack detection is never flipped by a
webflux-api safety-net; the app's own runtime classpath provides its stack.
- Registered worker and plugin in springdoc-openapi-bom; per-module gitignore.
---
pom.xml | 2 +
springdoc-openapi-bom/pom.xml | 10 +
springdoc-openapi-generator-worker/.gitignore | 144 ++++++++++
springdoc-openapi-generator-worker/pom.xml | 53 ++++
.../generator/GeneratorWorkerMain.java | 53 ++++
.../generator/GeneratorWorkerWebFlux.java | 101 +++++++
.../generator/GeneratorWorkerWebMvc.java | 94 +++++++
.../NoOpReactiveWebServerFactory.java | 40 +++
.../org/springdoc/generator/WriteUtils.java | 48 ++++
springdoc-openapi-gradle-plugin/.gitignore | 144 ++++++++++
springdoc-openapi-gradle-plugin/README.md | 155 +++++++++++
springdoc-openapi-gradle-plugin/build.gradle | 66 +++++
.../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes
.../gradle/wrapper/gradle-wrapper.properties | 9 +
springdoc-openapi-gradle-plugin/gradlew | 248 ++++++++++++++++++
springdoc-openapi-gradle-plugin/gradlew.bat | 82 ++++++
.../settings.gradle | 1 +
.../gradle/OpenApiGenerateExtension.java | 68 +++++
.../gradle/SpringDocOpenApiGradlePlugin.java | 108 ++++++++
.../gradle/tasks/GenerateOpenApiTask.java | 187 +++++++++++++
...springdoc-openapi-gradle-plugin.properties | 1 +
...gDocOpenApiGradlePluginFunctionalTest.java | 214 +++++++++++++++
.../src/main/java/test/PetController.java | 22 ++
.../src/main/java/test/SampleApp.java | 10 +
.../src/main/java/test/PetController.java | 19 ++
.../src/main/java/test/SampleApp.java | 9 +
springdoc-openapi-maven-plugin/.gitignore | 144 ++++++++++
springdoc-openapi-maven-plugin/README.md | 119 +++++++++
springdoc-openapi-maven-plugin/pom.xml | 81 ++++++
.../src/it/generate-jpa/pom.xml | 61 +++++
.../it/generate-jpa/src/main/java/it/App.java | 9 +
.../it/generate-jpa/src/main/java/it/Pet.java | 31 +++
.../src/main/java/it/PetController.java | 21 ++
.../src/it/generate-jpa/verify.groovy | 21 ++
.../src/it/generate-skip/pom.xml | 46 ++++
.../generate-skip/src/main/java/it/App.java | 9 +
.../src/main/java/it/PetController.java | 19 ++
.../src/it/generate-skip/verify.groovy | 16 ++
.../src/it/generate-webflux/pom.xml | 45 ++++
.../src/main/java/it/App.java | 9 +
.../src/main/java/it/PetController.java | 19 ++
.../src/it/generate-webflux/verify.groovy | 15 ++
.../src/it/generate-webmvc/pom.xml | 48 ++++
.../generate-webmvc/src/main/java/it/App.java | 9 +
.../src/main/java/it/PetController.java | 18 ++
.../src/it/generate-webmvc/verify.groovy | 15 ++
.../src/it/generate-yaml/pom.xml | 46 ++++
.../generate-yaml/src/main/java/it/App.java | 9 +
.../src/main/java/it/PetController.java | 19 ++
.../src/it/generate-yaml/verify.groovy | 24 ++
.../springdoc/maven/GenerateOpenApiMojo.java | 236 +++++++++++++++++
51 files changed, 2977 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
create mode 100644 springdoc-openapi-gradle-plugin/.gitignore
create mode 100644 springdoc-openapi-gradle-plugin/README.md
create mode 100644 springdoc-openapi-gradle-plugin/build.gradle
create mode 100644 springdoc-openapi-gradle-plugin/gradle/wrapper/gradle-wrapper.jar
create mode 100644 springdoc-openapi-gradle-plugin/gradle/wrapper/gradle-wrapper.properties
create mode 100755 springdoc-openapi-gradle-plugin/gradlew
create mode 100644 springdoc-openapi-gradle-plugin/gradlew.bat
create mode 100644 springdoc-openapi-gradle-plugin/settings.gradle
create mode 100644 springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/OpenApiGenerateExtension.java
create mode 100644 springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/SpringDocOpenApiGradlePlugin.java
create mode 100644 springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/tasks/GenerateOpenApiTask.java
create mode 100644 springdoc-openapi-gradle-plugin/src/main/resources/springdoc-openapi-gradle-plugin.properties
create mode 100644 springdoc-openapi-gradle-plugin/src/test/java/org/springdoc/gradle/SpringDocOpenApiGradlePluginFunctionalTest.java
create mode 100644 springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webflux/src/main/java/test/PetController.java
create mode 100644 springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webflux/src/main/java/test/SampleApp.java
create mode 100644 springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webmvc/src/main/java/test/PetController.java
create mode 100644 springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webmvc/src/main/java/test/SampleApp.java
create mode 100644 springdoc-openapi-maven-plugin/.gitignore
create mode 100644 springdoc-openapi-maven-plugin/README.md
create mode 100644 springdoc-openapi-maven-plugin/pom.xml
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-jpa/pom.xml
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/App.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/Pet.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/PetController.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-jpa/verify.groovy
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-skip/pom.xml
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-skip/src/main/java/it/App.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-skip/src/main/java/it/PetController.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-skip/verify.groovy
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-webflux/pom.xml
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-webflux/src/main/java/it/App.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-webflux/src/main/java/it/PetController.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-webflux/verify.groovy
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-webmvc/pom.xml
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-webmvc/src/main/java/it/App.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-webmvc/src/main/java/it/PetController.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-webmvc/verify.groovy
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-yaml/pom.xml
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-yaml/src/main/java/it/App.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-yaml/src/main/java/it/PetController.java
create mode 100644 springdoc-openapi-maven-plugin/src/it/generate-yaml/verify.groovy
create mode 100644 springdoc-openapi-maven-plugin/src/main/java/org/springdoc/maven/GenerateOpenApiMojo.java
diff --git a/pom.xml b/pom.xml
index e1e1e50b2..b155e61ae 100644
--- a/pom.xml
+++ b/pom.xml
@@ -50,6 +50,8 @@
springdoc-openapi-starter-webmvc-mcp
springdoc-openapi-starter-webflux-mcp
springdoc-openapi-bom
+ springdoc-openapi-generator-worker
+ springdoc-openapi-maven-plugin
springdoc-openapi-tests
diff --git a/springdoc-openapi-bom/pom.xml b/springdoc-openapi-bom/pom.xml
index 7ab6f6b8d..d49f9ff75 100644
--- a/springdoc-openapi-bom/pom.xml
+++ b/springdoc-openapi-bom/pom.xml
@@ -60,6 +60,16 @@
springdoc-openapi-starter-webflux-mcp
${project.version}
+
+ io.github.vpelikh
+ springdoc-openapi-generator-worker
+ ${project.version}
+
+
+ io.github.vpelikh
+ springdoc-openapi-maven-plugin
+ ${project.version}
+
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..cbbaf3fe1
--- /dev/null
+++ b/springdoc-openapi-generator-worker/pom.xml
@@ -0,0 +1,53 @@
+
+ 4.0.0
+
+ io.github.vpelikh
+ springdoc-openapi
+ 5.0.6-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.
+
+
+
+
+ io.github.vpelikh
+ springdoc-openapi-starter-webflux-api
+ ${project.version}
+ provided
+
+
+ io.github.vpelikh
+ 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..058e56a2d
--- /dev/null
+++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerMain.java
@@ -0,0 +1,53 @@
+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) 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..976fc874c
--- /dev/null
+++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebFlux.java
@@ -0,0 +1,101 @@
+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"));
+
+ ConfigurableApplicationContext context = null;
+ try {
+ 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());
+ }
+ finally {
+ if (context != null) {
+ context.close();
+ }
+ }
+ }
+
+ 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..2a9a444ec
--- /dev/null
+++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/GeneratorWorkerWebMvc.java
@@ -0,0 +1,94 @@
+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"));
+
+ ConfigurableApplicationContext context = null;
+ try {
+ 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());
+ }
+ finally {
+ if (context != null) {
+ context.close();
+ }
+ }
+ }
+
+ 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..08bb8ea24
--- /dev/null
+++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/NoOpReactiveWebServerFactory.java
@@ -0,0 +1,40 @@
+package org.springdoc.generator;
+
+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
+ 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..bb5b91a13
--- /dev/null
+++ b/springdoc-openapi-generator-worker/src/main/java/org/springdoc/generator/WriteUtils.java
@@ -0,0 +1,48 @@
+package org.springdoc.generator;
+
+import java.io.IOException;
+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 (java.nio.file.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
diff --git a/springdoc-openapi-gradle-plugin/.gitignore b/springdoc-openapi-gradle-plugin/.gitignore
new file mode 100644
index 000000000..ab21548c1
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/.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-gradle-plugin/README.md b/springdoc-openapi-gradle-plugin/README.md
new file mode 100644
index 000000000..243c497c1
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/README.md
@@ -0,0 +1,155 @@
+# springdoc-openapi-gradle-plugin
+
+A Gradle plugin that generates the [OpenAPI](https://swagger.io/specification/) specification
+for a Spring Boot application without leaving a web server running. It forks a dedicated JVM and
+supports both stacks with no per-stack config; the worker detects the target app's stack from its
+classpath and dispatches to the matching generator.
+
+## Important: WebFlux vs WebMvc
+
+> **WebFlux (reactive) — genuinely serverless.** A no-op `ReactiveWebServerFactory` keeps springdoc
+> active and **no port is ever bound**. This fully honors the "generate without starting the app"
+> goal.
+>
+> **WebMvc (servlet) — starts a real, ephemeral server.** The servlet model requires an actual
+> container (the `DispatcherServlet` must register in a `ServletContext`), so generation boots the
+> embedded server on an **ephemeral port (0)** and shuts the context down immediately after writing
+> the spec. No fixed/exposed port is used and nothing stays listening, but a real container does
+> briefly start. This is a servlet-model constraint, not a plugin choice.
+
+## Status
+
+Feature/experimental. Open for review on branch `feature/openapi-gradle-plugin`.
+
+## Project layout
+
+```
+springdoc-openapi-gradle-plugin/ (this Gradle build, the plugin)
+ src/main/java/org/springdoc/gradle/ plugin, extension, task
+ src/test/... Gradle TestKit functional test + sample app
+```
+
+The actual JVM worker (`springdoc-openapi-generator-worker`, which boots the app and writes the
+spec) lives in a sibling Maven module shared with the Maven plugin, so both build systems use a
+single implementation rather than duplicating it.
+
+## Usage
+
+Apply the plugin to the Gradle project containing your Spring Boot application and set the main
+class:
+
+```groovy
+plugins {
+ id 'java'
+ id 'io.github.vpelikh.springdoc-openapi-gradle-plugin' version '5.0.6-SNAPSHOT'
+}
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-webflux:4.1.1'
+ implementation 'io.github.vpelikh:springdoc-openapi-starter-webflux-api:5.0.6-SNAPSHOT'
+}
+
+openApiGenerate {
+ mainClass = 'com.example.YourApplication'
+}
+```
+
+Then:
+
+```
+./gradlew generateOpenApi
+```
+
+The spec is written to `build/docs/openapi.json` by default.
+
+## Extension options (`openApiGenerate { ... }`)
+
+| Property | Type | Default | Description |
+|----------------|----------|------------------|-----------------------------------------------|
+| `mainClass` | `String` | *required* | `@SpringBootApplication` class to boot |
+| `outputDir` | `File` | `build/docs` | Directory for the generated document |
+| `outputFileName`| `String` | `openapi` | Base file name (`.json` / `.yaml` appended) |
+| `format` | `String` | `json` | `json` or `yaml` |
+| `timeoutSeconds`| `int` | `120` | Worker time bound; aborts the fork on timeout |
+| `skip` | `boolean`| `false` | Skip `generateOpenApi` entirely |
+| `systemProperties`| `Map` | `{}` | Extra `-D` props for the worker JVM |
+
+## How it works
+
+The plugin:
+
+1. Resolves the application's `runtimeClasspath` plus a small `springdocGenerator` configuration
+ carrying only the fork's worker JAR and its transitive runtime deps. The relevant springdoc
+ stack API (`webflux-api` or `webmvc-api`) comes from the application's own dependencies, so the
+ fork classpath matches the Maven Mojo's and never forces a stack onto the process.
+2. Forks a JVM (`java -cp org.springdoc.gradle.GeneratorWorkerMain ...`).
+3. Inside that JVM the worker boots the application with `WebApplicationType.REACTIVE` and
+ registers a **no-op `ReactiveWebServerFactory`** (a `WebServer` whose `start()` does nothing),
+ so the reactive web context exists for springdoc but **no port is ever bound**.
+4. It invokes springdoc's existing `OpenApiWebfluxResource` (with a mock request) to produce the
+ JSON/YAML document, writes it to the configured output, and closes the context.
+5. The plugin task is `@Cacheable`, so unchanged inputs are up-to-date.
+
+## Building & testing
+
+From this directory:
+
+```
+./gradlew test # TestKit functional test
+```
+
+The functional test boots the bundled sample reactive app and asserts the generated document
+contains the `/pets` paths.
+
+## Notes / limitations
+
+- Uses the fork's modules (`io.github.vpelikh:springdoc-openapi-starter-webflux-api` /
+ `springdoc-openapi-starter-webmvc-api`) at `5.0.6-SNAPSHOT`; install them and the shared
+ `springdoc-openapi-generator-worker` into `~/.m2` (via the root Maven build) first.
+- Supports both WebFlux (reactive, fully serverless) and WebMvc (servlet, ephemeral auto-stopped
+ embedded server). The stack is detected automatically from the app's classpath.
+- **WebFlux "no port bound" caveat:** the no-op `ReactiveWebServerFactory` is registered with
+ `@ConditionalOnMissingBean`. If the application itself defines a `ReactiveWebServerFactory` bean,
+ that one wins and a real server can bind a port at generation time. This is rare (configuring the
+ server via a `WebServerFactoryCustomizer` does not define a factory bean and is unaffected). To
+ guarantee no port is bound, avoid defining such a bean or use `@OpenAPIDefinition(...)` to control
+ the generated spec instead.
+- The shared worker declares `spring-test` as a runtime dependency solely to build mock
+ `ServerHttpRequest` / `HttpServletRequest`; it is pulled onto the fork classpath but never
+ bundled into the worker jar.
+- The `generateOpenApi` task is `@Cacheable`; its `javaExecutable` input is absolute (toolchain
+ path), so remote build-cache hits are machine-specific.
+- The fork-invocation logic (build `java -cp`, stream output, time out) is intentionally kept small
+ and duplicated in the Gradle task and Maven Mojo rather than extracted into a shared helper, to
+ avoid coupling the plugins to the worker jar's compile classpath. The shared worker still owns
+ the actual generation.
+- The offline spec's `servers` entry defaults to `http://localhost` (WebMvc) / a mock URL
+ (WebFlux). Set `@OpenAPIDefinition(servers = @Server(...))` or a global `OpenApiCustomizer` to
+ override it for your deployment.
+
+### Generating apps that need infrastructure
+
+The worker boots the application's real context, so beans that need external resources (a
+database, JMS broker, external service) must be satisfiable at generation time. For example, a
+JPA/Hibernate app with no reachable database will fail to refresh.
+
+Use `systemProperties` to point generation at a test profile/overrides, exactly like a test:
+
+```groovy
+openApiGenerate {
+ mainClass = 'com.example.App'
+ systemProperties = [
+ 'spring.profiles.active': 'generation',
+ 'spring.autoconfigure.exclude':
+ 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration'
+ ]
+}
+```
+
+Spring Boot still reads `@Entity`/JPA annotations off the classpath, so the OpenAPI spec is
+correct while no real database connection is needed.
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/build.gradle b/springdoc-openapi-gradle-plugin/build.gradle
new file mode 100644
index 000000000..2de8547e0
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/build.gradle
@@ -0,0 +1,66 @@
+// * * * * * * * * * * * *
+// Plugins
+// * * * * * * * * * * * *
+plugins {
+ id 'java-gradle-plugin'
+ id 'maven-publish'
+}
+
+// * * * * * * * * * * * *
+// Project configuration
+// * * * * * * * * * * * *
+
+group = 'io.github.vpelikh'
+version = '5.0.6-SNAPSHOT'
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+ maven {
+ url = 'https://central.sonatype.com/repository/maven-snapshots/'
+ }
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+compileJava {
+ options.release = 17
+}
+
+dependencies {
+ implementation gradleApi()
+
+ testImplementation gradleTestKit()
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.4'
+}
+
+// Filter plugin version into the properties file at build time (like swagger plugin).
+processResources {
+ inputs.property 'pluginVersion', project.version
+ filteringCharset = 'UTF-8'
+ filesMatching('springdoc-openapi-gradle-plugin.properties') {
+ expand(pluginVersion: project.version)
+ }
+}
+
+// * * * * * * * * * * * *
+// Plugin publishing
+// * * * * * * * * * * * *
+
+gradlePlugin {
+ plugins {
+ springdoc {
+ id = 'io.github.vpelikh.springdoc-openapi-gradle-plugin'
+ implementationClass = 'org.springdoc.gradle.SpringDocOpenApiGradlePlugin'
+ }
+ }
+}
+
+test {
+ useJUnitPlatform()
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/gradle/wrapper/gradle-wrapper.jar b/springdoc-openapi-gradle-plugin/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f
GIT binary patch
literal 48462
zcma&NV{|3jwk;gnwr$(CRk3Z`Sy9Ed?Nn^ruGlsztklcC=e7I2x9>aqJFB(1eyu-q
z%|3b`eLzVT6buar3JMAc2#EOW{C^)LAZQ?YaW!FjX$1*JIcZUG1yyl%HEd!6f#E+}*Jo*NafvM<-FbE0;-_L#rp}qdn%JEoAVNlEB#J^Oq`mU_#*ev4HLmc>
zjXz_hFft^><#omb;Zer-%wm4hxo!wjuX3hBldg(^-RiOleKin`>KHfL3P*{k?(rji(#j2Cc0K509#>qu=-T&B!-5EBi(+
zIuTD-qfcAYgS@`Fb2^-p)4#o6A3z0&fp?~cV=CRsAeCmO4ZQ5kKgC%0el=Q&Rhd#k
zaGmAbUW8uKC}-C0s~2);d{;mpsNBx9rn__66W{AhaSvJEK+c0b6ARO+l(CI7E|S5x
zhaYP--@F<|99X&)9`q^2(^-Zu^Tzfm)v|gkTJHQ!G*zIg5hzoygeXZoYUEJ;iFkE#
zq^r$*c|>Hmn3GapzcDYnjgSFiO^NFyTR5AH#mh%zRToMpEi(r)1$5)h455DuV}0al
z!*psWuL@Ke-2gvftfMEGf9YEi^<{B@qru
zINgo+YsE&LN?)1qItJoNhISp-fZ86`XR#*6xcvM~_7=JHUX;K9*=Gu5X~
zix|O2d=&C#u_w{=B$eCpJ4L*6i7={j+{Og~`Emz@&98}6s<-p^)`0fXE4cJBP{>)Ltb>JwcqI>yz
z0-r-SEhC@p)XOoh|1|XgjFaREHfsu4dAGVz*k#m+V<4
zHqvlud6=;#QWHUoTR_a8Y8+heN?M%n1@0YLiaN@GuOPNd26tik7eKulTx?mM-R!1H
znB6+H{^krFXg_b{y=QeCT~qR3T4}l+b!Oz9;~|3*6F<3?#|DYYW&1RtFE)ILZ!`85
zVmvrZkLTzf31unH7Cc5E0iFShqlBE9hgEnRJH1juII*vyp&xd!g`q}X_6WT6E$hhQ`Vdp9k^<)VS?lj!cTh
z7FQcQAVA@jL^cXod8cnhKG2TS9+;QU6Kq>}UOY3&TL9gXbl{Fv8@WsF=z7>X0To@$
zY@Oi1uc|MdJ$>Kn{@!g_e`-I&Tpwfg9cr>(iakDX1qciCG_1y!Di#4_)lE!bWJbrp
z5aUonb6m-?tiQyR_`P#~SOu+tb_ev6JO>EbEhHK@KbeT0_FDo>dl9bMg)>xmCNB*g
zG5NC8ABavuTEZVGW6jP*nAqRt3W?7Iigc-EE~zpNJXRAE
z>`~RO9$892j&I1kV;9U)xT8^}IeV`n{}QDtj2o-RBt`DGZUOO;O*lFCb_vpyGh*;95PfeGu!dyrmZ9VJ3Z*upg
z6R-3Lr%_55$Hw1^{+KWx0#z`T7O6sXo1h;m?B_ur`X2bFz-SzDrL
zpk^@B<+I6imc@7vip
za%1jMB7q@1j#
zz{u?YojZMW{5j$@h=v4iu2mTu7IzI|)Sxn!74=*J>1a&?Xjt
z2%JhSi#4huEcD9qdR9Lj4vwmfnL{%+vQ{f-KgYeqin(OPd8+(g*Uq#TLxQjD4
zLCL%ul(V&PAPlAx8D`@K8Rc`{GPecQ<)d=KWel0ejFeeXGQ6o7601B!!I@RY&eDriADD6wP6DcFKDLZ|lO#YwnrNCZ)zRJpdxX_nPZa4j#$j6v!h|6p!dH}MY6#B`@%6=)
z-HigguDACKBULnon^FKzazF|Y1{t(U5rUGnEU|}djVsWT-F>@@mNx?_$kF51QF4C5
zStKR$^3(fw85(4HGs9{mUTtn1)3PwxTN?6}j;32&vJ^BiPHfndLkdU5sOemXKGyCZ
z@<7j(k>DNeo~QXyJkFWk!7(y1SB%nA3{v~P2c8ooKa4auM!el!Q_=;lJ$c5ADqE+^
zX8*|A99v;jWPrm(8=h;2ZAj|(vVbx~wQ{N%v;eYLD_BB2LAEWCs@xauyBDl(_HIBvA(XJ7B1E;O
zJYCJ8xFJh7f5sr;Y#Wp_`$4Z_H4e9bGiBp?Qu&2!@%Bl2dT5evfFO*^hLDiBu2%Jl
z*WAlL5PaQ7skJa(qVysky}DQquZ8U?2@UyJ8zB#=U_E>MgE%XA$CtfL31m$rATJvC
zs@!crc0=128PM=Zp
zW_5Czv9))n_8Ru?{pxM2F8^r%*O41}RnONbSj*piG%`nyF>6ky=|;B&k8iot(J=kyoU3p<_zaAX(1ijzf*uXA
zZ_5jeC{Lks+&QeFIlmzZi3+fsF4fNW^~kvC4Q*T-vrNP!x9xnen12lZQM=1_MdW76LKX(GuW`%T~dM^YX6+ras|Xy4Qhfcq=D+z-P-ea
z`T;^gj3+grr3^hwqcNTJErl$z+k>{bYFm6QV%7Opth?9+>|Dn)O@`7F@=j-XSqGPW
zjUAu%b3Er@;j1%RZxVDhI3sakg-gvTLOSV7;FV6ED=(5;UG??=WADZw^=$4AyFh#}VMe3afM^pF
zFa}-nM8X=K?Jy02*o02@6k{
z%O!hBhjXlXKdhy3A{xGB<##e|j3^dFv~~%v2_H{t(mN7NVeS~51?D&Ozbxa`qwZ_4
z;C#Q#fL1sua%ggucgIEHZtcY=Ag&GgE|h7Q{77D!WUq`;SSGEE0pU;aoj<7-JCAvf
zduN=(tx3Mb+EUXKoax|v;8b@#HJ&Q|!g4ryrl|R>WlAv?IH`bk)I24;eE4NIq@SLK31LD4+w~#3iN{=<`<1R!t^$@K5>U6%W=%8_ANuR5
zs(IDuI18ftirTDARnGmF%;iz+4{MlMihJw_l!0Y)NttXC_t+s)V<