diff --git a/.github/actions/build-and-test-plugins/action.yml b/.github/actions/build-and-test-plugins/action.yml
new file mode 100644
index 000000000..7f75da6a7
--- /dev/null
+++ b/.github/actions/build-and-test-plugins/action.yml
@@ -0,0 +1,21 @@
+name: 'Build and test plugins'
+description: >
+ Builds and installs the Maven reactor, builds the Gradle plugin, and runs the
+ Maven plugin's integration tests. Shared by the Build and Release workflows so
+ the application build+test sequence is defined in one place.
+runs:
+ using: "composite"
+ steps:
+ - name: Maven install (parallel, skip invoker ITs)
+ uses: ./.github/actions/maven-install
+
+ - name: Build Gradle plugin
+ shell: bash
+ run: |
+ cd ./springdoc-openapi-gradle-plugin
+ ./gradlew build --info --build-cache --parallel
+
+ - name: Maven plugin integration tests
+ shell: bash
+ run: |
+ ./mvnw --no-transfer-progress -B -pl springdoc-openapi-maven-plugin verify
\ No newline at end of file
diff --git a/.github/actions/maven-install/action.yml b/.github/actions/maven-install/action.yml
new file mode 100644
index 000000000..bf793ae1d
--- /dev/null
+++ b/.github/actions/maven-install/action.yml
@@ -0,0 +1,12 @@
+name: 'Maven install (parallel, skip invoker ITs)'
+description: >
+ Builds and installs the whole Maven reactor to the local repo, skipping the
+ maven-plugin's invoker-plugin integration tests. Those run serially afterwards
+ via build-and-test-plugins.
+runs:
+ using: "composite"
+ steps:
+ - name: Maven install
+ shell: bash
+ run: |
+ ./mvnw --no-transfer-progress -B -T 1C -Dinvoker.skip=true install --file pom.xml
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2701b3e06..3c230626b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -34,8 +34,13 @@ jobs:
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
- - name: Build with Maven
- run: ./mvnw --no-transfer-progress -B -T 1C install --file pom.xml
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+ with:
+ cache-read-only: ${{ github.event_name == 'pull_request' }}
+
+ - name: Build and test plugins
+ uses: ./.github/actions/build-and-test-plugins
- name: Deploy SNAPSHOT to Maven Central
if: github.event_name == 'push' && matrix.java == 17
@@ -43,7 +48,7 @@ jobs:
MY_POM_VERSION=$(./mvnw -q -DforceStdout help:evaluate -Dexpression=project.version --non-recursive)
echo "POM VERSION: $MY_POM_VERSION"
if [[ $MY_POM_VERSION =~ ^.*SNAPSHOT$ ]]; then
- ./mvnw --no-transfer-progress -B -T 1C -DskipTests deploy -e
+ ./mvnw --no-transfer-progress -B -T 1C -Dinvoker.skip=true -DskipTests deploy -e
else
echo "Not a SNAPSHOT version, skipping deployment."
fi
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index d355aec5f..f0c3c7107 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -38,7 +38,9 @@ jobs:
languages: java
- name: Build with Maven
- run: ./mvnw --no-transfer-progress -B -T 1C install --file pom.xml
+ # CodeQL only needs the source built for analysis; the maven-plugin's invoker
+ # ITs already run in the main Build workflow (see maven-install action).
+ uses: ./.github/actions/maven-install
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 383380994..e6c3959f8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,6 +33,14 @@ jobs:
server-password: MAVEN_PASSWORD
gpg-private-key: ${{ secrets.OSSRH_GPG_PRIVATE_KEY }}
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+ env:
+ GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
+ GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }}
+ with:
+ cache-read-only: false
+
- name: Compute release version
id: version
run: . ./CI/compute-release-version.sh ${{ github.event.inputs.release_type }}
@@ -42,12 +50,19 @@ jobs:
- name: Prepare release commit (detached)
run: . ./CI/prepare-release-commit.sh
- - name: Build and test
- run: ./mvnw --no-transfer-progress -B -T 1C install --file pom.xml
+ - name: Build and test plugins
+ uses: ./.github/actions/build-and-test-plugins
- name: Deploy to Maven Central
if: success()
- run: ./mvnw --no-transfer-progress -B -T 1C -Prelease deploy
+ run: ./mvnw --no-transfer-progress -B -T 1C -Dinvoker.skip=true -Prelease deploy
+
+ - name: Publish Gradle plugin
+ if: success()
+ run: |
+ cd ./springdoc-openapi-gradle-plugin
+ ./gradlew publishPlugins -Pgradle.publish.key="${GRADLE_PUBLISH_KEY}" -Pgradle.publish.secret="${GRADLE_PUBLISH_SECRET}" --info --build-cache --parallel
+ cd ../..
- name: Tag and push release tag
if: success()
@@ -111,4 +126,6 @@ jobs:
MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GPG_PRIVATE_KEY: ${{ secrets.OSSRH_GPG_PRIVATE_KEY }}
- GPG_PASSPHRASE: ${{ secrets.OSSRH_GPG_PRIVATE_PASSPHRASE }}
\ No newline at end of file
+ GPG_PASSPHRASE: ${{ secrets.OSSRH_GPG_PRIVATE_PASSPHRASE }}
+ GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
+ GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }}
\ No newline at end of file
diff --git a/CI/bump-snapshot.sh b/CI/bump-snapshot.sh
index 53581d0be..cf752b7bc 100644
--- a/CI/bump-snapshot.sh
+++ b/CI/bump-snapshot.sh
@@ -28,6 +28,9 @@ echo "Bumping version to $NEXT_SNAPSHOT"
# Update all POMs in one go
./mvnw versions:set -DnewVersion="${NEXT_SNAPSHOT}" -DgenerateBackupPoms=false
+# Update gradle.properties
+sed -i "s/version=.*/version=${NEXT_SNAPSHOT}/" springdoc-openapi-gradle-plugin/gradle.properties
+
# Commit and push
git config user.email "action@github.com"
git config user.name "GitHub Action"
diff --git a/CI/prepare-release-commit.sh b/CI/prepare-release-commit.sh
index 2a4e27c10..beeb91f3a 100644
--- a/CI/prepare-release-commit.sh
+++ b/CI/prepare-release-commit.sh
@@ -10,6 +10,9 @@ git config user.name "GitHub Action"
# Update all Maven POMs in one go
./mvnw versions:set -DnewVersion="${RELEASE_VERSION}" -DgenerateBackupPoms=false
+# Update gradle.properties
+sed -i "s/version=.*/version=${RELEASE_VERSION}/" springdoc-openapi-gradle-plugin/gradle.properties
+
# Stage all changes and commit (detached)
git add -A
git commit -m "Release version ${RELEASE_VERSION}"
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..c21fc0c75
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/build.gradle
@@ -0,0 +1,88 @@
+// * * * * * * * * * * * *
+// Plugins
+// * * * * * * * * * * * *
+plugins {
+ id 'java-gradle-plugin'
+ // Publishing publicly to plugins.gradle.org
+ id 'com.gradle.plugin-publish' version '2.1.1'
+ // Publishing to maven
+ id 'maven-publish'
+}
+
+// * * * * * * * * * * * *
+// Project configuration
+// * * * * * * * * * * * *
+
+group = 'io.github.vpelikh'
+
+project.description = 'Gradle plugin that generates the OpenAPI specification for a Spring Boot app'
+project.ext.pluginId = 'io.github.vpelikh.springdoc-openapi-gradle-plugin'
+project.ext.scm = 'https://github.com/vpelikh/springdoc-openapi.git'
+project.ext.url = 'https://github.com/vpelikh/springdoc-openapi/tree/main/springdoc-openapi-gradle-plugin'
+
+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
+// * * * * * * * * * * * *
+
+// Configuration for: com.gradle.plugin-publish
+gradlePlugin {
+ website = project.ext.url
+ vcsUrl = project.ext.scm
+
+ plugins {
+ springdoc {
+ id = project.ext.pluginId
+ displayName = 'Springdoc OpenAPI Gradle Plugin'
+ description = project.description
+ implementationClass = 'org.springdoc.gradle.SpringDocOpenApiGradlePlugin'
+ tags = ['springdoc', 'openapi', 'spring-boot', 'spring', 'api']
+ }
+ }
+}
+
+publishing {
+ repositories {
+ maven {
+ url = mavenLocal().url
+ }
+ }
+}
+
+test {
+ useJUnitPlatform()
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/gradle.properties b/springdoc-openapi-gradle-plugin/gradle.properties
new file mode 100644
index 000000000..d375d330f
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/gradle.properties
@@ -0,0 +1 @@
+version=5.0.6-SNAPSHOT
\ 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 000000000..b1b8ef56b
Binary files /dev/null and b/springdoc-openapi-gradle-plugin/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/springdoc-openapi-gradle-plugin/gradle/wrapper/gradle-wrapper.properties b/springdoc-openapi-gradle-plugin/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..ad7845be3
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/springdoc-openapi-gradle-plugin/gradlew b/springdoc-openapi-gradle-plugin/gradlew
new file mode 100755
index 000000000..b9bb139f7
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/springdoc-openapi-gradle-plugin/gradlew.bat b/springdoc-openapi-gradle-plugin/gradlew.bat
new file mode 100644
index 000000000..24c62d56f
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/springdoc-openapi-gradle-plugin/settings.gradle b/springdoc-openapi-gradle-plugin/settings.gradle
new file mode 100644
index 000000000..5008d0dee
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'springdoc-openapi-gradle-plugin'
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/OpenApiGenerateExtension.java b/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/OpenApiGenerateExtension.java
new file mode 100644
index 000000000..6d6f73296
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/OpenApiGenerateExtension.java
@@ -0,0 +1,68 @@
+package org.springdoc.gradle;
+
+import org.gradle.api.file.DirectoryProperty;
+import org.gradle.api.model.ObjectFactory;
+import org.gradle.api.provider.MapProperty;
+import org.gradle.api.provider.Property;
+
+import javax.inject.Inject;
+
+/**
+ * Extension for the {@code openApiGenerate { ... }} DSL block.
+ */
+public abstract class OpenApiGenerateExtension {
+
+ private final Property outputFileName;
+ private final DirectoryProperty outputDir;
+ private final Property format;
+ private final Property mainClass;
+ private final Property timeoutSeconds;
+ private final Property skip;
+ private final MapProperty systemProperties;
+
+ @Inject
+ public OpenApiGenerateExtension(ObjectFactory objects) {
+ this.outputFileName = objects.property(String.class).convention("openapi");
+ this.outputDir = objects.directoryProperty();
+ this.format = objects.property(String.class).convention("json");
+ this.mainClass = objects.property(String.class);
+ this.timeoutSeconds = objects.property(Integer.class).convention(120);
+ this.skip = objects.property(Boolean.class).convention(false);
+ this.systemProperties = objects.mapProperty(String.class, String.class);
+ }
+
+ public Property getOutputFileName() {
+ return outputFileName;
+ }
+
+ public DirectoryProperty getOutputDir() {
+ return outputDir;
+ }
+
+ public Property getFormat() {
+ return format;
+ }
+
+ public Property getMainClass() {
+ return mainClass;
+ }
+
+ public Property getTimeoutSeconds() {
+ return timeoutSeconds;
+ }
+
+ /**
+ * Whether generation should be skipped entirely. Defaults to {@code false}.
+ */
+ public Property getSkip() {
+ return skip;
+ }
+
+ /**
+ * Additional system properties ({@code -D}) passed to the forked worker JVM, e.g.
+ * {@code spring.profiles.active=generation} or {@code spring.autoconfigure.exclude=...}.
+ */
+ public MapProperty getSystemProperties() {
+ return systemProperties;
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/SpringDocOpenApiGradlePlugin.java b/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/SpringDocOpenApiGradlePlugin.java
new file mode 100644
index 000000000..25a357214
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/SpringDocOpenApiGradlePlugin.java
@@ -0,0 +1,108 @@
+package org.springdoc.gradle;
+
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.artifacts.Configuration;
+import org.gradle.api.tasks.SourceSet;
+import org.gradle.api.tasks.SourceSetContainer;
+import org.gradle.api.tasks.TaskProvider;
+import org.gradle.jvm.toolchain.JavaToolchainService;
+import org.gradle.jvm.toolchain.JavaLauncher;
+import org.springdoc.gradle.tasks.GenerateOpenApiTask;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+/**
+ * Gradle plugin that generates the OpenAPI specification for a Spring Boot application
+ * without ever starting a web server. It forks a dedicated JVM that boots the application's
+ * reactive web context (with a no-op web server factory), lets springdoc-openapi build the
+ * spec, then writes it to disk and shuts the context down.
+ */
+public class SpringDocOpenApiGradlePlugin implements Plugin {
+
+ private static final String PLUGIN_PROPERTIES = "/springdoc-openapi-gradle-plugin.properties";
+ private static final String PLUGIN_VERSION = loadPluginVersion();
+
+ @Override
+ public void apply(Project project) {
+ // Configuration carrying the thin worker jar (and its transitive runtime deps).
+ Configuration generatorConfig = project.getConfigurations().create("springdocGenerator")
+ .setVisible(false)
+ .setTransitive(true);
+ // Deliberately does NOT add a springdoc stack API as a safety net: forcing the
+ // webflux-api jar onto the fork classpath of a WebMvc app would flip springdoc's
+ // stack detection (servlet-first) and silently generate a reactive spec for a
+ // servlet app. The target app's own runtimeClasspath already provides the stack API
+ // it needs. This keeps the fork classpath identical in shape to the Maven Mojo's.
+ generatorConfig.defaultDependencies(dependencies -> dependencies.addAll(java.util.List.of(
+ project.getDependencies().create(
+ "io.github.vpelikh:springdoc-openapi-generator-worker:" + PLUGIN_VERSION)
+ )));
+
+ OpenApiGenerateExtension extension = project.getExtensions()
+ .create("openApiGenerate", OpenApiGenerateExtension.class, project.getObjects());
+ extension.getOutputDir().convention(project.getLayout().getBuildDirectory().dir("docs"));
+
+ SourceSet mainSourceSet = project.getExtensions()
+ .getByType(SourceSetContainer.class)
+ .getByName("main");
+
+ TaskProvider task = project.getTasks().register("generateOpenApi", GenerateOpenApiTask.class, t -> {
+ t.setGroup("documentation");
+ t.setDescription("Generates the OpenAPI specification from the Spring Boot application.");
+ t.dependsOn(mainSourceSet.getClassesTaskName());
+ t.getClasspath().from(mainSourceSet.getOutput(), mainSourceSet.getRuntimeClasspath());
+ t.getGeneratorClasspath().from(generatorConfig);
+ t.getOutputDir().convention(extension.getOutputDir());
+ t.getMainClass().convention(extension.getMainClass());
+ t.getOutputFileName().convention(extension.getOutputFileName());
+ t.getFormat().convention(extension.getFormat());
+ t.getTimeoutSeconds().convention(extension.getTimeoutSeconds());
+ t.getSystemProperties().set(extension.getSystemProperties());
+ var javaExe = resolveJavaLauncherExecutable(project);
+ if (javaExe != null) {
+ t.getJavaExecutable().convention(javaExe);
+ }
+ // Allow skipping via the extension (`openApiGenerate.skip = true`).
+ t.onlyIf(spec -> !extension.getSkip().getOrElse(false));
+ });
+ }
+
+ /**
+ * Resolves the {@code java} executable from the project's Java toolchain (the same JDK used
+ * to compile the application), so the forked worker runs on the configured toolchain rather
+ * than the Gradle daemon's JVM. Falls back to the daemon JVM if there is no toolchain.
+ */
+ private static org.gradle.api.provider.Provider resolveJavaLauncherExecutable(Project project) {
+ JavaToolchainService toolchainService = project.getExtensions().findByType(JavaToolchainService.class);
+ org.gradle.api.plugins.JavaPluginExtension javaExtension =
+ project.getExtensions().findByType(org.gradle.api.plugins.JavaPluginExtension.class);
+ if (toolchainService == null || javaExtension == null || javaExtension.getToolchain() == null) {
+ return null;
+ }
+ org.gradle.api.provider.Provider launcher =
+ toolchainService.launcherFor(javaExtension.getToolchain());
+ return launcher.map(JavaLauncher::getExecutablePath);
+ }
+
+ private static String loadPluginVersion() {
+ Properties properties = new Properties();
+ try (InputStream stream = SpringDocOpenApiGradlePlugin.class.getResourceAsStream(PLUGIN_PROPERTIES)) {
+ if (stream == null) {
+ throw new IllegalStateException("Missing " + PLUGIN_PROPERTIES);
+ }
+ properties.load(stream);
+ }
+ catch (IOException e) {
+ throw new IllegalStateException("Unable to load " + PLUGIN_PROPERTIES, e);
+ }
+ String pluginVersion = properties.getProperty("plugin.version");
+ if (pluginVersion == null || pluginVersion.trim().isEmpty()
+ || (pluginVersion.startsWith("${") && pluginVersion.endsWith("}"))) {
+ throw new IllegalStateException("Unresolved plugin.version in " + PLUGIN_PROPERTIES + ": " + pluginVersion);
+ }
+ return pluginVersion;
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/tasks/GenerateOpenApiTask.java b/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/tasks/GenerateOpenApiTask.java
new file mode 100644
index 000000000..634ac94f1
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/main/java/org/springdoc/gradle/tasks/GenerateOpenApiTask.java
@@ -0,0 +1,187 @@
+package org.springdoc.gradle.tasks;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.DirectoryProperty;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.MapProperty;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.CacheableTask;
+import org.gradle.api.tasks.Classpath;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.InputFile;
+import org.gradle.api.tasks.Optional;
+import org.gradle.api.tasks.OutputDirectory;
+import org.gradle.api.tasks.PathSensitive;
+import org.gradle.api.tasks.PathSensitivity;
+import org.gradle.api.tasks.TaskAction;
+
+import javax.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Generates the OpenAPI specification by forking a dedicated JVM that boots the target Spring
+ * Boot application (WebFlux or WebMvc) against the application's own classpath, writes the spec
+ * to disk, and shuts the context down. Forking avoids contaminating the Gradle daemon or mixing
+ * classloaders.
+ */
+@CacheableTask
+public abstract class GenerateOpenApiTask extends DefaultTask {
+
+ private final ConfigurableFileCollection classpath = getProject().getObjects().fileCollection();
+ private final ConfigurableFileCollection generatorClasspath = getProject().getObjects().fileCollection();
+ private final DirectoryProperty outputDir = getProject().getObjects().directoryProperty();
+ private final Property outputFileName = getProject().getObjects().property(String.class).convention("openapi");
+ private final Property format = getProject().getObjects().property(String.class).convention("json");
+ private final Property mainClass = getProject().getObjects().property(String.class);
+ private final RegularFileProperty javaExecutable = getProject().getObjects().fileProperty();
+ private final Property timeoutSeconds = getProject().getObjects().property(Integer.class).convention(120);
+ private final MapProperty systemProperties = getProject().getObjects().mapProperty(String.class, String.class);
+
+ @Inject
+ public GenerateOpenApiTask() {
+ }
+
+ @Classpath
+ public ConfigurableFileCollection getClasspath() {
+ return classpath;
+ }
+
+ @Classpath
+ public ConfigurableFileCollection getGeneratorClasspath() {
+ return generatorClasspath;
+ }
+
+ @OutputDirectory
+ public DirectoryProperty getOutputDir() {
+ return outputDir;
+ }
+
+ @Input
+ @Optional
+ public Property getOutputFileName() {
+ return outputFileName;
+ }
+
+ @Input
+ @Optional
+ public Property getFormat() {
+ return format;
+ }
+
+ @Input
+ @Optional
+ public Property getMainClass() {
+ return mainClass;
+ }
+
+ /**
+ * The {@code java} executable to use for the forked worker. When the project applies the
+ * {@code java} plugin, this is wired to the compilation toolchain's launcher rather than the
+ * Gradle daemon JVM, keeping the worker on the same JDK as the application.
+ */
+ @InputFile
+ @Optional
+ @PathSensitive(PathSensitivity.ABSOLUTE)
+ public RegularFileProperty getJavaExecutable() {
+ return javaExecutable;
+ }
+
+ /**
+ * Upper bound (seconds) for the forked worker. Defaults to 120.
+ */
+ @Input
+ @Optional
+ public Property getTimeoutSeconds() {
+ return timeoutSeconds;
+ }
+
+ @Input
+ @Optional
+ public MapProperty getSystemProperties() {
+ return systemProperties;
+ }
+
+ @TaskAction
+ public void generate() {
+ String main = mainClass.getOrNull();
+ if (main == null || main.isBlank()) {
+ throw new GradleException("openApiGenerate.mainClass must be set to the application's @SpringBootApplication class");
+ }
+
+ File appOutput = outputDir.getAsFile().get();
+ if (!appOutput.exists() && !appOutput.mkdirs()) {
+ throw new GradleException("Could not create output dir " + appOutput);
+ }
+
+ String cpString = Stream.concat(classpath.getFiles().stream(), generatorClasspath.getFiles().stream())
+ .distinct()
+ .map(File::getAbsolutePath)
+ .collect(Collectors.joining(File.pathSeparator));
+
+ String javaBin;
+ org.gradle.api.file.RegularFile javaExeRef = javaExecutable.getOrNull();
+ File javaExe = javaExeRef != null ? javaExeRef.getAsFile() : null;
+ if (javaExe != null && javaExe.isFile()) {
+ javaBin = javaExe.getAbsolutePath();
+ }
+ else {
+ javaBin = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java";
+ }
+ List command = new ArrayList<>();
+ command.add(javaBin);
+ // Pass through user-supplied system properties (e.g. spring.profiles.active,
+ // spring.autoconfigure.exclude) so infra-dependent apps can be generated against
+ // a generation-time profile/overrides.
+ systemProperties.getOrElse(java.util.Collections.emptyMap())
+ .forEach((k, v) -> command.add("-D" + k + "=" + v));
+ command.add("-cp");
+ command.add(cpString);
+ command.add("org.springdoc.generator.GeneratorWorkerMain");
+ command.add(main);
+ command.add(appOutput.getAbsolutePath());
+ command.add(outputFileName.getOrElse("openapi"));
+ command.add(format.getOrElse("json"));
+
+ getLogger().lifecycle("Launching generator worker JVM (" + javaBin + ") for main class " + mainClass);
+ Process process = null;
+ try {
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.redirectErrorStream(true);
+ process = pb.start();
+ try (var reader = new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ getLogger().lifecycle(line);
+ }
+ }
+ int timeout = timeoutSeconds.getOrElse(120);
+ boolean finished = process.waitFor(timeout, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ throw new GradleException("Generator worker did not finish within " + timeout + "s and was terminated");
+ }
+ int exit = process.exitValue();
+ if (exit != 0) {
+ throw new GradleException("Generator worker exited with code " + exit);
+ }
+ }
+ catch (IOException e) {
+ throw new GradleException("Failed to launch generator worker: " + e.getMessage(), e);
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ if (process != null) {
+ process.destroyForcibly();
+ }
+ throw new GradleException("Generator worker interrupted", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/main/resources/springdoc-openapi-gradle-plugin.properties b/springdoc-openapi-gradle-plugin/src/main/resources/springdoc-openapi-gradle-plugin.properties
new file mode 100644
index 000000000..401723b86
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/main/resources/springdoc-openapi-gradle-plugin.properties
@@ -0,0 +1 @@
+plugin.version=${pluginVersion}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/test/java/org/springdoc/gradle/SpringDocOpenApiGradlePluginFunctionalTest.java b/springdoc-openapi-gradle-plugin/src/test/java/org/springdoc/gradle/SpringDocOpenApiGradlePluginFunctionalTest.java
new file mode 100644
index 000000000..d6e63eb09
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/test/java/org/springdoc/gradle/SpringDocOpenApiGradlePluginFunctionalTest.java
@@ -0,0 +1,214 @@
+package org.springdoc.gradle;
+
+import org.gradle.testkit.runner.BuildResult;
+import org.gradle.testkit.runner.GradleRunner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.gradle.testkit.runner.TaskOutcome.SKIPPED;
+import static org.gradle.testkit.runner.TaskOutcome.SUCCESS;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SpringDocOpenApiGradlePluginFunctionalTest {
+
+ @TempDir
+ Path testProjectDir;
+
+ private Path sampleApp;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ sampleApp = Paths.get("src/test/resources/sample-app-webflux");
+ }
+
+ @Test
+ void generatesOpenApiSpecFromReactiveApp() throws IOException {
+ // Copy sample app into the temp project
+ copyRecursively(sampleApp, testProjectDir);
+
+ // Add settings + build for the sample app, using the plugin under test
+ Files.writeString(testProjectDir.resolve("settings.gradle"), "rootProject.name = 'sample-app-webflux'\n");
+ Files.writeString(testProjectDir.resolve("build.gradle"), """
+ plugins {
+ id 'java'
+ id 'io.github.vpelikh.springdoc-openapi-gradle-plugin'
+ }
+
+ 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 = 'test.SampleApp'
+ }
+ """);
+
+ BuildResult result = GradleRunner.create()
+ .withProjectDir(testProjectDir.toFile())
+ .withArguments("generateOpenApi", "--stacktrace")
+ .withPluginClasspath()
+ .build();
+
+ assertTrue(result.task(":generateOpenApi").getOutcome() == SUCCESS);
+ Path spec = testProjectDir.resolve("build/docs/openapi.json");
+ assertTrue(Files.exists(spec), "Expected generated spec at " + spec + " but it does not exist");
+ String content = Files.readString(spec);
+ assertTrue(content.contains("/pets"), "expected /pets path in spec but was: " + content.substring(0, Math.min(200, content.length())));
+ assertTrue(content.contains("openapi") || content.contains("swagger"), "expected OpenAPI doc root");
+ }
+
+ @Test
+ void generatesOpenApiSpecFromServletApp() throws IOException {
+ copyRecursively(Paths.get("src/test/resources/sample-app-webmvc"), testProjectDir);
+
+ Files.writeString(testProjectDir.resolve("settings.gradle"), "rootProject.name = 'sample-app-webmvc'\n");
+ Files.writeString(testProjectDir.resolve("build.gradle"), """
+ plugins {
+ id 'java'
+ id 'io.github.vpelikh.springdoc-openapi-gradle-plugin'
+ }
+
+ repositories {
+ mavenLocal()
+ mavenCentral()
+ }
+
+ dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-web:4.1.1'
+ implementation 'io.github.vpelikh:springdoc-openapi-starter-webmvc-api:5.0.6-SNAPSHOT'
+ }
+
+ openApiGenerate {
+ mainClass = 'test.SampleApp'
+ systemProperties = [
+ 'spring.main.banner-mode': 'off'
+ ]
+ }
+ """);
+
+ BuildResult result = GradleRunner.create()
+ .withProjectDir(testProjectDir.toFile())
+ .withArguments("generateOpenApi", "--stacktrace")
+ .withPluginClasspath()
+ .build();
+
+ assertTrue(result.task(":generateOpenApi").getOutcome() == SUCCESS);
+ Path spec = testProjectDir.resolve("build/docs/openapi.json");
+ assertTrue(Files.exists(spec), "Expected generated webmvc spec at " + spec + " but it does not exist");
+ String content = Files.readString(spec);
+ assertTrue(content.contains("/pets"), "expected /pets path in webmvc spec but was: " + content.substring(0, Math.min(200, content.length())));
+ }
+
+ @Test
+ void skipFlagProducesNoSpec() throws IOException {
+ copyRecursively(sampleApp, testProjectDir);
+
+ Files.writeString(testProjectDir.resolve("settings.gradle"), "rootProject.name = 'sample-app-skip'\n");
+ Files.writeString(testProjectDir.resolve("build.gradle"), """
+ plugins {
+ id 'java'
+ id 'io.github.vpelikh.springdoc-openapi-gradle-plugin'
+ }
+
+ 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 = 'test.SampleApp'
+ skip = true
+ }
+ """);
+
+ BuildResult result = GradleRunner.create()
+ .withProjectDir(testProjectDir.toFile())
+ .withArguments("generateOpenApi", "--stacktrace")
+ .withPluginClasspath()
+ .build();
+
+ // The task is skipped, not exercised, and the build still succeeds.
+ assertTrue(result.task(":generateOpenApi").getOutcome() == SKIPPED,
+ "expected generateOpenApi to be SKIPPED when skip=true");
+ Path spec = testProjectDir.resolve("build/docs/openapi.json");
+ assertTrue(!Files.exists(spec), "Expected no spec at " + spec + " when skip=true");
+ }
+
+ @Test
+ void generatesOpenApiYamlFormat() throws IOException {
+ copyRecursively(sampleApp, testProjectDir);
+
+ Files.writeString(testProjectDir.resolve("settings.gradle"), "rootProject.name = 'sample-app-yaml'\n");
+ Files.writeString(testProjectDir.resolve("build.gradle"), """
+ plugins {
+ id 'java'
+ id 'io.github.vpelikh.springdoc-openapi-gradle-plugin'
+ }
+
+ 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 = 'test.SampleApp'
+ format = 'yaml'
+ }
+ """);
+
+ BuildResult result = GradleRunner.create()
+ .withProjectDir(testProjectDir.toFile())
+ .withArguments("generateOpenApi", "--stacktrace")
+ .withPluginClasspath()
+ .build();
+
+ assertTrue(result.task(":generateOpenApi").getOutcome() == SUCCESS);
+ Path spec = testProjectDir.resolve("build/docs/openapi.yaml");
+ assertTrue(Files.exists(spec), "Expected generated yaml spec at " + spec + " but it does not exist");
+ String content = Files.readString(spec);
+ assertTrue(content.contains("openapi:"), "expected YAML root marker in spec but was: " + content.substring(0, Math.min(200, content.length())));
+ assertTrue(content.contains("/pets"), "expected /pets path in yaml spec but was: " + content.substring(0, Math.min(200, content.length())));
+ // A JSON file would start with '[' or '{'; a YAML spec must not.
+ String trimmed = content.trim();
+ assertTrue(!trimmed.startsWith("{") && !trimmed.startsWith("["),
+ "expected YAML output but got JSON: " + content);
+ }
+
+ private void copyRecursively(Path source, Path target) throws IOException {
+ try (var stream = Files.walk(source)) {
+ for (Path src : (Iterable) stream::iterator) {
+ Path dest = target.resolve(source.relativize(src).toString());
+ if (Files.isDirectory(src)) {
+ Files.createDirectories(dest);
+ }
+ else {
+ Files.createDirectories(dest.getParent());
+ Files.copy(src, dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webflux/src/main/java/test/PetController.java b/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webflux/src/main/java/test/PetController.java
new file mode 100644
index 000000000..d612afce2
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webflux/src/main/java/test/PetController.java
@@ -0,0 +1,22 @@
+package test;
+
+import io.swagger.v3.oas.annotations.Operation;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+@RestController
+public class PetController {
+
+ @Operation(summary = "Get a pet by id")
+ @GetMapping("/pets/{id}")
+ public Mono getPet(@PathVariable String id) {
+ return Mono.just("pet-" + id);
+ }
+
+ @GetMapping("/pets")
+ public Mono listPets() {
+ return Mono.just("[]");
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webflux/src/main/java/test/SampleApp.java b/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webflux/src/main/java/test/SampleApp.java
new file mode 100644
index 000000000..9e71918ed
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webflux/src/main/java/test/SampleApp.java
@@ -0,0 +1,10 @@
+package test;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SampleApp {
+ public static void main(String[] args) {
+ // entry point used by the generator worker
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webmvc/src/main/java/test/PetController.java b/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webmvc/src/main/java/test/PetController.java
new file mode 100644
index 000000000..d83d9dd6a
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webmvc/src/main/java/test/PetController.java
@@ -0,0 +1,19 @@
+package test;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class PetController {
+
+ @GetMapping("/pets/{id}")
+ public String getPet(@PathVariable String id) {
+ return "pet-" + id;
+ }
+
+ @GetMapping("/pets")
+ public String listPets() {
+ return "[]";
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webmvc/src/main/java/test/SampleApp.java b/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webmvc/src/main/java/test/SampleApp.java
new file mode 100644
index 000000000..11cb40274
--- /dev/null
+++ b/springdoc-openapi-gradle-plugin/src/test/resources/sample-app-webmvc/src/main/java/test/SampleApp.java
@@ -0,0 +1,9 @@
+package test;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SampleApp {
+ public static void main(String[] args) {
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/.gitignore b/springdoc-openapi-maven-plugin/.gitignore
new file mode 100644
index 000000000..ab21548c1
--- /dev/null
+++ b/springdoc-openapi-maven-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-maven-plugin/README.md b/springdoc-openapi-maven-plugin/README.md
new file mode 100644
index 000000000..97eacc3cc
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/README.md
@@ -0,0 +1,119 @@
+# springdoc-openapi-maven-plugin
+
+A Maven 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.
+
+Reuses the shared worker module (`springdoc-openapi-generator-worker`) that the Gradle plugin
+also uses, so the two build systems share one implementation.
+
+## Usage
+
+Bind the goal to the `package` (default) phase or invoke it directly:
+
+```xml
+
+ io.github.vpelikh
+ springdoc-openapi-maven-plugin
+ 5.0.6-SNAPSHOT
+
+ com.example.YourApplication
+
+
+
+
+ generate
+
+
+
+
+```
+
+Then:
+
+```
+mvn package # or mvn springdoc:generate
+```
+
+The spec is written to `${project.build.directory}/docs/openapi.json` by default.
+
+## Goal: `generate`
+
+| Parameter | Property | Default | Description |
+|------------------|----------------------|---------------------------|------------------------------------------|
+| `mainClass` | `springdoc.mainClass` | *required* | Application's `@SpringBootApplication` base class |
+| `outputDir` | `springdoc.outputDir` | `${project.build.directory}/docs` | Directory for the generated document |
+| `outputFileName` | `springdoc.outputFileName` | `openapi` | Base file name (extension appended) |
+| `format` | `springdoc.format` | `json` | `json` or `yaml` |
+| `timeout` | `springdoc.timeout` | `120` | Worker time bound in seconds; aborts on timeout |
+| `skip` | `springdoc.skip` | `false` | Skip generation entirely (`mvn package -Dspringdoc.skip=true`) |
+| `systemProperties`| `` | `{}` | Extra `-D` props for the worker JVM |
+
+## How it works
+
+1. The Mojo collects the project's runtime classpath (plus compiled output and the shared
+ `springdoc-openapi-generator-worker` jar, resolved at the plugin's own version).
+2. It forks a JVM running `org.springdoc.generator.GeneratorWorkerMain`.
+3. The worker detects the app's stack from its classpath and dispatches to the matching
+ generator:
+ - WebFlux: a no-op `ReactiveWebServerFactory` keeps springdoc active with **no port bound**.
+ - WebMvc: the embedded server starts on an **ephemeral port** and is shut down immediately.
+4. It invokes springdoc's matching `OpenApi*Resource` with a mock request, writes the JSON/YAML
+ document, and shuts the context down.
+
+### Generating apps that need infrastructure
+
+The worker boots the application's real context, so beans needing external resources (a database,
+JMS broker, external service) must be satisfiable at generation time. Use `systemProperties` to
+point generation at a test profile/overrides, e.g. for a JPA/Hibernate app:
+
+```xml
+
+ com.example.App
+
+ generation
+
+ org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
+
+
+
+```
+
+The `-D` properties reach the forked worker (verified: `spring.profiles.active` activates in the
+worker JVM), so Spring reads annotations off the classpath without needing a real connection.
+
+## Notes / limitations
+
+- **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.
+- 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.
+
+## Building the plugin
+
+From the repo root (with the fork modules already installed):
+
+```
+mvn -pl springdoc-openapi-generator-worker,springdoc-openapi-maven-plugin -am install -DskipTests
+```
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/pom.xml b/springdoc-openapi-maven-plugin/pom.xml
new file mode 100644
index 000000000..d52e13d0f
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/pom.xml
@@ -0,0 +1,81 @@
+
+ 4.0.0
+
+ io.github.vpelikh
+ springdoc-openapi
+ 5.0.6-SNAPSHOT
+
+ springdoc-openapi-maven-plugin
+ maven-plugin
+ ${project.artifactId}
+ A Maven plugin to generate the OpenAPI specification from a Spring Boot application without leaving a web server running.
+
+
+
+
+ org.apache.maven
+ maven-plugin-api
+ 3.9.6
+ provided
+
+
+ org.apache.maven.plugin-tools
+ maven-plugin-annotations
+ 3.13.1
+ provided
+
+
+ org.apache.maven
+ maven-core
+ 3.9.6
+ provided
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-plugin-plugin
+ 3.13.1
+
+ springdoc
+
+
+
+ default-descriptor
+
+ descriptor
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-invoker-plugin
+ 3.10.1
+
+ ${project.build.directory}/it
+ ${settings.localRepository}
+ verify
+ true
+
+ ${project.version}
+
+
+
+
+ integration-test
+
+ install
+ integration-test
+ verify
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-jpa/pom.xml b/springdoc-openapi-maven-plugin/src/it/generate-jpa/pom.xml
new file mode 100644
index 000000000..eedc7f019
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-jpa/pom.xml
@@ -0,0 +1,61 @@
+
+ 4.0.0
+ test
+ springdoc-generate-jpa-it
+ 1.0.0
+ jar
+
+
+ 17
+ UTF-8
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+ 4.1.1
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+ 4.1.1
+
+
+ com.h2database
+ h2
+ 2.3.232
+ runtime
+
+
+ io.github.vpelikh
+ springdoc-openapi-starter-webmvc-api
+ ${springdoc.version}
+
+
+
+
+
+
+ io.github.vpelikh
+ springdoc-openapi-maven-plugin
+ ${springdoc.version}
+
+ it.App
+
+ jdbc:h2:mem:gen;DB_CLOSE_DELAY=-1
+ org.h2.Driver
+ create-drop
+
+
+
+
+
+ generate
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/App.java b/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/App.java
new file mode 100644
index 000000000..438310580
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/App.java
@@ -0,0 +1,9 @@
+package it;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class App {
+ public static void main(String[] args) {
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/Pet.java b/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/Pet.java
new file mode 100644
index 000000000..39554309e
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/Pet.java
@@ -0,0 +1,31 @@
+package it;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+
+@Entity
+public class Pet {
+
+ @Id
+ @GeneratedValue
+ private Long id;
+
+ private String name;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/PetController.java b/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/PetController.java
new file mode 100644
index 000000000..2572b84a8
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-jpa/src/main/java/it/PetController.java
@@ -0,0 +1,21 @@
+package it;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class PetController {
+
+ @GetMapping("/pets/{id}")
+ public Pet getPet(Long id) {
+ Pet pet = new Pet();
+ pet.setId(id);
+ pet.setName("pet-" + id);
+ return pet;
+ }
+
+ @GetMapping("/pets")
+ public String listPets() {
+ return "[]";
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-jpa/verify.groovy b/springdoc-openapi-maven-plugin/src/it/generate-jpa/verify.groovy
new file mode 100644
index 000000000..de6d5a5d2
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-jpa/verify.groovy
@@ -0,0 +1,21 @@
+//
+// Post-build hook for the maven-invoker-plugin 'generate-jpa' IT.
+// Verifies the plugin generated a spec for an app whose context needs a real DataSource
+// (spring-boot-starter-data-jpa + Hibernate), using the spring.datasource.* overrides passed
+// through systemProperties. It also checks the JPA @Entity was reflected as a schema.
+//
+def spec = new File(basedir, 'target/docs/openapi.json')
+if (!spec.isFile()) {
+ throw new FileNotFoundException('Expected generated OpenAPI at ' + spec)
+}
+
+def content = spec.text
+if (!content.contains('/pets') || !content.contains('/pets/{id}')) {
+ throw new IllegalStateException('Generated OpenAPI is missing the /pets paths: ' + content)
+}
+// The Pet entity should be reflected as a schema (proves JPA annotations were read).
+if (!content.contains('"Pet"')) {
+ throw new IllegalStateException('Generated OpenAPI is missing the JPA Pet schema: ' + content)
+}
+
+println 'Verified JPA (Hibernate + DataSource override) OpenAPI spec: ' + spec
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-skip/pom.xml b/springdoc-openapi-maven-plugin/src/it/generate-skip/pom.xml
new file mode 100644
index 000000000..27c833262
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-skip/pom.xml
@@ -0,0 +1,46 @@
+
+ 4.0.0
+ test
+ springdoc-generate-skip-it
+ 1.0.0
+ jar
+
+
+ 17
+ UTF-8
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+ 4.1.1
+
+
+ io.github.vpelikh
+ springdoc-openapi-starter-webflux-api
+ ${springdoc.version}
+
+
+
+
+
+
+ io.github.vpelikh
+ springdoc-openapi-maven-plugin
+ ${springdoc.version}
+
+ it.App
+ true
+
+
+
+
+ generate
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-skip/src/main/java/it/App.java b/springdoc-openapi-maven-plugin/src/it/generate-skip/src/main/java/it/App.java
new file mode 100644
index 000000000..438310580
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-skip/src/main/java/it/App.java
@@ -0,0 +1,9 @@
+package it;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class App {
+ public static void main(String[] args) {
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-skip/src/main/java/it/PetController.java b/springdoc-openapi-maven-plugin/src/it/generate-skip/src/main/java/it/PetController.java
new file mode 100644
index 000000000..c889f3795
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-skip/src/main/java/it/PetController.java
@@ -0,0 +1,19 @@
+package it;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+@RestController
+public class PetController {
+
+ @GetMapping("/pets/{id}")
+ public Mono getPet(String id) {
+ return Mono.just("pet-" + id);
+ }
+
+ @GetMapping("/pets")
+ public Mono listPets() {
+ return Mono.just("[]");
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-skip/verify.groovy b/springdoc-openapi-maven-plugin/src/it/generate-skip/verify.groovy
new file mode 100644
index 000000000..3e81632b0
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-skip/verify.groovy
@@ -0,0 +1,16 @@
+//
+// Post-build hook for the maven-invoker-plugin 'generate-skip' IT.
+// Verifies that setting true skips generation entirely: no spec document
+// is produced and the build (which still runs the bound goal on the package phase)
+// succeeds rather than requiring mainClass/context boot.
+//
+def docsDir = new File(basedir, 'target/docs')
+if (docsDir.exists()) {
+ def leftovers = docsDir.listFiles()
+ if (leftovers != null && leftovers.length > 0) {
+ throw new IllegalStateException('Expected no generated documents when skip=true but found: '
+ + leftovers*.name.join(', '))
+ }
+}
+
+println 'Verified: skip=true produced no OpenAPI document'
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-webflux/pom.xml b/springdoc-openapi-maven-plugin/src/it/generate-webflux/pom.xml
new file mode 100644
index 000000000..c19fcbab4
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-webflux/pom.xml
@@ -0,0 +1,45 @@
+
+ 4.0.0
+ test
+ springdoc-generate-webflux-it
+ 1.0.0
+ jar
+
+
+ 17
+ UTF-8
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+ 4.1.1
+
+
+ io.github.vpelikh
+ springdoc-openapi-starter-webflux-api
+ ${springdoc.version}
+
+
+
+
+
+
+ io.github.vpelikh
+ springdoc-openapi-maven-plugin
+ ${springdoc.version}
+
+ it.App
+
+
+
+
+ generate
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-webflux/src/main/java/it/App.java b/springdoc-openapi-maven-plugin/src/it/generate-webflux/src/main/java/it/App.java
new file mode 100644
index 000000000..438310580
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-webflux/src/main/java/it/App.java
@@ -0,0 +1,9 @@
+package it;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class App {
+ public static void main(String[] args) {
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-webflux/src/main/java/it/PetController.java b/springdoc-openapi-maven-plugin/src/it/generate-webflux/src/main/java/it/PetController.java
new file mode 100644
index 000000000..c889f3795
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-webflux/src/main/java/it/PetController.java
@@ -0,0 +1,19 @@
+package it;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+@RestController
+public class PetController {
+
+ @GetMapping("/pets/{id}")
+ public Mono getPet(String id) {
+ return Mono.just("pet-" + id);
+ }
+
+ @GetMapping("/pets")
+ public Mono listPets() {
+ return Mono.just("[]");
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-webflux/verify.groovy b/springdoc-openapi-maven-plugin/src/it/generate-webflux/verify.groovy
new file mode 100644
index 000000000..7d9cce16c
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-webflux/verify.groovy
@@ -0,0 +1,15 @@
+//
+// Post-build hook for the maven-invoker-plugin 'generate-webflux' IT.
+// Verifies that the plugin produced target/docs/openapi.json with the expected paths.
+//
+def spec = new File(basedir, 'target/docs/openapi.json')
+if (!spec.isFile()) {
+ throw new FileNotFoundException('Expected generated OpenAPI at ' + spec)
+}
+
+def content = spec.text
+if (!content.contains('/pets') || !content.contains('/pets/{id}')) {
+ throw new IllegalStateException('Generated OpenAPI is missing the /pets paths: ' + content)
+}
+
+println 'Verified OpenAPI spec: ' + spec
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-webmvc/pom.xml b/springdoc-openapi-maven-plugin/src/it/generate-webmvc/pom.xml
new file mode 100644
index 000000000..ded8e343d
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-webmvc/pom.xml
@@ -0,0 +1,48 @@
+
+ 4.0.0
+ test
+ springdoc-generate-webmvc-it
+ 1.0.0
+ jar
+
+
+ 17
+ UTF-8
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+ 4.1.1
+
+
+ io.github.vpelikh
+ springdoc-openapi-starter-webmvc-api
+ ${springdoc.version}
+
+
+
+
+
+
+ io.github.vpelikh
+ springdoc-openapi-maven-plugin
+ ${springdoc.version}
+
+ it.App
+
+ generation
+
+
+
+
+
+ generate
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-webmvc/src/main/java/it/App.java b/springdoc-openapi-maven-plugin/src/it/generate-webmvc/src/main/java/it/App.java
new file mode 100644
index 000000000..438310580
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-webmvc/src/main/java/it/App.java
@@ -0,0 +1,9 @@
+package it;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class App {
+ public static void main(String[] args) {
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-webmvc/src/main/java/it/PetController.java b/springdoc-openapi-maven-plugin/src/it/generate-webmvc/src/main/java/it/PetController.java
new file mode 100644
index 000000000..509838c25
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-webmvc/src/main/java/it/PetController.java
@@ -0,0 +1,18 @@
+package it;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class PetController {
+
+ @GetMapping("/pets/{id}")
+ public String getPet(String id) {
+ return "pet-" + id;
+ }
+
+ @GetMapping("/pets")
+ public String listPets() {
+ return "[]";
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-webmvc/verify.groovy b/springdoc-openapi-maven-plugin/src/it/generate-webmvc/verify.groovy
new file mode 100644
index 000000000..b84b59bb0
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-webmvc/verify.groovy
@@ -0,0 +1,15 @@
+//
+// Post-build hook for the maven-invoker-plugin 'generate-webmvc' IT.
+// Verifies that the plugin produced target/docs/openapi.json with the expected paths.
+//
+def spec = new File(basedir, 'target/docs/openapi.json')
+if (!spec.isFile()) {
+ throw new FileNotFoundException('Expected generated OpenAPI at ' + spec)
+}
+
+def content = spec.text
+if (!content.contains('/pets') || !content.contains('/pets/{id}')) {
+ throw new IllegalStateException('Generated OpenAPI is missing the /pets paths: ' + content)
+}
+
+println 'Verified WebMvc OpenAPI spec: ' + spec
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-yaml/pom.xml b/springdoc-openapi-maven-plugin/src/it/generate-yaml/pom.xml
new file mode 100644
index 000000000..f22feca58
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-yaml/pom.xml
@@ -0,0 +1,46 @@
+
+ 4.0.0
+ test
+ springdoc-generate-yaml-it
+ 1.0.0
+ jar
+
+
+ 17
+ UTF-8
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+ 4.1.1
+
+
+ io.github.vpelikh
+ springdoc-openapi-starter-webflux-api
+ ${springdoc.version}
+
+
+
+
+
+
+ io.github.vpelikh
+ springdoc-openapi-maven-plugin
+ ${springdoc.version}
+
+ it.App
+ yaml
+
+
+
+
+ generate
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-yaml/src/main/java/it/App.java b/springdoc-openapi-maven-plugin/src/it/generate-yaml/src/main/java/it/App.java
new file mode 100644
index 000000000..438310580
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-yaml/src/main/java/it/App.java
@@ -0,0 +1,9 @@
+package it;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class App {
+ public static void main(String[] args) {
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-yaml/src/main/java/it/PetController.java b/springdoc-openapi-maven-plugin/src/it/generate-yaml/src/main/java/it/PetController.java
new file mode 100644
index 000000000..c889f3795
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-yaml/src/main/java/it/PetController.java
@@ -0,0 +1,19 @@
+package it;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+@RestController
+public class PetController {
+
+ @GetMapping("/pets/{id}")
+ public Mono getPet(String id) {
+ return Mono.just("pet-" + id);
+ }
+
+ @GetMapping("/pets")
+ public Mono listPets() {
+ return Mono.just("[]");
+ }
+}
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/it/generate-yaml/verify.groovy b/springdoc-openapi-maven-plugin/src/it/generate-yaml/verify.groovy
new file mode 100644
index 000000000..a30c0534f
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/it/generate-yaml/verify.groovy
@@ -0,0 +1,24 @@
+//
+// Post-build hook for the maven-invoker-plugin 'generate-yaml' IT.
+// Verifies that yaml produces target/docs/openapi.yaml (not .json)
+// containing the expected paths and a YAML-ish body.
+//
+def spec = new File(basedir, 'target/docs/openapi.yaml')
+if (!spec.isFile()) {
+ throw new FileNotFoundException('Expected generated OpenAPI YAML at ' + spec)
+}
+
+def content = spec.text
+if (!content.contains('openapi:')) {
+ throw new IllegalStateException('Generated file does not look like OpenAPI YAML: ' + content)
+}
+if (!content.contains('/pets') || !content.contains('/pets/{id}')) {
+ throw new IllegalStateException('Generated OpenAPI YAML is missing the /pets paths: ' + content)
+}
+
+// A JSON file would start with '{' or '['; a YAML spec should not.
+if (content.trim().startsWith('{') || content.contains('"openapi"')) {
+ throw new IllegalStateException('Expected YAML output but found JSON instead: ' + content)
+}
+
+println 'Verified OpenAPI YAML spec: ' + spec
\ No newline at end of file
diff --git a/springdoc-openapi-maven-plugin/src/main/java/org/springdoc/maven/GenerateOpenApiMojo.java b/springdoc-openapi-maven-plugin/src/main/java/org/springdoc/maven/GenerateOpenApiMojo.java
new file mode 100644
index 000000000..ef512567c
--- /dev/null
+++ b/springdoc-openapi-maven-plugin/src/main/java/org/springdoc/maven/GenerateOpenApiMojo.java
@@ -0,0 +1,236 @@
+package org.springdoc.maven;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.maven.artifact.DependencyResolutionRequiredException;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugins.annotations.Component;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+import org.eclipse.aether.RepositorySystem;
+import org.eclipse.aether.artifact.DefaultArtifact;
+import org.eclipse.aether.collection.CollectRequest;
+import org.eclipse.aether.graph.Dependency;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.resolution.ArtifactResult;
+import org.eclipse.aether.resolution.DependencyRequest;
+import org.eclipse.aether.resolution.DependencyResolutionException;
+import org.eclipse.aether.resolution.DependencyResult;
+
+/**
+ * Maven goal that generates the OpenAPI specification for a Spring Boot application without
+ * leaving a web server running. It forks a dedicated JVM (using the project's runtime
+ * classpath) that boots the application's reactive web context on an ephemeral port, lets
+ * springdoc-openapi build the document, writes it to disk, and shuts the context down.
+ */
+@Mojo(name = "generate", defaultPhase = LifecyclePhase.PACKAGE, requiresDependencyResolution = ResolutionScope.RUNTIME)
+public class GenerateOpenApiMojo extends AbstractMojo {
+
+ /**
+ * The Maven project.
+ */
+ @Parameter(defaultValue = "${project}", readonly = true, required = true)
+ private MavenProject project;
+
+ /**
+ * The Maven session.
+ */
+ @Parameter(defaultValue = "${session}", readonly = true, required = true)
+ private MavenSession session;
+
+ /**
+ * Aether repository system for resolving the worker artifact.
+ */
+ @Component
+ private RepositorySystem repositorySystem;
+
+ /**
+ * Fully-qualified name of the application's {@code @SpringBootApplication} class.
+ */
+ @Parameter(property = "springdoc.mainClass", required = true)
+ private String mainClass;
+
+ /**
+ * Directory for the generated document. Defaults to {@code ${project.build.directory}/docs}.
+ */
+ @Parameter(defaultValue = "${project.build.directory}/docs", property = "springdoc.outputDir")
+ private File outputDir;
+
+ /**
+ * Base file name of the generated document (without extension).
+ */
+ @Parameter(defaultValue = "openapi", property = "springdoc.outputFileName")
+ private String outputFileName;
+
+ /**
+ * Output format: {@code json} or {@code yaml}.
+ */
+ @Parameter(defaultValue = "json", property = "springdoc.format")
+ private String format;
+
+ /**
+ * The plugin's own version, used to resolve the matching generator-worker artifact.
+ */
+ @Parameter(defaultValue = "${plugin.version}", readonly = true, required = true)
+ private String pluginVersion;
+
+ /**
+ * Upper bound (seconds) for the forked worker. Defaults to 120.
+ */
+ @Parameter(defaultValue = "120", property = "springdoc.timeout")
+ private int timeout;
+
+ /**
+ * Skip generation entirely. Useful to disable the bound goal without removing the plugin,
+ * e.g. {@code mvn package -Dspringdoc.skip=true}.
+ */
+ @Parameter(defaultValue = "false", property = "springdoc.skip")
+ private boolean skip;
+
+ /**
+ * Additional system properties ({@code -D}) passed to the forked worker JVM, e.g.
+ * {@code spring.profiles.active=generation} or {@code spring.autoconfigure.exclude=...}.
+ */
+ @Parameter
+ private Map systemProperties;
+
+ @Override
+ public void execute() throws MojoExecutionException {
+ if (skip) {
+ getLog().info("Springdoc: generation skipped (springdoc.skip=true)");
+ return;
+ }
+ if (mainClass == null || mainClass.isBlank()) {
+ throw new MojoExecutionException("springdoc.mainClass must be set to the application's @SpringBootApplication class");
+ }
+ if (!outputDir.exists() && !outputDir.mkdirs()) {
+ throw new MojoExecutionException("Could not create output dir " + outputDir);
+ }
+
+ List classpath = new ArrayList<>();
+ try {
+ classpath.add(project.getBuild().getOutputDirectory());
+ classpath.addAll(project.getRuntimeClasspathElements());
+ }
+ catch (DependencyResolutionRequiredException e) {
+ throw new MojoExecutionException("Could not resolve runtime classpath", e);
+ }
+
+ // The fork uses the thin shared generator-worker jar (boots the app, exposes
+ // GeneratorWorkerMain) plus its transitive runtime dependencies (spring-test for the
+ // mock request). Resolved at the plugin's own version.
+ List workerJars = resolveWorkerClasspath();
+ classpath.addAll(workerJars);
+
+ // keep order & dedupe
+ Set dedup = new LinkedHashSet<>(classpath);
+ String cp = String.join(File.pathSeparator, dedup);
+
+ String javaBin = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java";
+ List command = new ArrayList<>();
+ command.add(javaBin);
+ // Pass through user-supplied system properties (e.g. spring.profiles.active,
+ // spring.autoconfigure.exclude) so infra-dependent apps can be generated against
+ // a generation-time profile/overrides.
+ if (systemProperties != null) {
+ systemProperties.forEach((k, v) -> command.add("-D" + k + "=" + v));
+ }
+ command.add("-cp");
+ command.add(cp);
+ command.add("org.springdoc.generator.GeneratorWorkerMain");
+ command.add(mainClass);
+ command.add(outputDir.getAbsolutePath());
+ command.add(outputFileName);
+ command.add(format);
+
+ getLog().info("Springdoc: generating OpenAPI spec for main class " + mainClass);
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.redirectErrorStream(true);
+ Process process = null;
+ try {
+ process = pb.start();
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ getLog().info(line);
+ }
+ }
+ boolean finished = process.waitFor(timeout, java.util.concurrent.TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ throw new MojoExecutionException("Springdoc generator worker did not finish within "
+ + timeout + "s and was terminated");
+ }
+ int exit = process.exitValue();
+ if (exit != 0) {
+ throw new MojoExecutionException("Springdoc generator worker exited with code " + exit);
+ }
+ }
+ catch (IOException e) {
+ if (process != null) {
+ process.destroyForcibly();
+ }
+ throw new MojoExecutionException("Failed to launch springdoc generator worker", e);
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ if (process != null) {
+ process.destroyForcibly();
+ }
+ throw new MojoExecutionException("Springdoc generator worker interrupted", e);
+ }
+ }
+
+ /**
+ * Resolves the {@code springdoc-openapi-generator-worker} artifact and its transitive runtime
+ * dependencies from the build session's repositories (or the reactor). Fails the build if it
+ * cannot be resolved, since generation is impossible without it.
+ *
+ * @return absolute paths to the worker jar and its runtime dependencies
+ * @throws MojoExecutionException if resolution fails
+ */
+ private List resolveWorkerClasspath() throws MojoExecutionException {
+ try {
+ Dependency root = new Dependency(
+ new DefaultArtifact("io.github.vpelikh",
+ "springdoc-openapi-generator-worker", "jar", pluginVersion),
+ "runtime");
+ java.util.List repos =
+ session.getCurrentProject().getRemoteProjectRepositories();
+ CollectRequest collect = new CollectRequest(root, repos);
+ DependencyRequest request = new DependencyRequest(collect, null);
+ DependencyResult result = repositorySystem.resolveDependencies(session.getRepositorySession(), request);
+ List paths = new ArrayList<>();
+ for (ArtifactResult artifact : result.getArtifactResults()) {
+ paths.add(artifact.getArtifact().getFile().getAbsolutePath());
+ }
+ if (paths.isEmpty()) {
+ throw new MojoExecutionException("Could not resolve springdoc generator worker at "
+ + pluginVersion);
+ }
+ return paths;
+ }
+ catch (DependencyResolutionException e) {
+ throw new MojoExecutionException(
+ "Could not resolve springdoc-openapi-generator-worker:" + pluginVersion
+ + ". Ensure the artifact is installed (e.g. via the springdoc Maven build) "
+ + "or available in the configured repositories.",
+ e);
+ }
+ }
+}
\ No newline at end of file