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: + *

+ * 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