diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 4fdd333..f00604c 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -27,7 +27,7 @@ jobs: distribution: temurin - name: 🦞 chmod /gradlew - run: chmod +x ./gradlew + run: chmod +x ./gradlew - name: 🔦 Test run: ./gradlew test --info diff --git a/.run/TEST.run.xml b/.run/TEST.run.xml index 7376a7c..e4d820e 100644 --- a/.run/TEST.run.xml +++ b/.run/TEST.run.xml @@ -1,33 +1,34 @@ - - - - - - - - - - - + + + + + + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e76c74..b600025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [UnReleased] - + ## Added diff --git a/CODE_OF_CONDUCT.adoc b/CODE_OF_CONDUCT.adoc index 4ecea8e..50ea179 100644 --- a/CODE_OF_CONDUCT.adoc +++ b/CODE_OF_CONDUCT.adoc @@ -1,14 +1,8 @@ = Contributor Code of Conduct -As contributors and maintainers of this project, and in the interest of fostering an open -and welcoming community, we pledge to respect all people who contribute through reporting -issues, posting feature requests, updating documentation, submitting pull requests or -patches, and other activities. +As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. -We are committed to making participation in this project a harassment-free experience for -everyone, regardless of level of experience, gender, gender identity and expression, -sexual orientation, disability, personal appearance, body size, race, ethnicity, age, -religion, or nationality. +We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. Examples of unacceptable behavior by participants include: @@ -16,23 +10,15 @@ Examples of unacceptable behavior by participants include: * Personal attacks * Trolling or insulting/derogatory comments * Public or private harassment -* Publishing other's private information, such as physical or electronic addresses, - without explicit permission +* Publishing other's private information, such as physical or electronic addresses, without explicit permission * Other unethical or unprofessional conduct -Project maintainers have the right and responsibility to remove, edit, or reject comments, -commits, code, wiki edits, issues, and other contributions that are not aligned to this -Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors -that they deem inappropriate, threatening, offensive, or harmful. +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. -By adopting this Code of Conduct, project maintainers commit themselves to fairly and -consistently applying these principles to every aspect of managing this project. Project -maintainers who do not follow or enforce the Code of Conduct may be permanently removed -from the project team. - -This Code of Conduct applies both within project spaces and in public spaces when an -individual is representing the project or its community. +By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. +Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. This Code of Conduct is adapted from the https://contributor-covenant.org[Contributor Covenant], version 1.3.0, available at diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index 17cbe59..5c2d7c0 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -1,99 +1,90 @@ = Contributing to springdoc-openapi -springdoc-openapi is released under the Apache 2.0 license. If you would like to contribute -something, or simply want to hack on the code this document should help you get started. - - +springdoc-openapi is released under the Apache 2.0 license. +If you would like to contribute something, or simply want to hack on the code this document should help you get started. == Code of Conduct -This project adheres to the Contributor Covenant link:CODE_OF_CONDUCT.adoc[code of -conduct]. By participating, you are expected to uphold this code. +This project adheres to the Contributor Covenant link:CODE_OF_CONDUCT.adoc[code of +conduct]. +By participating, you are expected to uphold this code. == Using GitHub Issues -We use GitHub issues to track bugs and enhancements. If you have a general usage question -please ask on https://stackoverflow.com[Stack Overflow]. The springdoc-openapi team and the -broader community monitor the https://stackoverflow.com/tags/springdoc[`springdoc`] + +We use GitHub issues to track bugs and enhancements. +If you have a general usage question please ask on https://stackoverflow.com[Stack Overflow]. +The springdoc-openapi team and the broader community monitor the https://stackoverflow.com/tags/springdoc[`springdoc`] tag. -These are some basic guidelines before opening an issue. First of all you need to make sure, you don't create duplicate issues, and there no question already answred on https://stackoverflow.com/tags/springdoc. +These are some basic guidelines before opening an issue. +First of all you need to make sure, you don't create duplicate issues, and there no question already answred on https://stackoverflow.com/tags/springdoc. If you are starting using springdoc-openapi, we advise you to use the last available release. Then refer to the relevant documentation: 1. For OpenAPI specification 3: - - https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md +- https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md 2. For swagger2-annotations: - - https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations +- https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations 3. For swagger-ui configuration: - - https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md +- https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md 4. For springdoc-openapi: - - https://springdoc.github.io/springdoc-openapi-demos/ - - https://springdoc.github.io/springdoc-openapi-demos/faq.html - - https://springdoc.github.io/springdoc-openapi-demos/migrating-from-springfox.html - +- https://springdoc.github.io/springdoc-openapi-demos/ +- https://springdoc.github.io/springdoc-openapi-demos/faq.html +- https://springdoc.github.io/springdoc-openapi-demos/migrating-from-springfox.html -If you are reporting a bug, please help to speed up problem diagnosis by providing as -much information as possible: - - You need to describe your context (the title of an issue is not enough) - - What version of spring-boot you are using? - - What modules and versions of springdoc-openapi are you using? - - What are the actual and the expected result using OpenAPI Description (yml or json)? - - Provide with a sample code (HelloController) or Test that reproduces the problem +If you are reporting a bug, please help to speed up problem diagnosis by providing as much information as possible: +- You need to describe your context (the title of an issue is not enough) +- What version of spring-boot you are using? +- What modules and versions of springdoc-openapi are you using? +- What are the actual and the expected result using OpenAPI Description (yml or json)? +- Provide with a sample code (HelloController) or Test that reproduces the problem == Reporting Security Vulnerabilities -If you think you have found a security vulnerability in Spring Boot please *DO NOT* -disclose it publicly until we've had a chance to fix it. Please don't report security -vulnerabilities using GitHub issues, instead head over to support@springdoc.org and -learn how to disclose them responsibly. +If you think you have found a security vulnerability in Spring Boot please *DO NOT* +disclose it publicly until we've had a chance to fix it. +Please don't report security vulnerabilities using GitHub issues, instead head over to support@springdoc.org and learn how to disclose them responsibly. == Code Conventions and Housekeeping -None of these is essential for a pull request, but they will all help. They can also be -added after the original pull request but before a merge. - -* We use the https://github.com/spring-io/spring-javaformat/[Spring JavaFormat] project - to apply code formatting conventions. If you use Eclipse and you follow the '`Importing - into eclipse`' instructions below you should get project specific formatting - automatically. You can also install the - https://github.com/spring-io/spring-javaformat/#intellij-idea[Spring JavaFormat IntelliJ - Plugin] + +None of these is essential for a pull request, but they will all help. +They can also be added after the original pull request but before a merge. + +* We use the https://github.com/spring-io/spring-javaformat/[Spring JavaFormat] project to apply code formatting conventions. +If you use Eclipse and you follow the '`Importing into eclipse`' instructions below you should get project specific formatting automatically. +You can also install the +https://github.com/spring-io/spring-javaformat/#intellij-idea[Spring JavaFormat IntelliJ + Plugin] * Make sure all new `.java` files to have a simple Javadoc class comment with at least an - `@author` tag identifying you, and preferably at least a paragraph on what the class is - for. -* Add the ASF license header comment to all new `.java` files (copy from existing files - in the project) -* Add yourself as an `@author` to the `.java` files that you modify substantially (more - than cosmetic changes). +`@author` tag identifying you, and preferably at least a paragraph on what the class is for. +* Add the ASF license header comment to all new `.java` files (copy from existing files in the project) +* Add yourself as an `@author` to the `.java` files that you modify substantially (more than cosmetic changes). * Add some Javadocs. * A few unit tests would help a lot as well -- someone has to do it. -* If no-one else is using your branch, please rebase it against the current master (or - other target branch in the main project). -* When writing a commit message please follow https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[these conventions], - if you are fixing an existing issue please add `Fixes #XXXX` at the end of the commit - message (where `XXXX` is the issue number). - - +* If no-one else is using your branch, please rebase it against the current master (or other target branch in the main project). +* When writing a commit message please follow https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[these conventions], if you are fixing an existing issue please add `Fixes #XXXX` at the end of the commit message (where `XXXX` is the issue number). == Working with the Code + If you don't have an IDE preference we would recommend that you use IntellIJ. === Importing into IntelliJ IDEA -If you have performed a checkout of this repository already, use "`File`" -> "`Open`" and -then select the root `build.gradle` file to import the code. -Alternatively, you can let IntellIJ IDEA checkout the code for you. Use "`File`" -> -"`New`" -> "`Project from Version Control`" and -`https://github.com/spring-projects/spring-boot` for the URL. Once the checkout has -completed, a pop-up will suggest to open the project. +If you have performed a checkout of this repository already, use "`File`" -> "`Open`" and then select the root `build.gradle` file to import the code. +Alternatively, you can let IntellIJ IDEA checkout the code for you. +Use "`File`" -> +"`New`" -> "`Project from Version Control`" and +`https://github.com/spring-projects/spring-boot` for the URL. +Once the checkout has completed, a pop-up will suggest to open the project. ==== Install the Spring Formatter plugin -If you haven't done so, install the formatter plugin so that proper formatting rules are -applied automatically when you reformat code in the IDE. + +If you haven't done so, install the formatter plugin so that proper formatting rules are applied automatically when you reformat code in the IDE. * Download the latest https://search.maven.org/search?q=g:io.spring.javaformat%20AND%20a:spring-javaformat-intellij-plugin[IntelliJ IDEA plugin]. * Select "`IntelliJ IDEA`" -> "`Preferences`". @@ -101,10 +92,9 @@ applied automatically when you reformat code in the IDE. * Select the wheel and "`Install Plugin from Disk...`". * Select the jar file you've downloaded. - ==== Import additional code style -The formatter does not cover all rules (such as order of imports) and an additional file -needs to be added. + +The formatter does not cover all rules (such as order of imports) and an additional file needs to be added. * Select "`IntelliJ IDEA`" -> "`Preferences`". * Select "`Editor`" -> "`Code Style`". @@ -115,18 +105,18 @@ needs to be added. You can use Spring Boot project specific source formatting settings. - ===== Install the Spring Formatter plugin + * Select "`Help`" -> "`Install New Software`". * Add `https://repo.spring.io/javaformat-eclipse-update-site/` as a site. * Install "Spring Java Format". -NOTE: The plugin is optional. Projects can be imported without the plugins, your code -changes just won't be automatically formatted. +NOTE: The plugin is optional. +Projects can be imported without the plugins, your code changes just won't be automatically formatted. === Building from Source -springdoc-openapi source can be built from the command line using https://maven.apache.org/[Maven] on -JDK 1.8 or above. + +springdoc-openapi source can be built from the command line using https://maven.apache.org/[Maven] on JDK 1.8 or above. The project can be built from the root directory using the standard maven command: @@ -135,10 +125,10 @@ The project can be built from the root directory using the standard maven comman $ ./mvn install ---- - == Cloning the git repository on Windows -Some files in the git repository may exceed the Windows maximum file path (260 -characters), depending on where you clone the repository. If you get `Filename too long` + +Some files in the git repository may exceed the Windows maximum file path (260 characters), depending on where you clone the repository. +If you get `Filename too long` errors, set the `core.longPaths=true` git option: ``` diff --git a/README.md b/README.md index fc5d941..b9b06ed 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ -[![Build Status](https://travis-ci.org/springdoc/springdoc-openapi-gradle-plugin.svg?branch=master)](https://travis-ci.org/springdoc/springdoc-openapi-gradle-plugin) -[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=springdoc_springdoc-openapi-gradle-plugin&metric=alert_status)](https://sonarcloud.io/dashboard?id=springdoc_springdoc-openapi-gradle-plugin) +[![Build Status](https://ci-cd.springdoc.org:8443/buildStatus/icon?job=springdoc-openapi-gradle-IC)](https://ci-cd.springdoc.org:8443/view/springdoc-openapi/job/springdoc-openapi-gradle-IC/) # Introducing springdoc-openapi-gradle-plugin Gradle plugin for springdoc-openapi. -This plugin allows you to generate an OpenAPI 3 specification for a Spring Boot application from a Gradle build. +This plugin allows you to generate an OpenAPI 3 specification for a Spring Boot +application from a Gradle build. Compatibility Notes ------------------- -The plugin is built on Gradle version 7.0. +The plugin is built on Gradle version 7.0. Dependencies ------------ @@ -18,7 +18,6 @@ This plugin has a runtime dependency on the following plugins: 1. Spring Boot Gradle plugin - `org.springframework.boot` 2. Gradle process plugin - `com.github.psxpaul.execfork` - How To Use ---------- @@ -27,43 +26,50 @@ Gradle Groovy DSL ```groovy plugins { id "org.springframework.boot" version "2.7.0" - id "org.springdoc.openapi-gradle-plugin" version "1.6.0" + id "org.springdoc.openapi-gradle-plugin" version "1.7.0" } ``` Gradle Kotlin DSL + ```groovy plugins { id("org.springframework.boot") version "2.7.0" - id("org.springdoc.openapi-gradle-plugin") version "1.6.0" + id("org.springdoc.openapi-gradle-plugin") version "1.7.0" } ``` -Note: For latest versions of the plugins please check the [Gradle Plugins portal](https://plugins.gradle.org/). +Note: For latest versions of the plugins please check +the [Gradle Plugins portal](https://plugins.gradle.org/). How the plugin works? ------------ -When you add this plugin and its runtime dependency plugins to your build file, the plugin creates the following tasks: +When you add this plugin and its runtime dependency plugins to your build file, the plugin +creates the following tasks: 1. forkedSpringBootRun 2. generateOpenApiDocs -Running the task `generateOpenApiDocs` writes the OpenAPI spec into a `openapi.json` file in your project's build dir. +Running the task `generateOpenApiDocs` writes the OpenAPI spec into a `openapi.json` file +in your project's build dir. ```bash gradle clean generateOpenApiDocs ``` -When you run the gradle task **generateOpenApiDocs**, it starts your spring boot application in the background using **forkedSpringBootRun** task. -Once your application is up and running **generateOpenApiDocs** makes a rest call to your applications doc url to download and store the open api docs file as json. +When you run the gradle task **generateOpenApiDocs**, it starts your spring boot +application in the background using **forkedSpringBootRun** task. +Once your application is up and running **generateOpenApiDocs** makes a rest call to your +applications doc url to download and store the open api docs file as json. Customization ------------- -The following customizations can be done on task generateOpenApiDocs using extension openApi as follows +The following customizations can be done on task generateOpenApiDocs using extension +openApi as follows ```kotlin openApi { @@ -83,19 +89,26 @@ openApi { |----------------------|-------------------------------------------------------------------------------------------------------------------------------------|----------|--------------------------------------| | `apiDocsUrl` | The URL from where the OpenAPI doc can be downloaded | No | http://localhost:8080/v3/api-docs | | `outputDir` | The output directory for the generated OpenAPI file | No | $buildDir - Your project's build dir | -| `outputFileName` | The name of the output file with extension | No | openapi.json | +| `outputFileName` | Specifies the output file name and format. Use extension `.yaml` for YAML output, `.json` for JSON output | No | openapi.json | | `waitTimeInSeconds` | Time to wait in seconds for your Spring Boot application to start, before we make calls to `apiDocsUrl` to download the OpenAPI doc | No | 30 seconds | | `groupedApiMappings` | A map of URLs (from where the OpenAPI docs can be downloaded) to output file names | No | [] | | `customBootRun` | Any bootRun property that you would normal need to start your spring boot application. | No | (N/A) | ### `customBootRun` properties examples -`customBootRun` allows you to send in the properties that might be necessary to allow for the forked spring boot application that gets started + +`customBootRun` allows you to send in the properties that might be necessary to allow for +the forked spring boot application that gets started to be able to start (profiles, other custom properties, etc.) -`customBootRun` allows you can specify bootRun style parameter, such as `args`, `jvmArgs`, `systemProperties` and `workingDir`. -If you don't specify `customBootRun` parameter, this plugin uses the parameter specified to `bootRun` in Spring Boot Gradle Plugin. +`customBootRun` allows you can specify bootRun style parameter, such +as `args`, `jvmArgs`, `systemProperties` and `workingDir`. +If you don't specify `customBootRun` parameter, this plugin uses the parameter specified +to `bootRun` in Spring Boot Gradle Plugin. #### Passing static args -This allows for you to be able to just send the static properties when executing Spring application in `generateOpenApiDocs`. + +This allows for you to be able to just send the static properties when executing Spring +application in `generateOpenApiDocs`. + ``` openApi { customBootRun { @@ -105,11 +118,14 @@ openApi { ``` #### Passing straight from gradle -This allows for you to be able to just send in whatever you need when you generate docs. + +This allows for you to be able to just send in whatever you need when you generate docs. `./gradlew clean generateOpenApiDocs -Dspring.profiles.active=special` -and as long as the config looks as follows that value will be passed into the forked spring boot application. +and as long as the config looks as follows that value will be passed into the forked +spring boot application. + ``` openApi { customBootRun { @@ -119,21 +135,30 @@ openApi { ``` ### Grouped API Mappings Notes -The `groupedApiMappings` customization allows you to specify multiple URLs/file names for use within this plugin. This configures the plugin to ignore the `apiDocsUrl` and `outputFileName` parameters and only use those found in `groupedApiMappings`. The plugin will then attempt to download each OpenAPI doc in turn as it would for a single OpenAPI doc. + +The `groupedApiMappings` customization allows you to specify multiple URLs/file names for +use within this plugin. This configures the plugin to ignore the `apiDocsUrl` +and `outputFileName` parameters and only use those found in `groupedApiMappings`. The +plugin will then attempt to download each OpenAPI doc in turn as it would for a single +OpenAPI doc. # Building the plugin + 1. Clone the repo `git@github.com:springdoc/springdoc-openapi-gradle-plugin.git` -2. Build and publish the plugin into your local maven repository by running the following +2. Build and publish the plugin into your local maven repository by running the following ``` ./gradlew clean pTML ``` - + # Testing the plugin -1. Create a new spring boot application or use an existing spring boot app and follow the `How To Use` section above to configure this plugin. -2. Update the version for the plugin to match the current version found in `build.gradle.kts` + +1. Create a new spring boot application or use an existing spring boot app and follow + the `How To Use` section above to configure this plugin. +2. Update the version for the plugin to match the current version found + in `build.gradle.kts` ``` - id("org.springdoc.openapi-gradle-plugin") version "1.6.0" + id("org.springdoc.openapi-gradle-plugin") version "1.7.0" ``` 3. Add the following to the spring boot apps `settings.gradle` @@ -149,6 +174,7 @@ The `groupedApiMappings` customization allows you to specify multiple URLs/file # **Thank you for the support** -* Thanks a lot [JetBrains](https://www.jetbrains.com/?from=springdoc-openapi) for supporting springdoc-openapi project. +* Thanks a lot [JetBrains](https://www.jetbrains.com/?from=springdoc-openapi) for + supporting springdoc-openapi project. -![JenBrains logo](https://springdoc.org/images/jetbrains.svg) +![JetBrains logo](https://springdoc.org/img/jetbrains.svg) diff --git a/build.gradle.kts b/build.gradle.kts index 3a9b4f2..cf775f1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,15 +1,15 @@ plugins { `java-gradle-plugin` - id("com.gradle.plugin-publish") version "0.14.0" + id("com.gradle.plugin-publish") version "1.2.0" id("org.sonarqube") version "3.1.1" - kotlin("jvm") version "1.4.31" + kotlin("jvm") version "1.8.20" `maven-publish` id("com.github.ben-manes.versions") version "0.38.0" - id("io.gitlab.arturbosch.detekt") version "1.16.0" + id("io.gitlab.arturbosch.detekt") version "1.23.1" } group = "org.springdoc" -version = "1.6.0" +version = "1.7.0" sonarqube { properties { @@ -17,12 +17,12 @@ sonarqube { } } repositories { + gradlePluginPortal() mavenCentral() maven { name = "Spring Repositories" url = uri("https://repo.spring.io/libs-release/") } - gradlePluginPortal() maven { name = "Gradle Plugins Maven Repository" url = uri("https://plugins.gradle.org/m2/") @@ -33,9 +33,17 @@ publishing { repositories { maven { // change URLs to point to your repos, e.g. http://my.org/repo - val releasesRepoUrl = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") - val snapshotsRepoUrl = uri("https://oss.sonatype.org/content/repositories/snapshots") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl + val releasesRepoUrl = + uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") + val snapshotsRepoUrl = + uri("https://oss.sonatype.org/content/repositories/snapshots") + url = if (version.toString() + .endsWith("SNAPSHOT") + ) { + snapshotsRepoUrl + } else { + releasesRepoUrl + } credentials { username = System.getenv("OSSRH_USER") password = System.getenv("OSSRH_PASS") @@ -46,55 +54,57 @@ publishing { dependencies { implementation(kotlin("reflect")) - implementation("com.google.code.gson:gson:2.8.6") + implementation("com.google.code.gson:gson:2.8.9") implementation("org.awaitility:awaitility-kotlin:4.0.3") implementation("com.github.psxpaul:gradle-execfork-plugin:0.2.0") - implementation("org.springframework.boot:spring-boot-gradle-plugin:2.5.6") + implementation("org.springframework.boot:spring-boot-gradle-plugin:2.7.14") testImplementation(gradleTestKit()) testImplementation(platform("org.junit:junit-bom:5.7.1")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("com.beust:klaxon:5.5") - testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.2") + testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2") testImplementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.13.2") - detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.16.0") + detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.1") } gradlePlugin { + website = "https://github.com/springdoc/springdoc-openapi-gradle-plugin" + vcsUrl = "https://github.com/springdoc/springdoc-openapi-gradle-plugin.git" plugins { create("springdoc-gradle-plugin") { id = "org.springdoc.openapi-gradle-plugin" displayName = "A Gradle plugin for the springdoc-openapi library" description = " This plugin uses springdoc-openapi to generate an OpenAPI description at build time" implementationClass = "org.springdoc.openapi.gradle.plugin.OpenApiGradlePlugin" + tags = listOf("springdoc", "openapi", "swagger") } } } -pluginBundle { - website = "https://github.com/springdoc/springdoc-openapi-gradle-plugin" - vcsUrl = "https://github.com/springdoc/springdoc-openapi-gradle-plugin.git" - tags = listOf("springdoc", "openapi", "swagger") -} - val jvmVersion: JavaLanguageVersion = JavaLanguageVersion.of(8) +java { + toolchain.languageVersion.set(jvmVersion) +} + tasks.withType { kotlinOptions { - jvmTarget = "1.${jvmVersion.toString()}" + jvmTarget = "1.$jvmVersion" } } tasks.withType().configureEach { useJUnitPlatform() - maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 + maxParallelForks = + (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 } detekt { - config = files("config/detekt/detekt.yml") + config.setFrom("config/detekt/detekt.yml") parallel = true } tasks.withType().configureEach { - jvmTarget = "1.${jvmVersion.toString()}" + jvmTarget = "1.$jvmVersion" } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index d145605..37e6a2c 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -206,6 +206,7 @@ exceptions: - NumberFormatException - ParseException - MalformedURLException + - UnknownDomainObjectException allowedExceptionNameRegex: '_|(ignore|expected).*' ThrowingExceptionFromFinally: active: true @@ -230,7 +231,6 @@ exceptions: - IllegalMonitorStateException - NullPointerException - IndexOutOfBoundsException - - RuntimeException - Throwable allowedExceptionNameRegex: '_|(ignore|expected).*' TooGenericExceptionThrown: @@ -325,10 +325,6 @@ formatting: PackageName: active: true autoCorrect: true - ParameterListWrapping: - active: true - autoCorrect: true - indentSize: 4 SpacingAroundAngleBrackets: active: false autoCorrect: true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f371643..9f4197d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/main.yml b/main.yml new file mode 100644 index 0000000..f00604c --- /dev/null +++ b/main.yml @@ -0,0 +1,33 @@ +name: Test CI +on: [ push, fork ] + +jobs: + TEST_ALL: + runs-on: ubuntu-latest + strategy: + matrix: + java: [ '11', '17' ] + steps: + - name: Checkout + uses: actions/checkout@v3 + + - uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: 🪜 Setup java ${{ matrix.java }} + uses: actions/setup-java@v3 + with: + java-version: ${{ matrix.java }} + distribution: temurin + + - name: 🦞 chmod /gradlew + run: chmod +x ./gradlew + + - name: 🔦 Test + run: ./gradlew test --info diff --git a/settings.gradle.kts b/settings.gradle.kts index 1012b6d..0331211 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,8 @@ rootProject.name = "springdoc-openapi-gradle-plugin" pluginManagement { - repositories { - mavenCentral() - gradlePluginPortal() - } + repositories { + mavenCentral() + gradlePluginPortal() + } } diff --git a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiExtension.kt b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiExtension.kt index 95a587a..136ecb9 100644 --- a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiExtension.kt +++ b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiExtension.kt @@ -1,35 +1,41 @@ package org.springdoc.openapi.gradle.plugin import org.gradle.api.Action -import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import javax.inject.Inject -open class OpenApiExtension @Inject constructor(project: Project) { - val apiDocsUrl: Property = project.objects.property(String::class.java) - val outputFileName: Property = project.objects.property(String::class.java) - val outputDir: DirectoryProperty = project.objects.directoryProperty() - val waitTimeInSeconds: Property = project.objects.property(Int::class.java) - val groupedApiMappings: MapProperty = project.objects.mapProperty(String::class.java, String::class.java) - val customBootRun: CustomBootRunAction = project.objects.newInstance(CustomBootRunAction::class.java, project) - fun customBootRun(action: Action) { - action.execute(customBootRun) - } +open class OpenApiExtension @Inject constructor( + objects: ObjectFactory, +) { + val apiDocsUrl: Property = objects.property(String::class.java) + val outputFileName: Property = objects.property(String::class.java) + val outputDir: DirectoryProperty = objects.directoryProperty() + val waitTimeInSeconds: Property = objects.property(Int::class.java) + val groupedApiMappings: MapProperty = + objects.mapProperty(String::class.java, String::class.java) + val customBootRun: CustomBootRunAction = + objects.newInstance(CustomBootRunAction::class.java) + + fun customBootRun(action: Action) { + action.execute(customBootRun) + } } open class CustomBootRunAction @Inject constructor( - project: Project, + objects: ObjectFactory, ) { - val systemProperties: MapProperty = project.objects.mapProperty(String::class.java, Any::class.java) - val workingDir: RegularFileProperty = project.objects.fileProperty() - val mainClass: Property = project.objects.property(String::class.java) - val args: ListProperty = project.objects.listProperty(String::class.java) - val classpath: ConfigurableFileCollection = project.objects.fileCollection() - val jvmArgs: ListProperty = project.objects.listProperty(String::class.java) - val environment: MapProperty = project.objects.mapProperty(String::class.java, Any::class.java) + val systemProperties: MapProperty = + objects.mapProperty(String::class.java, Any::class.java) + val workingDir: DirectoryProperty = objects.directoryProperty() + val mainClass: Property = objects.property(String::class.java) + val args: ListProperty = objects.listProperty(String::class.java) + val classpath: ConfigurableFileCollection = objects.fileCollection() + val jvmArgs: ListProperty = objects.listProperty(String::class.java) + val environment: MapProperty = + objects.mapProperty(String::class.java, Any::class.java) } diff --git a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGeneratorTask.kt b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGeneratorTask.kt index 957ff86..1a7d6f5 100644 --- a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGeneratorTask.kt +++ b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGeneratorTask.kt @@ -2,9 +2,14 @@ package org.springdoc.openapi.gradle.plugin import com.google.gson.GsonBuilder import com.google.gson.JsonObject +import com.google.gson.JsonSyntaxException import org.awaitility.Durations import org.awaitility.core.ConditionTimeoutException -import org.awaitility.kotlin.* +import org.awaitility.kotlin.atMost +import org.awaitility.kotlin.await +import org.awaitility.kotlin.ignoreException +import org.awaitility.kotlin.until +import org.awaitility.kotlin.withPollInterval import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty @@ -18,84 +23,108 @@ import java.net.HttpURLConnection import java.net.URL import java.time.Duration import java.time.temporal.ChronoUnit.SECONDS +import java.util.* private const val MAX_HTTP_STATUS_CODE = 299 open class OpenApiGeneratorTask : DefaultTask() { - @get:Input - val apiDocsUrl: Property = project.objects.property(String::class.java) + @get:Input + val apiDocsUrl: Property = project.objects.property(String::class.java) - @get:Input - val outputFileName: Property = project.objects.property(String::class.java) + @get:Input + val outputFileName: Property = project.objects.property(String::class.java) - @get:Input - val groupedApiMappings: MapProperty = - project.objects.mapProperty(String::class.java, String::class.java) + @get:Input + val groupedApiMappings: MapProperty = + project.objects.mapProperty(String::class.java, String::class.java) - @get:OutputDirectory - val outputDir: DirectoryProperty = project.objects.directoryProperty() - private val waitTimeInSeconds: Property = project.objects.property(Int::class.java) + @get:OutputDirectory + val outputDir: DirectoryProperty = project.objects.directoryProperty() + private val waitTimeInSeconds: Property = + project.objects.property(Int::class.java) - init { - description = OPEN_API_TASK_DESCRIPTION - group = GROUP_NAME - // load my extensions - val extension: OpenApiExtension = project.extensions.run { getByName(EXTENSION_NAME) as OpenApiExtension } + init { + description = OPEN_API_TASK_DESCRIPTION + group = GROUP_NAME + // load my extensions + val extension: OpenApiExtension = + project.extensions.getByName(EXTENSION_NAME) as OpenApiExtension - // set a default value if not provided - val defaultOutputDir = project.objects.directoryProperty() - defaultOutputDir.set(project.buildDir) + // set a default value if not provided + val defaultOutputDir = project.objects.directoryProperty() + defaultOutputDir.set(project.buildDir) - apiDocsUrl.set(extension.apiDocsUrl.getOrElse(DEFAULT_API_DOCS_URL)) - outputFileName.set(extension.outputFileName.getOrElse(DEFAULT_OPEN_API_FILE_NAME)) - groupedApiMappings.set(extension.groupedApiMappings.getOrElse(emptyMap())) - outputDir.set(extension.outputDir.getOrElse(defaultOutputDir.get())) - waitTimeInSeconds.set(extension.waitTimeInSeconds.getOrElse(DEFAULT_WAIT_TIME_IN_SECONDS)) - } + apiDocsUrl.convention(extension.apiDocsUrl.getOrElse(DEFAULT_API_DOCS_URL)) + outputFileName.convention( + extension.outputFileName.getOrElse( + DEFAULT_OPEN_API_FILE_NAME + ) + ) + groupedApiMappings.convention(extension.groupedApiMappings.getOrElse(emptyMap())) + outputDir.convention(extension.outputDir.getOrElse(defaultOutputDir.get())) + waitTimeInSeconds.convention( + extension.waitTimeInSeconds.getOrElse( + DEFAULT_WAIT_TIME_IN_SECONDS + ) + ) + } - @TaskAction - fun execute() { - if (groupedApiMappings.isPresent && groupedApiMappings.get().isNotEmpty()) { - groupedApiMappings.get().forEach(this::generateApiDocs) - } else { - generateApiDocs(apiDocsUrl.get(), outputFileName.get()) - } - } + @TaskAction + fun execute() { + if (groupedApiMappings.isPresent && groupedApiMappings.get().isNotEmpty()) { + groupedApiMappings.get().forEach(this::generateApiDocs) + } else { + generateApiDocs(apiDocsUrl.get(), outputFileName.get()) + } + } - private fun generateApiDocs(url: String, fileName: String) { - try { - val isYaml = url.toLowerCase().matches(Regex(".+[./]yaml(/.+)*")) - await ignoreException ConnectException::class withPollInterval Durations.ONE_SECOND atMost Duration.of( - waitTimeInSeconds.get().toLong(), - SECONDS - ) until { - val connection: HttpURLConnection = URL(url).openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connect() - val statusCode = connection.responseCode - logger.trace("apiDocsUrl = {} status code = {}", url, statusCode) - statusCode < MAX_HTTP_STATUS_CODE - } - logger.info("Generating OpenApi Docs..") - val connection: HttpURLConnection = URL(url).openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connect() + private fun generateApiDocs(url: String, fileName: String) { + try { + val isYaml = url.toLowerCase().matches(Regex(".+[./]yaml(/.+)*")) + await ignoreException ConnectException::class withPollInterval Durations.ONE_SECOND atMost Duration.of( + waitTimeInSeconds.get().toLong(), + SECONDS + ) until { + val connection: HttpURLConnection = + URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connect() + val statusCode = connection.responseCode + logger.trace("apiDocsUrl = {} status code = {}", url, statusCode) + statusCode < MAX_HTTP_STATUS_CODE + } + logger.info("Generating OpenApi Docs..") + val connection: HttpURLConnection = + URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connect() - val response = String(connection.inputStream.readBytes(), Charsets.UTF_8) + val response = String(connection.inputStream.readBytes(), Charsets.UTF_8) - val apiDocs = if (isYaml) response else prettifyJson(response) + val apiDocs = if (isYaml) response else prettifyJson(response) - val outputFile = outputDir.file(fileName).get().asFile - outputFile.writeText(apiDocs) - } catch (e: ConditionTimeoutException) { - this.logger.error("Unable to connect to $url waited for ${waitTimeInSeconds.get()} seconds", e) - throw GradleException("Unable to connect to $url waited for ${waitTimeInSeconds.get()} seconds") - } - } + val outputFile = outputDir.file(fileName).get().asFile + outputFile.writeText(apiDocs) + } catch (e: ConditionTimeoutException) { + this.logger.error( + "Unable to connect to $url waited for ${waitTimeInSeconds.get()} seconds", + e + ) + throw GradleException("Unable to connect to $url waited for ${waitTimeInSeconds.get()} seconds") + } + } - private fun prettifyJson(response: String): String { - val gson = GsonBuilder().setPrettyPrinting().create() - val googleJsonObject = gson.fromJson(response, JsonObject::class.java) - return gson.toJson(googleJsonObject) - } + private fun prettifyJson(response: String): String { + val gson = GsonBuilder().setPrettyPrinting().create() + try { + val googleJsonObject = gson.fromJson(response, JsonObject::class.java) + return gson.toJson(googleJsonObject) + } catch (e: RuntimeException) { + throw JsonSyntaxException( + "Failed to parse the API docs response string. " + + "Please ensure that the response is in the correct format. response=$response", + e + ) + } + } } diff --git a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePlugin.kt b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePlugin.kt index 5899e2c..a4abb62 100644 --- a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePlugin.kt +++ b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePlugin.kt @@ -4,92 +4,113 @@ import com.github.psxpaul.task.JavaExecFork import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task -import org.gradle.api.logging.Logging +import org.gradle.api.UnknownDomainObjectException import org.gradle.api.tasks.TaskProvider import org.gradle.internal.jvm.Jvm import org.springframework.boot.gradle.tasks.run.BootRun open class OpenApiGradlePlugin : Plugin { - private val logger = Logging.getLogger(OpenApiGradlePlugin::class.java) - override fun apply(project: Project) { - with(project) { - // Run time dependency on the following plugins - plugins.apply(SPRING_BOOT_PLUGIN) - plugins.apply(EXEC_FORK_PLUGIN) + override fun apply(project: Project) { + with(project) { + // Run time dependency on the following plugins + plugins.apply(SPRING_BOOT_PLUGIN) + plugins.apply(EXEC_FORK_PLUGIN) - extensions.create(EXTENSION_NAME, OpenApiExtension::class.java, this) + extensions.create(EXTENSION_NAME, OpenApiExtension::class.java) + tasks.register(FORKED_SPRING_BOOT_RUN_TASK_NAME, JavaExecFork::class.java) + tasks.register(OPEN_API_TASK_NAME, OpenApiGeneratorTask::class.java) - afterEvaluate { generate(this) } - } - } + generate(this) + } + } - private fun generate(project: Project) = project.run { - springBoot3CompatibilityCheck() + private fun generate(project: Project) = project.run { + springBoot3CompatibilityCheck() - // The task, used to run the Spring Boot application (`bootRun`) - val bootRunTask = tasks.named(SPRING_BOOT_RUN_TASK_NAME) - // The task, used to resolve the application's main class (`bootRunMainClassName`) - val bootRunMainClassNameTask = tasks.find { it.name == SPRING_BOOT_RUN_MAIN_CLASS_NAME_TASK_NAME} - ?:tasks.named(SPRING_BOOT_3_RUN_MAIN_CLASS_NAME_TASK_NAME) + // The task, used to run the Spring Boot application (`bootRun`) + val bootRunTask = tasks.named(SPRING_BOOT_RUN_TASK_NAME) + // The task, used to resolve the application's main class (`bootRunMainClassName`) + val bootRunMainClassNameTask = + try { + val task = tasks.named(SPRING_BOOT_RUN_MAIN_CLASS_NAME_TASK_NAME) + logger.debug( + "Detected Spring Boot task {}", + SPRING_BOOT_RUN_MAIN_CLASS_NAME_TASK_NAME + ) + task + } catch (e: UnknownDomainObjectException) { + val task = tasks.named(SPRING_BOOT_3_RUN_MAIN_CLASS_NAME_TASK_NAME) + logger.debug( + "Detected Spring Boot task {}", + SPRING_BOOT_3_RUN_MAIN_CLASS_NAME_TASK_NAME + ) + task + } - val extension = extensions.findByName(EXTENSION_NAME) as OpenApiExtension - val customBootRun = extension.customBootRun - // Create a forked version spring boot run task - val forkedSpringBoot = tasks.register(FORKED_SPRING_BOOT_RUN_TASK_NAME, JavaExecFork::class.java) { fork -> - fork.dependsOn(bootRunMainClassNameTask) - fork.onlyIf { needToFork(bootRunTask, customBootRun, fork) } - } + val extension = extensions.findByName(EXTENSION_NAME) as OpenApiExtension + val customBootRun = extension.customBootRun + // Create a forked version spring boot run task + val forkedSpringBoot = tasks.named( + FORKED_SPRING_BOOT_RUN_TASK_NAME, + JavaExecFork::class.java + ) { fork -> + fork.dependsOn(tasks.named(bootRunMainClassNameTask.name)) + fork.onlyIf { needToFork(bootRunTask, customBootRun, fork) } + } - // This is my task. Before I can run it, I have to run the dependent tasks - tasks.register(OPEN_API_TASK_NAME, OpenApiGeneratorTask::class.java) { - it.dependsOn(forkedSpringBoot) - } + // This is my task. Before I can run it, I have to run the dependent tasks + val openApiTask = + tasks.named(OPEN_API_TASK_NAME, OpenApiGeneratorTask::class.java) { + it.dependsOn(forkedSpringBoot) + } - // The forked task need to be terminated as soon as my task is finished - forkedSpringBoot.get().stopAfter = tasks.named(OPEN_API_TASK_NAME) - } + // The forked task need to be terminated as soon as my task is finished + forkedSpringBoot.get().stopAfter = openApiTask as TaskProvider + } - private fun Project.springBoot3CompatibilityCheck() { - val tasksNames = tasks.names - val boot2TaskName = "bootRunMainClassName" - val boot3TaskName = "resolveMainClassName" - if (!tasksNames.contains(boot2TaskName) && tasksNames.contains(boot3TaskName)) - tasks.register(boot2TaskName) { it.dependsOn(tasks.named(boot3TaskName)) } - } + private fun Project.springBoot3CompatibilityCheck() { + val tasksNames = tasks.names + val boot2TaskName = "bootRunMainClassName" + val boot3TaskName = "resolveMainClassName" + if (!tasksNames.contains(boot2TaskName) && tasksNames.contains(boot3TaskName)) { + tasks.register(boot2TaskName) { it.dependsOn(tasks.named(boot3TaskName)) } + } + } - private fun needToFork( - bootRunTask: TaskProvider, - customBootRun: CustomBootRunAction, - fork: JavaExecFork - ): Boolean { - val bootRun = bootRunTask.get() as BootRun + private fun needToFork( + bootRunTask: TaskProvider, + customBootRun: CustomBootRunAction, + fork: JavaExecFork + ): Boolean { + val bootRun = bootRunTask.get() as BootRun - val baseSystemProperties = customBootRun.systemProperties.orNull?.takeIf { it.isNotEmpty() } - ?: bootRun.systemProperties - with(fork) { - // copy all system properties, excluding those starting with `java.class.path` - systemProperties = baseSystemProperties.filter { - !it.key.startsWith(CLASS_PATH_PROPERTY_NAME) - } + val baseSystemProperties = + customBootRun.systemProperties.orNull?.takeIf { it.isNotEmpty() } + ?: bootRun.systemProperties + with(fork) { + // copy all system properties, excluding those starting with `java.class.path` + systemProperties = baseSystemProperties.filter { + !it.key.startsWith(CLASS_PATH_PROPERTY_NAME) + } - // use original bootRun parameter if the list-type customBootRun properties are empty - workingDir = customBootRun.workingDir.asFile.orNull - ?: bootRun.workingDir - args = customBootRun.args.orNull?.takeIf { it.isNotEmpty() }?.toMutableList() - ?: bootRun.args?.toMutableList() ?: mutableListOf() - classpath = customBootRun.classpath.takeIf { !it.isEmpty } - ?: bootRun.classpath - main = customBootRun.mainClass.orNull - ?: bootRun.mainClass.get() - jvmArgs = customBootRun.jvmArgs.orNull?.takeIf { it.isNotEmpty() } - ?: bootRun.jvmArgs - environment = customBootRun.environment.orNull?.takeIf { it.isNotEmpty() } - ?: bootRun.environment - if (Jvm.current().toString().startsWith("1.8")) { - killDescendants = false - } - } - return true - } + // use original bootRun parameter if the list-type customBootRun properties are empty + workingDir = customBootRun.workingDir.asFile.orNull + ?: fork.workingDir + args = customBootRun.args.orNull?.takeIf { it.isNotEmpty() }?.toMutableList() + ?: bootRun.args?.toMutableList() ?: mutableListOf() + classpath = customBootRun.classpath.takeIf { !it.isEmpty } + ?: bootRun.classpath + main = customBootRun.mainClass.orNull + ?: bootRun.mainClass.get() + jvmArgs = customBootRun.jvmArgs.orNull?.takeIf { it.isNotEmpty() } + ?: bootRun.jvmArgs + environment = customBootRun.environment.orNull?.takeIf { it.isNotEmpty() } + ?: bootRun.environment + if (Jvm.current().toString().startsWith("1.8")) { + killDescendants = false + } + } + return true + } } diff --git a/src/test/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePluginTest.kt b/src/test/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePluginTest.kt index b1912a3..192e7b1 100644 --- a/src/test/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePluginTest.kt +++ b/src/test/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePluginTest.kt @@ -11,25 +11,28 @@ import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.slf4j.Logger +import org.slf4j.LoggerFactory import java.io.File import java.io.FileReader import java.nio.file.Files class OpenApiGradlePluginTest { - private val projectTestDir = Files.createTempDirectory("acceptance-project").toFile() - private val buildFile = File(projectTestDir, "build.gradle") - private val projectBuildDir = File(projectTestDir, "build") + private val projectTestDir = Files.createTempDirectory("acceptance-project").toFile() + private val buildFile = File(projectTestDir, "build.gradle") + private val projectBuildDir = File(projectTestDir, "build") - private val pathsField = "paths" - private val openapiField = "openapi" + private val pathsField = "paths" + private val openapiField = "openapi" - private val baseBuildGradle = """plugins { + private val baseBuildGradle = """plugins { id 'java' id 'org.springframework.boot' version '2.7.6' - id 'io.spring.dependency-management' version '1.1.15.RELEASE' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' id 'org.springdoc.openapi-gradle-plugin' } @@ -47,101 +50,112 @@ class OpenApiGradlePluginTest { } """.trimIndent() - @BeforeEach - fun createTemporaryAcceptanceProjectFromTemplate() { - File(javaClass.classLoader.getResource("acceptance-project")!!.path).copyRecursively(projectTestDir) - } + companion object { + val logger: Logger = LoggerFactory.getLogger(OpenApiGradlePluginTest::class.java) + } - @Test - fun `default build no options`() { - buildFile.writeText(baseBuildGradle) + @BeforeEach + fun createTemporaryAcceptanceProjectFromTemplate() { + File(javaClass.classLoader.getResource("acceptance-project")!!.path).copyRecursively( + projectTestDir + ) + } - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiJsonFile(1) - } + @Test + fun `default build no options`() { + buildFile.writeText(baseBuildGradle) - @Test - fun `different output dir`() { - val specialOutputDir = File(projectTestDir, "specialDir") - specialOutputDir.mkdirs() + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1) + } - buildFile.writeText( - """$baseBuildGradle + @Test + fun `different output dir`() { + val specialOutputDir = File(projectTestDir, "specialDir") + specialOutputDir.mkdirs() + + buildFile.writeText( + """$baseBuildGradle openApi{ outputDir = file("${specialOutputDir.toURI().path}") } """.trimMargin() - ) + ) - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiJsonFile(1, buildDir = specialOutputDir) - } + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1, buildDir = specialOutputDir) + } - @Test - fun `different output file name`() { - val specialOutputJsonFileName = RandomStringUtils.randomAlphanumeric(15) + @Test + fun `different output file name`() { + val specialOutputJsonFileName = RandomStringUtils.randomAlphanumeric(15) - buildFile.writeText( - """$baseBuildGradle + buildFile.writeText( + """$baseBuildGradle openApi{ outputFileName = "$specialOutputJsonFileName" } """.trimMargin() - ) + ) - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiJsonFile(1, specialOutputJsonFileName) - } + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1, specialOutputJsonFileName) + } - @Test - fun `using properties`() { - buildFile.writeText( - """$baseBuildGradle + @Test + fun `using properties`() { + buildFile.writeText( + """$baseBuildGradle bootRun { args = ["--spring.profiles.active=multiple-endpoints", "--some.second.property=someValue"] } """.trimMargin() - ) + ) - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiJsonFile(3) - } + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(3) + } - @Test - fun `using forked properties via System properties`() { - buildFile.writeText( - """$baseBuildGradle + @Test + fun `using forked properties via System properties`() { + buildFile.writeText( + """$baseBuildGradle bootRun { systemProperties = System.properties } """.trimMargin() - ) - - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild("-Dspring.profiles.active=multiple-endpoints")).outcome) - assertOpenApiJsonFile(2) - } - - @Test - fun `using forked properties via System properties with customBootRun`() { - buildFile.writeText( - """$baseBuildGradle + ) + + assertEquals( + TaskOutcome.SUCCESS, + openApiDocsTask(runTheBuild("-Dspring.profiles.active=multiple-endpoints")).outcome + ) + assertOpenApiJsonFile(2) + } + + @Test + fun `using forked properties via System properties with customBootRun`() { + buildFile.writeText( + """$baseBuildGradle openApi { customBootRun { systemProperties = System.properties } } """.trimMargin() - ) - - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild("-Dspring.profiles.active=multiple-endpoints")).outcome) - assertOpenApiJsonFile(2) - } - - - @Test - fun `configurable wait time`() { - buildFile.writeText( - """$baseBuildGradle + ) + + assertEquals( + TaskOutcome.SUCCESS, + openApiDocsTask(runTheBuild("-Dspring.profiles.active=multiple-endpoints")).outcome + ) + assertOpenApiJsonFile(2) + } + + @Test + fun `configurable wait time`() { + buildFile.writeText( + """$baseBuildGradle bootRun { args = ["--spring.profiles.active=slower"] } @@ -149,16 +163,16 @@ class OpenApiGradlePluginTest { waitTimeInSeconds = 60 } """.trimMargin() - ) + ) - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiJsonFile(1) - } + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1) + } - @Test - fun `using different api url`() { - buildFile.writeText( - """$baseBuildGradle + @Test + fun `using different api url`() { + buildFile.writeText( + """$baseBuildGradle bootRun { args = ["--spring.profiles.active=different-url"] } @@ -166,16 +180,16 @@ class OpenApiGradlePluginTest { apiDocsUrl = "http://localhost:8080/secret-api-docs" } """.trimMargin() - ) + ) - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiJsonFile(1) - } + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1) + } - @Test - fun `using different api url via customBootRun`() { - buildFile.writeText( - """$baseBuildGradle + @Test + fun `using different api url via customBootRun`() { + buildFile.writeText( + """$baseBuildGradle openApi{ apiDocsUrl = "http://localhost:8080/secret-api-docs" customBootRun { @@ -183,38 +197,37 @@ class OpenApiGradlePluginTest { } } """.trimMargin() - ) - - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiJsonFile(1) - } + ) + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1) + } - @Test - fun `yaml generation`() { - val outputYamlFileName = "openapi.yaml" + @Test + fun `yaml generation`() { + val outputYamlFileName = "openapi.yaml" - buildFile.writeText( - """$baseBuildGradle + buildFile.writeText( + """$baseBuildGradle openApi{ apiDocsUrl = "http://localhost:8080/v3/api-docs.yaml" outputFileName = "$outputYamlFileName" } """.trimMargin() - ) + ) - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiYamlFile(1, outputYamlFileName) - } + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiYamlFile(1, outputYamlFileName) + } - @Test - fun `using multiple grouped apis`() { - val outputJsonFileNameGroupA = "openapi-groupA.json" - val outputJsonFileNameGroupB = "openapi-groupB.json" + @Test + fun `using multiple grouped apis`() { + val outputJsonFileNameGroupA = "openapi-groupA.json" + val outputJsonFileNameGroupB = "openapi-groupB.json" - buildFile.writeText( - """$baseBuildGradle + buildFile.writeText( + """$baseBuildGradle bootRun { args = ["--spring.profiles.active=multiple-grouped-apis"] } @@ -223,20 +236,20 @@ class OpenApiGradlePluginTest { "http://localhost:8080/v3/api-docs/groupB": "$outputJsonFileNameGroupB"] } """.trimMargin() - ) + ) - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiJsonFile(1, outputJsonFileNameGroupA) - assertOpenApiJsonFile(2, outputJsonFileNameGroupB) - } + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1, outputJsonFileNameGroupA) + assertOpenApiJsonFile(2, outputJsonFileNameGroupB) + } - @Test - fun `using multiple grouped apis with yaml`() { - val outputYamlFileNameGroupA = "openapi-groupA.yaml" - val outputYamlFileNameGroupB = "openapi-groupB.yaml" + @Test + fun `using multiple grouped apis with yaml`() { + val outputYamlFileNameGroupA = "openapi-groupA.yaml" + val outputYamlFileNameGroupB = "openapi-groupB.yaml" - buildFile.writeText( - """$baseBuildGradle + buildFile.writeText( + """$baseBuildGradle bootRun { args = ["--spring.profiles.active=multiple-grouped-apis"] } @@ -245,21 +258,21 @@ class OpenApiGradlePluginTest { "http://localhost:8080/v3/api-docs.yaml/groupB": "$outputYamlFileNameGroupB"] } """.trimMargin() - ) + ) - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertOpenApiYamlFile(1, outputYamlFileNameGroupA) - assertOpenApiYamlFile(2, outputYamlFileNameGroupB) - } + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiYamlFile(1, outputYamlFileNameGroupA) + assertOpenApiYamlFile(2, outputYamlFileNameGroupB) + } - @Test - fun `using multiple grouped apis should ignore single api properties`() { - val outputJsonFileNameSingleGroupA = "openapi-single-groupA.json" - val outputJsonFileNameGroupA = "openapi-groupA.json" - val outputJsonFileNameGroupB = "openapi-groupB.json" + @Test + fun `using multiple grouped apis should ignore single api properties`() { + val outputJsonFileNameSingleGroupA = "openapi-single-groupA.json" + val outputJsonFileNameGroupA = "openapi-groupA.json" + val outputJsonFileNameGroupB = "openapi-groupB.json" - buildFile.writeText( - """$baseBuildGradle + buildFile.writeText( + """$baseBuildGradle bootRun { args = ["--spring.profiles.active=multiple-grouped-apis"] } @@ -270,43 +283,69 @@ class OpenApiGradlePluginTest { "http://localhost:8080/v3/api-docs/groupB": "$outputJsonFileNameGroupB"] } """.trimMargin() - ) - - assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) - assertFalse(File(projectBuildDir, outputJsonFileNameSingleGroupA).exists()) - assertOpenApiJsonFile(1, outputJsonFileNameGroupA) - assertOpenApiJsonFile(2, outputJsonFileNameGroupB) - } - - private fun runTheBuild(vararg additionalArguments: String = emptyArray()) = GradleRunner.create() - .withProjectDir(projectTestDir) - .withArguments("clean", "generateOpenApiDocs", *additionalArguments) - .withPluginClasspath() - .build() - - private fun assertOpenApiJsonFile( - expectedPathCount: Int, - outputJsonFileName: String = DEFAULT_OPEN_API_FILE_NAME, - buildDir: File = projectBuildDir - ) { - val openApiJson = getOpenApiJsonAtLocation(File(buildDir, outputJsonFileName)) - assertEquals("3.0.1", openApiJson.string(openapiField)) - assertEquals(expectedPathCount, openApiJson.obj(pathsField)!!.size) - } - - private fun getOpenApiJsonAtLocation(path: File) = Parser.default().parse(FileReader(path)) as JsonObject - - private fun assertOpenApiYamlFile( - expectedPathCount: Int, - outputJsonFileName: String = DEFAULT_OPEN_API_FILE_NAME, - buildDir: File = projectBuildDir - ) { - val mapper = ObjectMapper(YAMLFactory()) - mapper.registerModule(KotlinModule.Builder().build()) - val node = mapper.readTree(File(buildDir, outputJsonFileName)) - assertEquals("3.0.1", node.get(openapiField).asText()) - assertEquals(expectedPathCount, node.get(pathsField)!!.size()) - } - - private fun openApiDocsTask(result: BuildResult) = result.tasks.find { it.path.contains("generateOpenApiDocs") }!! + ) + + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertFalse(File(projectBuildDir, outputJsonFileNameSingleGroupA).exists()) + assertOpenApiJsonFile(1, outputJsonFileNameGroupA) + assertOpenApiJsonFile(2, outputJsonFileNameGroupB) + } + + @Test + fun `using invalid doc url`() { + buildFile.writeText( + """$baseBuildGradle + openApi{ + apiDocsUrl = "http://localhost:8080/hello/world" + } + """.trimMargin() + ) + + try { + openApiDocsTask(runTheBuild()) + } catch (e: RuntimeException) { + logger.error(e.message) + assertNotNull(e.message?.lines()?.find { + it.contains( + "Failed to parse the API docs response string. " + + "Please ensure that the response is in the correct format." + ) + }) + } + } + + private fun runTheBuild(vararg additionalArguments: String = emptyArray()) = + GradleRunner.create() + .withProjectDir(projectTestDir) + .withArguments("clean", "generateOpenApiDocs", *additionalArguments) + .withPluginClasspath() + .build() + + private fun assertOpenApiJsonFile( + expectedPathCount: Int, + outputJsonFileName: String = DEFAULT_OPEN_API_FILE_NAME, + buildDir: File = projectBuildDir + ) { + val openApiJson = getOpenApiJsonAtLocation(File(buildDir, outputJsonFileName)) + assertEquals("3.0.1", openApiJson.string(openapiField)) + assertEquals(expectedPathCount, openApiJson.obj(pathsField)!!.size) + } + + private fun getOpenApiJsonAtLocation(path: File) = + Parser.default().parse(FileReader(path)) as JsonObject + + private fun assertOpenApiYamlFile( + expectedPathCount: Int, + outputJsonFileName: String = DEFAULT_OPEN_API_FILE_NAME, + buildDir: File = projectBuildDir + ) { + val mapper = ObjectMapper(YAMLFactory()) + mapper.registerModule(KotlinModule.Builder().build()) + val node = mapper.readTree(File(buildDir, outputJsonFileName)) + assertEquals("3.0.1", node.get(openapiField).asText()) + assertEquals(expectedPathCount, node.get(pathsField)!!.size()) + } + + private fun openApiDocsTask(result: BuildResult) = + result.tasks.find { it.path.contains("generateOpenApiDocs") }!! } diff --git a/src/test/resources/acceptance-project/src/main/java/com/example/demo/DemoApplication.java b/src/test/resources/acceptance-project/src/main/java/com/example/demo/DemoApplication.java index 0f77539..24f27db 100644 --- a/src/test/resources/acceptance-project/src/main/java/com/example/demo/DemoApplication.java +++ b/src/test/resources/acceptance-project/src/main/java/com/example/demo/DemoApplication.java @@ -1,30 +1,31 @@ package com.example.demo; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import java.time.Duration; - @SpringBootApplication -public class DemoApplication implements ApplicationRunner{ +public class DemoApplication implements ApplicationRunner { + + @Value("${slower:false}") + boolean slower; - @Value("${slower:false}") - boolean slower; + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } - public static void main(String[] args) { - SpringApplication.run(DemoApplication.class, args); - } - @Override - public void run(ApplicationArguments arg0) throws Exception { - System.out.println("Hello World from Application Runner"); - if (slower) { - Duration waitTime = Duration.ofSeconds(40); - System.out.println("Waiting for " + waitTime + " before starting"); - Thread.sleep(waitTime.toMillis()); - } - } + @Override + public void run(ApplicationArguments arg0) throws Exception { + System.out.println("Hello World from Application Runner"); + if (slower) { + Duration waitTime = Duration.ofSeconds(40); + System.out.println("Waiting for " + waitTime + " before starting"); + Thread.sleep(waitTime.toMillis()); + } + } } diff --git a/src/test/resources/acceptance-project/src/main/java/com/example/demo/config/GroupedConfiguration.java b/src/test/resources/acceptance-project/src/main/java/com/example/demo/config/GroupedConfiguration.java index c37c7b7..f648861 100644 --- a/src/test/resources/acceptance-project/src/main/java/com/example/demo/config/GroupedConfiguration.java +++ b/src/test/resources/acceptance-project/src/main/java/com/example/demo/config/GroupedConfiguration.java @@ -1,6 +1,7 @@ package com.example.demo; import org.springdoc.core.GroupedOpenApi; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -9,19 +10,19 @@ @Configuration public class GroupedConfiguration { - @Bean - public GroupedOpenApi groupA() { - return GroupedOpenApi.builder() - .group("groupA") - .pathsToMatch("/groupA/**") - .build(); - } + @Bean + public GroupedOpenApi groupA() { + return GroupedOpenApi.builder() + .group("groupA") + .pathsToMatch("/groupA/**") + .build(); + } - @Bean - public GroupedOpenApi groupB() { - return GroupedOpenApi.builder() - .group("groupB") - .pathsToMatch("/groupB/**") - .build(); - } + @Bean + public GroupedOpenApi groupB() { + return GroupedOpenApi.builder() + .group("groupB") + .pathsToMatch("/groupB/**") + .build(); + } } diff --git a/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/ConditionalController.java b/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/ConditionalController.java index 44f5cc9..1813c7f 100644 --- a/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/ConditionalController.java +++ b/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/ConditionalController.java @@ -4,12 +4,12 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -@ConditionalOnProperty(value = {"some.second.property"}, havingValue = "someValue") +@ConditionalOnProperty(value = { "some.second.property" }, havingValue = "someValue") @RestController("/conditional") public class ConditionalController { - @GetMapping("/conditional") - public String conditional() { - return "conditional"; - } + @GetMapping("/conditional") + public String conditional() { + return "conditional"; + } } diff --git a/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/GroupedController.java b/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/GroupedController.java index 2df2fdd..8c87777 100644 --- a/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/GroupedController.java +++ b/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/GroupedController.java @@ -8,18 +8,18 @@ @RestController("/grouped") public class GroupedController { - @GetMapping("/groupA") - public String groupA() { - return "groupA"; - } + @GetMapping("/groupA") + public String groupA() { + return "groupA"; + } - @GetMapping("/groupB/first") - public String groupB_first() { - return "groupB_first"; - } + @GetMapping("/groupB/first") + public String groupB_first() { + return "groupB_first"; + } - @GetMapping("/groupB/second") - public String groupB_second() { - return "groupB_second"; - } + @GetMapping("/groupB/second") + public String groupB_second() { + return "groupB_second"; + } } diff --git a/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/HelloWorldController.java b/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/HelloWorldController.java index 95c7d89..3873f80 100644 --- a/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/HelloWorldController.java +++ b/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/HelloWorldController.java @@ -1,13 +1,17 @@ package com.example.demo.endpoints; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; @RestController("/hello") +@RequestMapping("/hello") public class HelloWorldController { - @GetMapping("/world") - public String helloWorld() { - return "Hello World!"; - } + @GetMapping("/world") + @ResponseBody + public String helloWorld() { + return "Hello World!"; + } } diff --git a/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/ProfileController.java b/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/ProfileController.java index a482b63..625ac6e 100644 --- a/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/ProfileController.java +++ b/src/test/resources/acceptance-project/src/main/java/com/example/demo/endpoints/ProfileController.java @@ -9,11 +9,11 @@ @RestController("/special") public class ProfileController { - @Value("${test.props}") - String profileRelatedValue; + @Value("${test.props}") + String profileRelatedValue; - @GetMapping("/") - public String special() { - return profileRelatedValue; - } + @GetMapping("/") + public String special() { + return profileRelatedValue; + } }