Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Derive schema/property descriptions from KDocs by default #25

Merged
merged 1 commit into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading