Skip to content

Commit

Permalink
Derive schema/property descriptions from KDocs by default
Browse files Browse the repository at this point in the history
  • Loading branch information
tabilzad committed Sep 17, 2024
1 parent 691207b commit 9b4f1ae
Show file tree
Hide file tree
Showing 11 changed files with 198 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"

Expand All @@ -54,6 +57,7 @@ object SwaggerConfigurationKeys {
val ARG_DERIVE_PROP_REQ = CompilerConfigurationKey.create<Boolean>(OPTION_DERIVE_PROP_REQ)
val ARG_FORMAT = CompilerConfigurationKey.create<String>(OPTION_FORMAT)
val ARG_SERVERS = CompilerConfigurationKey.create<List<String>>(OPTION_SERVERS)
val ARG_KDOCS = CompilerConfigurationKey.create<Boolean>(OPTION_USE_KDOCS)
}

@OptIn(ExperimentalCompilerApi::class)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -144,7 +154,8 @@ class KtorDocsCommandLineProcessor : CommandLineProcessor {
hidePrivateAndInternalFields,
derivePropRequirement,
formatOption,
serverUrls
serverUrls,
useKDocs
)


Expand Down Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ internal data class PluginConfiguration(
val hideTransients: Boolean,
val hidePrivateFields: Boolean,
val servers: List<String>,
val deriveFieldRequirementFromTypeNullability: Boolean
val deriveFieldRequirementFromTypeNullability: Boolean,
val useKDocsForDescriptions: Boolean
) {
companion object {
fun createDefault(
Expand All @@ -27,6 +28,7 @@ internal data class PluginConfiguration(
hidePrivateFields: Boolean? = null,
servers: List<String>? = null,
deriveFieldRequirementFromTypeNullability: Boolean? = null,
useKDocsForDescriptions: Boolean? = null
): PluginConfiguration = PluginConfiguration(
isEnabled = isEnabled ?: true,
format = format ?: "yaml",
Expand All @@ -39,6 +41,7 @@ internal data class PluginConfiguration(
hidePrivateFields = hidePrivateFields ?: true,
deriveFieldRequirementFromTypeNullability = deriveFieldRequirementFromTypeNullability ?: true,
servers = servers ?: emptyList(),
useKDocsForDescriptions = useKDocsForDescriptions ?: true
)
}
}
29 changes: 29 additions & 0 deletions create-plugin/src/main/kotlin/io/github/tabilzad/ktor/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.*
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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!!
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,23 +244,35 @@ 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)
}

@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)
}

@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)
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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))
Expand All @@ -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"
}
Expand Down
54 changes: 54 additions & 0 deletions create-plugin/src/test/resources/expected/KDocs-expected.json
Original file line number Diff line number Diff line change
@@ -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" ]
}
}
}
}
Loading

0 comments on commit 9b4f1ae

Please sign in to comment.