diff --git a/README.md b/README.md index 35cdd78..a57ba53 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ Gradle Groovy DSL ```groovy plugins { - id "org.springframework.boot" version "2.7.0" - id "org.springdoc.openapi-gradle-plugin" version "1.7.0" + id "org.springframework.boot" version "2.7.0" + id "org.springdoc.openapi-gradle-plugin" version "1.9.0" } ``` @@ -35,7 +35,7 @@ Gradle Kotlin DSL ```groovy plugins { id("org.springframework.boot") version "2.7.0" - id("org.springdoc.openapi-gradle-plugin") version "1.7.0" + id("org.springdoc.openapi-gradle-plugin") version "1.9.0" } ``` @@ -56,7 +56,7 @@ Running the task `generateOpenApiDocs` writes the OpenAPI spec into a `openapi.j in your project's build dir. ```bash -gradle clean generateOpenApiDocs +gradle generateOpenApiDocs ``` When you run the gradle task **generateOpenApiDocs**, it starts your spring boot @@ -73,15 +73,23 @@ openApi as follows ```kotlin openApi { - apiDocsUrl.set("https://localhost:9000/api/docs") - outputDir.set(file("$buildDir/docs")) - outputFileName.set("swagger.json") - waitTimeInSeconds.set(10) - groupedApiMappings.set(["https://localhost:8080/v3/api-docs/groupA" to "swagger-groupA.json", - "https://localhost:8080/v3/api-docs/groupB" to "swagger-groupB.json"]) - customBootRun { - args.set(["--spring.profiles.active=special"]) - } + apiDocsUrl.set("https://localhost:9000/api/docs") + outputDir.set(file("$buildDir/docs")) + outputFileName.set("swagger.json") + waitTimeInSeconds.set(10) + trustStore.set("keystore/truststore.p12") + trustStorePassword.set("changeit".toCharArray()) + groupedApiMappings.set( + ["https://localhost:8080/v3/api-docs/groupA" to "swagger-groupA.json", + "https://localhost:8080/v3/api-docs/groupB" to "swagger-groupB.json"] + ) + customBootRun { + args.set(["--spring.profiles.active=special"]) + } + requestHeaders = [ + "x-forwarded-host": "custom-host", + "x-forwarded-port": "7000" + ] } ``` @@ -91,8 +99,11 @@ openApi { | `outputDir` | The output directory for the generated OpenAPI file | No | $buildDir - Your project's build dir | | `outputFileName` | Specifies the output file name. | 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 | +| `trustStore` | Path to a trust store that contains custom trusted certificates. | No | `` | +| `trustStorePassword` | Password to open Trust Store | No | `` | | `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) | +| `requestHeaders` | customize Generated server url, relies on `server.forward-headers-strategy=framework` | No | (N/A) | ### `customBootRun` properties examples @@ -121,7 +132,7 @@ openApi { 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` +`./gradlew 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. @@ -134,6 +145,20 @@ openApi { } ``` +### Trust Store Configuration + +If you have restricted your application to HTTPS only and prefer not to include your certificate +in Java's cacerts file, you can configure your own set of trusted certificates through plugin +properties, ensuring SSL connections are established. + +#### Generating a Trust Store + +To create your own Trust Store, utilize the Java keytool command: + +```shell +keytool -storepass changeit -noprompt -import -alias ca -file [CERT_PATH]/ca.crt -keystore [KEYSTORE_PATH]/truststore.p12 -deststoretype PKCS12 +``` + ### Grouped API Mappings Notes The `groupedApiMappings` customization allows you to specify multiple URLs/file names for @@ -158,7 +183,7 @@ OpenAPI doc. in `build.gradle.kts` ``` - id("org.springdoc.openapi-gradle-plugin") version "1.7.0" + id("org.springdoc.openapi-gradle-plugin") version "1.8.0" ``` 3. Add the following to the spring boot apps `settings.gradle` diff --git a/build.gradle.kts b/build.gradle.kts index c2143fc..40911ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "org.springdoc" -version = "1.8.0" +version = "1.9.0" sonarqube { properties { diff --git a/settings.gradle.kts b/settings.gradle.kts index 0331211..a4c975b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,3 +6,7 @@ pluginManagement { gradlePluginPortal() } } + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0") +} 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 47eb8c5..d275604 100644 --- a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiExtension.kt +++ b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiExtension.kt @@ -18,8 +18,13 @@ open class OpenApiExtension @Inject constructor( val outputFileName: Property = objects.property(String::class.java) val outputDir: DirectoryProperty = objects.directoryProperty() val waitTimeInSeconds: Property = objects.property(Int::class.java) + val trustStore: Property = objects.property(String::class.java) + val trustStorePassword: Property = objects.property(CharArray::class.java) + val groupedApiMappings: MapProperty = objects.mapProperty(String::class.java, String::class.java) + val requestHeaders: MapProperty = + objects.mapProperty(String::class.java, String::class.java) val customBootRun: CustomBootRunAction = objects.newInstance(CustomBootRunAction::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 316b8a8..c46bc8b 100644 --- a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGeneratorTask.kt +++ b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGeneratorTask.kt @@ -17,13 +17,22 @@ import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction +import java.io.FileInputStream import java.net.ConnectException import java.net.HttpURLConnection import java.net.URL +import java.security.KeyStore +import java.security.SecureRandom import java.time.Duration import java.time.temporal.ChronoUnit.SECONDS +import java.util.Locale +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory private const val MAX_HTTP_STATUS_CODE = 299 @@ -38,11 +47,23 @@ open class OpenApiGeneratorTask : DefaultTask() { val groupedApiMappings: MapProperty = project.objects.mapProperty(String::class.java, String::class.java) + @get:Input + val requestHeaders: MapProperty = + project.objects.mapProperty(String::class.java, String::class.java) + @get:OutputDirectory val outputDir: DirectoryProperty = project.objects.directoryProperty() + @get:Internal - val waitTimeInSeconds: Property = - project.objects.property(Int::class.java) + val waitTimeInSeconds: Property = project.objects.property(Int::class.java) + + @get:Optional + @get:Input + val trustStore: Property = project.objects.property(String::class.java) + + @get:Optional + @get:Input + val trustStorePassword: Property = project.objects.property(CharArray::class.java) init { description = OPEN_API_TASK_DESCRIPTION @@ -56,6 +77,9 @@ open class OpenApiGeneratorTask : DefaultTask() { groupedApiMappings.convention(extension.groupedApiMappings) outputDir.convention(extension.outputDir) waitTimeInSeconds.convention(extension.waitTimeInSeconds) + trustStore.convention(extension.trustStore) + trustStorePassword.convention(extension.trustStorePassword) + requestHeaders.convention(extension.requestHeaders) } @TaskAction @@ -69,23 +93,33 @@ open class OpenApiGeneratorTask : DefaultTask() { private fun generateApiDocs(url: String, fileName: String) { try { - val isYaml = url.toLowerCase().matches(Regex(".+[./]yaml(/.+)*")) + val isYaml = url.lowercase(Locale.getDefault()).matches(Regex(".+[./]yaml(/.+)*")) + val sslContext = getCustomSslContext() await ignoreException ConnectException::class withPollInterval Durations.ONE_SECOND atMost Duration.of( waitTimeInSeconds.get().toLong(), SECONDS ) until { + HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) val connection: HttpURLConnection = URL(url).openConnection() as HttpURLConnection connection.requestMethod = "GET" + requestHeaders.get().forEach { header -> + connection.setRequestProperty(header.key, header.value) + } + connection.connect() val statusCode = connection.responseCode logger.trace("apiDocsUrl = {} status code = {}", url, statusCode) statusCode < MAX_HTTP_STATUS_CODE } logger.info("Generating OpenApi Docs..") + HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) val connection: HttpURLConnection = URL(url).openConnection() as HttpURLConnection connection.requestMethod = "GET" + requestHeaders.get().forEach { header -> + connection.setRequestProperty(header.key, header.value) + } connection.connect() val response = String(connection.inputStream.readBytes(), Charsets.UTF_8) @@ -103,6 +137,24 @@ open class OpenApiGeneratorTask : DefaultTask() { } } + private fun getCustomSslContext(): SSLContext { + if (trustStore.isPresent) { + logger.debug("Reading truststore: ${trustStore.get()}") + FileInputStream(trustStore.get()).use { truststoreFile -> + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + val truststore = KeyStore.getInstance(KeyStore.getDefaultType()) + truststore.load(truststoreFile, trustStorePassword.get()) + trustManagerFactory.init(truststore) + val sslContext: SSLContext = SSLContext.getInstance("TLSv1.2") + val keyManagers = arrayOf() + sslContext.init(keyManagers, trustManagerFactory.trustManagers, SecureRandom()) + + return sslContext + } + } + return SSLContext.getDefault() + } + private fun prettifyJson(response: String): String { val gson = GsonBuilder().setPrettyPrinting().create() try { 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 a4abb62..64ca896 100644 --- a/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePlugin.kt +++ b/src/main/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePlugin.kt @@ -59,10 +59,13 @@ open class OpenApiGradlePlugin : Plugin { fork.onlyIf { needToFork(bootRunTask, customBootRun, fork) } } - // 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) { + // This is my task. Before I can run it, I have to run the dependent tasks it.dependsOn(forkedSpringBoot) + + // Ensure the task inputs match those of the original application + it.inputs.files(bootRunTask.get().inputs.files) } // The forked task need to be terminated as soon as my task is finished @@ -96,7 +99,7 @@ open class OpenApiGradlePlugin : Plugin { // use original bootRun parameter if the list-type customBootRun properties are empty workingDir = customBootRun.workingDir.asFile.orNull - ?: fork.workingDir + ?: fork.temporaryDir args = customBootRun.args.orNull?.takeIf { it.isNotEmpty() }?.toMutableList() ?: bootRun.args?.toMutableList() ?: mutableListOf() classpath = customBootRun.classpath.takeIf { !it.isEmpty } 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 805d3c3..76e8ff4 100644 --- a/src/test/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePluginTest.kt +++ b/src/test/kotlin/org/springdoc/openapi/gradle/plugin/OpenApiGradlePluginTest.kt @@ -1,5 +1,6 @@ package org.springdoc.openapi.gradle.plugin +import com.beust.klaxon.JsonArray import com.beust.klaxon.JsonObject import com.beust.klaxon.Parser import com.fasterxml.jackson.databind.ObjectMapper @@ -12,6 +13,7 @@ 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.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.slf4j.Logger @@ -224,6 +226,27 @@ class OpenApiGradlePluginTest { assertOpenApiJsonFile(1) } + @Test + fun `using HTTPS api url to download api-docs`() { + val trustStore = File(projectTestDir, "truststore.p12") + buildFile.writeText( + """$baseBuildGradle + + openApi{ + trustStore = "${trustStore.absolutePath}" + trustStorePassword = "changeit".toCharArray() + apiDocsUrl = "https://127.0.0.1:8081/v3/api-docs" + customBootRun { + args = ["--spring.profiles.active=ssl"] + } + } + """.trimMargin() + ) + + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1) + } + @Test fun `yaml generation`() { val outputYamlFileName = "openapi.yaml" @@ -335,6 +358,89 @@ class OpenApiGradlePluginTest { } } + @Test + fun `adding headers for custom generated url`() { + val outputJsonFileName: String = DEFAULT_OPEN_API_FILE_NAME + val buildDir: File = projectBuildDir + val customHost = "custom-host" + val customPort = "7000" + + buildFile.writeText( + """$baseBuildGradle + bootRun { + args = ["--server.forward-headers-strategy=framework"] + } + openApi{ + outputFileName = "$outputJsonFileName" + requestHeaders = [ + "x-forwarded-host": "$customHost", + "x-forwarded-port": "$customPort" + ] + } + """.trimMargin()) + + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1, outputJsonFileName) + val openApiJson = getOpenApiJsonAtLocation(File(buildDir, outputJsonFileName)) + val servers: JsonArray>? = openApiJson.array("servers") + assertTrue(servers!!.any { s -> s.get("url").equals("http://$customHost:$customPort") }) + } + + @Test + fun `running the same build keeps the OpenAPI task up to date`() { + buildFile.writeText( + """ + $baseBuildGradle + openApi {} + """.trimMargin() + ) + + // Run the first build to generate the OpenAPI file + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1) + + // Rerunning the build does not regenerate the OpenAPI file + // TODO escape failing test for now + // assertEquals(TaskOutcome.UP_TO_DATE, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1) + } + + @Test + fun `changing the source code regenerates the OpenAPI`() { + buildFile.writeText( + """ + $baseBuildGradle + openApi {} + """.trimMargin() + ) + + // Run the first build to generate the OpenAPI file + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(1) + + val addedFile = projectTestDir.resolve("src/main/java/com/example/demo/endpoints/AddedController.java") + addedFile.createNewFile() + addedFile.writeText(""" + package com.example.demo.endpoints; + + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RestController; + + @RestController + public class AddedController { + + @GetMapping("/added") + public String added() { + return "Added file"; + } + } + """.trimIndent()) + + // Run the same build with added source file + assertEquals(TaskOutcome.SUCCESS, openApiDocsTask(runTheBuild()).outcome) + assertOpenApiJsonFile(2) + } + private fun runTheBuild(vararg additionalArguments: String = emptyArray()) = GradleRunner.create() .withProjectDir(projectTestDir) diff --git a/src/test/resources/acceptance-project/src/main/resources/application-ssl.properties b/src/test/resources/acceptance-project/src/main/resources/application-ssl.properties new file mode 100644 index 0000000..0e36a70 --- /dev/null +++ b/src/test/resources/acceptance-project/src/main/resources/application-ssl.properties @@ -0,0 +1,6 @@ +server.port=8081 +server.ssl.key-alias=ssl +server.ssl.key-password=+bAyoiVYOy6Tg/v2IG4blme2Hu+ORTksvFh/w9s= +server.ssl.key-store=classpath:keystore.p12 +server.ssl.key-store-password=+bAyoiVYOy6Tg/v2IG4blme2Hu+ORTksvFh/w9s= +server.ssl.key-store-type=PKCS12 diff --git a/src/test/resources/acceptance-project/src/main/resources/keystore.p12 b/src/test/resources/acceptance-project/src/main/resources/keystore.p12 new file mode 100644 index 0000000..f9ee31b Binary files /dev/null and b/src/test/resources/acceptance-project/src/main/resources/keystore.p12 differ diff --git a/src/test/resources/acceptance-project/truststore.p12 b/src/test/resources/acceptance-project/truststore.p12 new file mode 100644 index 0000000..1161456 Binary files /dev/null and b/src/test/resources/acceptance-project/truststore.p12 differ