From 9b4f1ae9e2def9b68c59d9df640fc9712d783695 Mon Sep 17 00:00:00 2001 From: tabilzad Date: Mon, 16 Sep 2024 22:13:37 -0400 Subject: [PATCH] Derive schema/property descriptions from KDocs by default --- .../ktor/KtorDocsCommandLineProcessor.kt | 15 +++++- .../tabilzad/ktor/PluginConfiguration.kt | 5 +- .../kotlin/io/github/tabilzad/ktor/Utils.kt | 29 ++++++++++ .../tabilzad/ktor/k2/ExpressionsVisitorK2.kt | 13 +++-- .../k2/visitors/ClassDescriptorVisitorK2.kt | 16 +++--- .../github/tabilzad/ktor/K2StabilityTest.kt | 32 ++++++++--- .../resources/expected/KDocs-expected.json | 54 +++++++++++++++++++ .../src/test/resources/sources/KDocs.kt | 47 ++++++++++++++++ gradle.properties | 2 +- .../ktor/KtorInspektorGradleConfig.kt | 1 + .../io.github.tabilzad/ktor/KtorMetaPlugin.kt | 4 ++ 11 files changed, 198 insertions(+), 20 deletions(-) create mode 100644 create-plugin/src/test/resources/expected/KDocs-expected.json create mode 100644 create-plugin/src/test/resources/sources/KDocs.kt diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/KtorDocsCommandLineProcessor.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/KtorDocsCommandLineProcessor.kt index 8350536..a3e3877 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/KtorDocsCommandLineProcessor.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/KtorDocsCommandLineProcessor.kt @@ -6,6 +6,7 @@ import io.github.tabilzad.ktor.SwaggerConfigurationKeys.ARG_ENABLED import io.github.tabilzad.ktor.SwaggerConfigurationKeys.ARG_FORMAT import io.github.tabilzad.ktor.SwaggerConfigurationKeys.ARG_HIDE_PRIVATE import io.github.tabilzad.ktor.SwaggerConfigurationKeys.ARG_HIDE_TRANSIENTS +import io.github.tabilzad.ktor.SwaggerConfigurationKeys.ARG_KDOCS import io.github.tabilzad.ktor.SwaggerConfigurationKeys.ARG_PATH import io.github.tabilzad.ktor.SwaggerConfigurationKeys.ARG_REQUEST_FEATURE import io.github.tabilzad.ktor.SwaggerConfigurationKeys.ARG_SERVERS @@ -21,6 +22,7 @@ import io.github.tabilzad.ktor.SwaggerConfigurationKeys.OPTION_PATH import io.github.tabilzad.ktor.SwaggerConfigurationKeys.OPTION_REQUEST_BODY import io.github.tabilzad.ktor.SwaggerConfigurationKeys.OPTION_SERVERS import io.github.tabilzad.ktor.SwaggerConfigurationKeys.OPTION_TITLE +import io.github.tabilzad.ktor.SwaggerConfigurationKeys.OPTION_USE_KDOCS import io.github.tabilzad.ktor.SwaggerConfigurationKeys.OPTION_VER import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption import org.jetbrains.kotlin.compiler.plugin.CliOption @@ -40,6 +42,7 @@ object SwaggerConfigurationKeys { const val OPTION_HIDE_TRANSIENT = "hideTransientFields" const val OPTION_HIDE_PRIVATE = "hidePrivateAndInternalFields" const val OPTION_DERIVE_PROP_REQ = "deriveFieldRequirementFromTypeNullability" + const val OPTION_USE_KDOCS = "useKDocs" const val OPTION_SERVERS = "servers" const val OPTION_FORMAT = "format" @@ -54,6 +57,7 @@ object SwaggerConfigurationKeys { val ARG_DERIVE_PROP_REQ = CompilerConfigurationKey.create(OPTION_DERIVE_PROP_REQ) val ARG_FORMAT = CompilerConfigurationKey.create(OPTION_FORMAT) val ARG_SERVERS = CompilerConfigurationKey.create>(OPTION_SERVERS) + val ARG_KDOCS = CompilerConfigurationKey.create(OPTION_USE_KDOCS) } @OptIn(ExperimentalCompilerApi::class) @@ -113,6 +117,12 @@ class KtorDocsCommandLineProcessor : CommandLineProcessor { "Automatically derive object properties' requirement from the type nullability", false ) + val useKDocs = CliOption( + OPTION_USE_KDOCS, + "true opts for using kdocs for schema descriptions", + "Resolve schema descriptions from kdocs", + false + ) val formatOption = CliOption( OPTION_FORMAT, "Specification format", @@ -144,7 +154,8 @@ class KtorDocsCommandLineProcessor : CommandLineProcessor { hidePrivateAndInternalFields, derivePropRequirement, formatOption, - serverUrls + serverUrls, + useKDocs ) @@ -174,6 +185,8 @@ class KtorDocsCommandLineProcessor : CommandLineProcessor { hidePrivateAndInternalFields -> configuration.put(ARG_HIDE_PRIVATE, value.toBooleanStrictOrNull() ?: true) + useKDocs -> configuration.put(ARG_KDOCS, value.toBooleanStrictOrNull() ?: true) + serverUrls -> configuration.put(ARG_SERVERS, value.split("||").filter { it.isNotBlank() }) else -> throw IllegalArgumentException("Unexpected config option ${option.optionName}") diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/PluginConfiguration.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/PluginConfiguration.kt index 04a4bf0..4498f8f 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/PluginConfiguration.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/PluginConfiguration.kt @@ -12,7 +12,8 @@ internal data class PluginConfiguration( val hideTransients: Boolean, val hidePrivateFields: Boolean, val servers: List, - val deriveFieldRequirementFromTypeNullability: Boolean + val deriveFieldRequirementFromTypeNullability: Boolean, + val useKDocsForDescriptions: Boolean ) { companion object { fun createDefault( @@ -27,6 +28,7 @@ internal data class PluginConfiguration( hidePrivateFields: Boolean? = null, servers: List? = null, deriveFieldRequirementFromTypeNullability: Boolean? = null, + useKDocsForDescriptions: Boolean? = null ): PluginConfiguration = PluginConfiguration( isEnabled = isEnabled ?: true, format = format ?: "yaml", @@ -39,6 +41,7 @@ internal data class PluginConfiguration( hidePrivateFields = hidePrivateFields ?: true, deriveFieldRequirementFromTypeNullability = deriveFieldRequirementFromTypeNullability ?: true, servers = servers ?: emptyList(), + useKDocsForDescriptions = useKDocsForDescriptions ?: true ) } } diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/Utils.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/Utils.kt index 6ad79f9..f44ccd4 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/Utils.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/Utils.kt @@ -4,10 +4,13 @@ import io.github.tabilzad.ktor.k2.ClassIds.TRANSIENT_ANNOTATION_FQ import io.github.tabilzad.ktor.output.OpenApiSpec import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.descriptors.PropertyDescriptor +import org.jetbrains.kotlin.fir.declarations.FirDeclaration +import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.resolve.descriptorUtil.isEffectivelyPublicApi import org.jetbrains.kotlin.resolve.scopes.DescriptorKindFilter import org.jetbrains.kotlin.resolve.scopes.MemberScope import org.jetbrains.kotlin.resolve.scopes.getDescriptorsFiltered +import org.jetbrains.kotlin.util.getChildren import java.io.OutputStream fun Boolean.byFeatureFlag(flag: Boolean): Boolean = if (flag) { @@ -176,6 +179,32 @@ private fun addPostBody(it: KtorRouteSpec): OpenApiSpec.RequestBody? { } } +internal fun FirDeclaration.getKDocComments(configuration: PluginConfiguration): String? { + + if(!configuration.useKDocsForDescriptions) return null + + fun String.sanitizeKDoc(): String { + val lines = trim().lines().map { it.trim() } + return lines.filter { it.isNotEmpty() && it != "*" } + .joinToString("\n") { line -> + when { + line.startsWith("/**") -> line.removePrefix("/**").trim() + line.startsWith("*/") -> "" + else -> line.trimMargin("*").trim() + } + } + .trim() + } + + return source?.treeStructure?.let { + source?.lighterASTNode?.getChildren(it) + ?.firstOrNull { it.tokenType == KtTokens.DOC_COMMENT } + ?.toString() + ?.sanitizeKDoc() + } +} + + private fun OpenApiSpec.ObjectType.isPrimitive() = listOf("string", "number", "integer").contains(type) internal fun CompilerConfiguration?.buildPluginConfiguration(): PluginConfiguration = PluginConfiguration.createDefault( diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/ExpressionsVisitorK2.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/ExpressionsVisitorK2.kt index 13b8069..7f3eb08 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/ExpressionsVisitorK2.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/ExpressionsVisitorK2.kt @@ -3,12 +3,9 @@ package io.github.tabilzad.ktor.k2 import io.github.tabilzad.ktor.* import io.github.tabilzad.ktor.annotations.KtorDescription import io.github.tabilzad.ktor.annotations.KtorResponds -import io.github.tabilzad.ktor.k2.visitors.* -import io.github.tabilzad.ktor.k2.visitors.ClassDescriptorVisitorK2 -import io.github.tabilzad.ktor.k2.visitors.ResourceClassVisitor -import io.github.tabilzad.ktor.k2.visitors.RespondsAnnotationVisitor import io.github.tabilzad.ktor.k1.visitors.KtorDescriptionBag import io.github.tabilzad.ktor.k1.visitors.toSwaggerType +import io.github.tabilzad.ktor.k2.visitors.* import io.github.tabilzad.ktor.output.OpenApiSpec import org.jetbrains.kotlin.fir.FirElement import org.jetbrains.kotlin.fir.FirSession @@ -20,6 +17,7 @@ import org.jetbrains.kotlin.fir.references.resolved import org.jetbrains.kotlin.fir.references.toResolvedFunctionSymbol import org.jetbrains.kotlin.fir.resolve.firClassLike import org.jetbrains.kotlin.fir.resolve.fqName +import org.jetbrains.kotlin.fir.resolve.toFirRegularClass import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.types.* @@ -104,14 +102,21 @@ internal class ExpressionsVisitorK2( } + @OptIn(SymbolInternals::class) private fun ConeKotlinType.generateTypeAndVisitMemberDescriptors(): OpenApiSpec.ObjectType { val jetTypeFqName = fqNameStr() + val kdocs = this.toRegularClassSymbol(session) + ?.toLookupTag() + ?.toFirRegularClass(session) + ?.getKDocComments(config) + val objectType = OpenApiSpec.ObjectType( type = "object", properties = mutableMapOf(), fqName = jetTypeFqName, + description = kdocs, contentBodyRef = "#/components/schemas/${jetTypeFqName}", ) if (!classNames.names.contains(jetTypeFqName)) { diff --git a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/visitors/ClassDescriptorVisitorK2.kt b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/visitors/ClassDescriptorVisitorK2.kt index 1a5b17c..c752198 100644 --- a/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/visitors/ClassDescriptorVisitorK2.kt +++ b/create-plugin/src/main/kotlin/io/github/tabilzad/ktor/k2/visitors/ClassDescriptorVisitorK2.kt @@ -1,15 +1,16 @@ package io.github.tabilzad.ktor.k2.visitors -import io.github.tabilzad.ktor.output.OpenApiSpec -import io.github.tabilzad.ktor.output.OpenApiSpec.ObjectType import io.github.tabilzad.ktor.PluginConfiguration import io.github.tabilzad.ktor.annotations.KtorDescription import io.github.tabilzad.ktor.annotations.KtorFieldDescription +import io.github.tabilzad.ktor.getKDocComments +import io.github.tabilzad.ktor.k1.visitors.KtorDescriptionBag +import io.github.tabilzad.ktor.k1.visitors.toSwaggerType import io.github.tabilzad.ktor.k2.* import io.github.tabilzad.ktor.k2.JsonNameResolver.getCustomNameFromAnnotation import io.github.tabilzad.ktor.names -import io.github.tabilzad.ktor.k1.visitors.KtorDescriptionBag -import io.github.tabilzad.ktor.k1.visitors.toSwaggerType +import io.github.tabilzad.ktor.output.OpenApiSpec +import io.github.tabilzad.ktor.output.OpenApiSpec.ObjectType import org.jetbrains.kotlin.fir.FirElement import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.analysis.checkers.context.CheckerContext @@ -27,7 +28,8 @@ import org.jetbrains.kotlin.util.PrivateForInline import org.jetbrains.kotlin.util.getValueOrNull data class GenericParameter( - val genericName: String, val genericTypeRef: ConeKotlinType? + val genericName: String, + val genericTypeRef: ConeKotlinType? ) internal class ClassDescriptorVisitorK2( @@ -41,7 +43,6 @@ internal class ClassDescriptorVisitorK2( @OptIn(SealedClassInheritorsProviderInternals::class, SymbolInternals::class) override fun visitProperty(property: FirProperty, data: ObjectType): ObjectType { - val coneTypeOrNull = property.returnTypeRef.coneTypeOrNull!! val type = if (coneTypeOrNull is ConeTypeParameterType && genericParameters.isNotEmpty()) { genericParameters.find { it.genericName == coneTypeOrNull.renderReadable() }?.genericTypeRef!! @@ -314,6 +315,7 @@ internal class ClassDescriptorVisitorK2( } private fun ObjectType.addProperty(fir: FirProperty, objectType: ObjectType?, session: FirSession) { + val kdoc = fir.getKDocComments(config) val resolvedDescription = fir.findDocsDescription(session) val docsDescription = resolvedDescription.let { it?.summary ?: it?.descr } val name = fir.findName() @@ -324,7 +326,7 @@ internal class ClassDescriptorVisitorK2( properties?.put(name, spec) } - objectType?.description = docsDescription + objectType?.description = docsDescription ?: kdoc val isRequiredFromExplicitDesc = resolvedDescription?.isRequired if (isRequiredFromExplicitDesc != null && isRequiredFromExplicitDesc) { diff --git a/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/K2StabilityTest.kt b/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/K2StabilityTest.kt index 40fbfe9..ffd22dd 100644 --- a/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/K2StabilityTest.kt +++ b/create-plugin/src/test/kotlin/io/github/tabilzad/ktor/K2StabilityTest.kt @@ -244,7 +244,11 @@ class K2StabilityTest { @Test fun `should include private fields or ones annotated with @Transient`() { val (source, expected) = loadSourceAndExpected("PrivateFieldsNegation") - generateCompilerTest(testFile, source, PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false)) + generateCompilerTest( + testFile, + source, + PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false) + ) val result = testFile.readText() result.assertWith(expected) } @@ -252,7 +256,11 @@ class K2StabilityTest { @Test fun `should generate response correct response bodies when explicitly specified`() { val (source, expected) = loadSourceAndExpected("ResponseBody") - generateCompilerTest(testFile, source, PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false)) + generateCompilerTest( + testFile, + source, + PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false) + ) val result = testFile.readText() result.assertWith(expected) } @@ -260,7 +268,11 @@ class K2StabilityTest { @Test fun `should correctly resolve complex descriptions specified on response annotations`() { val (source, expected) = loadSourceAndExpected("ResponseBody2") - generateCompilerTest(testFile, source, PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false)) + generateCompilerTest( + testFile, + source, + PluginConfiguration.createDefault(hideTransients = false, hidePrivateFields = false) + ) val result = testFile.readText() result.assertWith(expected) } @@ -289,6 +301,14 @@ class K2StabilityTest { result.assertWith(expected) } + @Test + fun `should use kdocs as property or schema descriptions by default`() { + val (source, expected) = loadSourceAndExpected("KDocs") + generateCompilerTest(testFile, source, PluginConfiguration.createDefault()) + val result = testFile.readText() + result.assertWith(expected) + } + @Test fun `should resolve request body schema directly from http method parameter if it's not a resource`() { val (source, expected) = loadSourceAndExpected("RequestBodyParam") @@ -323,7 +343,7 @@ class K2StabilityTest { @Test fun `should append servers from gradle config`() { - val source = loadSourceCodeFrom("BlankSource") + val source = loadSourceCodeFrom("BlankSource") val input = listOf("server1", "server2") val expectation = input.map { OpenApiSpec.Server(it) } generateCompilerTest(testFile, source, PluginConfiguration.createDefault(servers = input)) @@ -333,13 +353,13 @@ class K2StabilityTest { @Test fun `should not append servers from gradle config if not specified`() { - val source = loadSourceCodeFrom("BlankSource") + val source = loadSourceCodeFrom("BlankSource") generateCompilerTest(testFile, source, PluginConfiguration.createDefault()) val result = testFile.parseSpec() assertThat(result.servers).isNull() } - private fun String?.assertWith(expected: String){ + private fun String?.assertWith(expected: String) { assertThat(this).isNotNull.withFailMessage { "swagger file was not generated" } diff --git a/create-plugin/src/test/resources/expected/KDocs-expected.json b/create-plugin/src/test/resources/expected/KDocs-expected.json new file mode 100644 index 0000000..11b8fb9 --- /dev/null +++ b/create-plugin/src/test/resources/expected/KDocs-expected.json @@ -0,0 +1,54 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "Open API Specification", + "description" : "test", + "version" : "1.0.0" + }, + "paths" : { + "/v1/action" : { + "post" : { + "requestBody" : { + "required" : true, + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/sources.KDocsClass" + } + } + } + } + } + } + }, + "components" : { + "schemas" : { + "sources.KDocsClass" : { + "type" : "object", + "properties" : { + "kdocsConstructorDerivedProperty" : { + "type" : "string", + "description" : "This field is called [kdocsConstructorDerivedProperty]." + }, + "kdocsConstructorParameter" : { + "type" : "string", + "description" : "This field is called [kdocsConstructorParameter].\nTis is another line with\n* This is another line with extra *\n* This \\is another \\*line with extra *" + }, + "kdocsLateinitVar" : { + "type" : "string", + "description" : "This field is called [kdocsLateinitVar]." + }, + "kdocsProperty" : { + "type" : "string", + "description" : "This field is called [kdocsProperty]." + }, + "noKdocs" : { + "type" : "string" + } + }, + "description" : "This class contains fields with kdocs.", + "required" : [ "kdocsConstructorParameter", "noKdocs", "kdocsConstructorDerivedProperty", "kdocsLateinitVar" ] + } + } + } +} \ No newline at end of file diff --git a/create-plugin/src/test/resources/sources/KDocs.kt b/create-plugin/src/test/resources/sources/KDocs.kt new file mode 100644 index 0000000..f1abd51 --- /dev/null +++ b/create-plugin/src/test/resources/sources/KDocs.kt @@ -0,0 +1,47 @@ +package sources + +import io.github.tabilzad.ktor.annotations.GenerateOpenApi +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.routing.* + +/** + * This class contains fields with kdocs. + */ +data class KDocsClass( + /** + * This field is called [kdocsConstructorParameter]. + * Tis is another line with + * * This is another line with extra * + * * This \is another \*line with extra * + */ + val kdocsConstructorParameter: String, + val noKdocs: String, +) { + + /** + * This field is called [kdocsConstructorDerivedProperty]. + */ + val kdocsConstructorDerivedProperty = noKdocs + "" + + /** + * This field is called [kdocsProperty]. + */ + var kdocsProperty: String? = null + + /** + * This field is called [kdocsLateinitVar]. + */ + lateinit var kdocsLateinitVar: String +} + +@GenerateOpenApi +fun Application.moduleKdocs() { + routing { + route("/v1") { + post("/action") { + call.receive() + } + } + } +} diff --git a/gradle.properties b/gradle.properties index 3292f94..256eeeb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=io.github.tabilzad -VERSION_NAME=0.6.3-alpha +VERSION_NAME=0.6.4-alpha POM_LICENCE_NAME=The Apache Software License, Version 2.0 POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt diff --git a/ktor-docs-plugin-gradle/src/main/kotlin/io.github.tabilzad/ktor/KtorInspektorGradleConfig.kt b/ktor-docs-plugin-gradle/src/main/kotlin/io.github.tabilzad/ktor/KtorInspektorGradleConfig.kt index 6b79384..3d5b79b 100644 --- a/ktor-docs-plugin-gradle/src/main/kotlin/io.github.tabilzad/ktor/KtorInspektorGradleConfig.kt +++ b/ktor-docs-plugin-gradle/src/main/kotlin/io.github.tabilzad/ktor/KtorInspektorGradleConfig.kt @@ -8,6 +8,7 @@ open class DocumentationOptions( var hideTransientFields: Boolean = true, var hidePrivateAndInternalFields: Boolean = true, var deriveFieldRequirementFromTypeNullability: Boolean = true, + var useKDocsForDescriptions: Boolean = true, var servers: List = emptyList() ) diff --git a/ktor-docs-plugin-gradle/src/main/kotlin/io.github.tabilzad/ktor/KtorMetaPlugin.kt b/ktor-docs-plugin-gradle/src/main/kotlin/io.github.tabilzad/ktor/KtorMetaPlugin.kt index afb697d..100b656 100644 --- a/ktor-docs-plugin-gradle/src/main/kotlin/io.github.tabilzad/ktor/KtorMetaPlugin.kt +++ b/ktor-docs-plugin-gradle/src/main/kotlin/io.github.tabilzad/ktor/KtorMetaPlugin.kt @@ -94,6 +94,10 @@ open class KtorMetaPlugin : KotlinCompilerPluginSupportPlugin { key = "servers", value = swaggerExtension.documentation.servers.joinToString("||") ), + SubpluginOption( + key = "useKdocs", + value = swaggerExtension.documentation.useKDocsForDescriptions.toString() + ), SubpluginOption( key = "format", value = swaggerExtension.pluginOptions.format