Skip to content

Commit

Permalink
feat: use detekt to extract kotlin schema
Browse files Browse the repository at this point in the history
fixes #530
  • Loading branch information
worstell committed Nov 2, 2023
1 parent 821be2c commit 1cfa3d9
Show file tree
Hide file tree
Showing 8 changed files with 587 additions and 0 deletions.
27 changes: 27 additions & 0 deletions examples/echo-kotlin/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
<artifactId>ftl-generator</artifactId>
<version>${ftl.version}</version>
</dependency>
<dependency>
<groupId>io.gitlab.arturbosch.detekt</groupId>
<artifactId>detekt-compiler-plugin</artifactId>
<type>jar</type>
<version>1.23.1</version>
</dependency>
</dependencies>

<build>
Expand All @@ -48,6 +54,7 @@
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
</sourceDirs>
<output>${project.build.directory}</output>
</configuration>
</execution>
<execution>
Expand Down Expand Up @@ -125,6 +132,7 @@
</goals>
<configuration>
<outputFile>${project.build.directory}/classpath.txt</outputFile>
<outputProperty>generated.classpath</outputProperty>
<prefix>dependency</prefix>
</configuration>
</execution>
Expand Down Expand Up @@ -155,6 +163,25 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.ozsie</groupId>
<artifactId>detekt-maven-plugin</artifactId>
<version>1.23.1</version>
<configuration>
<disableDefaultRuleSets>true</disableDefaultRuleSets>
<classPath>${generated.classpath}</classPath>
<jvmTarget>17</jvmTarget>
<basePath>${project.build.directory}</basePath>
</configuration>
<executions>
<execution>
<phase>compile</phase>
<goals>
<goal>ctr</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,15 @@ class ModuleGenerator() {
""".trimIndent()
)

Path.of(buildDir, "detekt.yml").writeText(
"""
SchemaExtractorRuleSet:
ExtractSchemaRule:
active: true
output: ${buildDir}
""".trimIndent()
)

val mainFile = Path.of(buildDir, "main")
mainFile.writeText(
"""
Expand Down
13 changes: 13 additions & 0 deletions kotlin-runtime/ftl-runtime/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<properties>
<rootDir>${basedir}/../..</rootDir>
<maven.deploy.skip>false</maven.deploy.skip>
<detekt.version>1.23.1</detekt.version>
</properties>

<dependencies>
Expand All @@ -38,6 +39,18 @@
<version>1.9.20-RC-1.0.13</version>
</dependency>

<dependency>
<groupId>io.gitlab.arturbosch.detekt</groupId>
<artifactId>detekt-api</artifactId>
<version>${detekt.version}</version>
</dependency>

<dependency>
<groupId>io.gitlab.arturbosch.detekt</groupId>
<artifactId>detekt-test</artifactId>
<version>${detekt.version}</version>
</dependency>

<!-- Classgraph -->
<dependency>
<groupId>io.github.classgraph</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
package xyz.block.ftl.schemaextractor

import io.gitlab.arturbosch.detekt.api.*
import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution
import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
import org.jetbrains.kotlin.backend.common.serialization.metadata.findKDocString
import org.jetbrains.kotlin.cfg.getElementParentDeclaration
import org.jetbrains.kotlin.descriptors.ClassDescriptor
import org.jetbrains.kotlin.descriptors.impl.referencedProperty
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.*
import org.jetbrains.kotlin.resolve.BindingContext
import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall
import org.jetbrains.kotlin.resolve.calls.util.getType
import org.jetbrains.kotlin.resolve.scopes.DescriptorKindFilter
import org.jetbrains.kotlin.resolve.scopes.getDescriptorsFiltered
import org.jetbrains.kotlin.resolve.source.getPsi
import org.jetbrains.kotlin.resolve.typeBinding.createTypeBindingForReturnType
import org.jetbrains.kotlin.types.KotlinType
import org.jetbrains.kotlin.types.TypeProjection
import org.jetbrains.kotlin.utils.addToStdlib.ifNotEmpty
import xyz.block.ftl.Context
import xyz.block.ftl.Ingress
import xyz.block.ftl.schemaextractor.SchemaExtractor.Companion.moduleName
import xyz.block.ftl.v1.schema.*
import xyz.block.ftl.v1.schema.Array
import java.io.File
import java.io.FileOutputStream
import java.nio.file.Path
import java.time.OffsetDateTime
import kotlin.Boolean
import kotlin.Long
import kotlin.String
import kotlin.collections.Map
import kotlin.collections.set
import kotlin.io.path.createDirectories

data class ModuleData(val comments: List<String> = emptyList(), val decls: MutableSet<Decl> = mutableSetOf())

@RequiresTypeResolution
class ExtractSchemaRule(config: Config) : Rule(config) {
private val output: String by config(defaultValue = ".")
private val modules: MutableMap<String, ModuleData> = mutableMapOf()

override val issue = Issue(
javaClass.simpleName,
Severity.Performance,
"Custom Rule",
Debt.FIVE_MINS,
)

override fun visitAnnotationEntry(annotationEntry: KtAnnotationEntry) {
if (
bindingContext.get(
BindingContext.ANNOTATION,
annotationEntry
)?.fqName?.asString() != xyz.block.ftl.Verb::class.qualifiedName
) {
return
}

runCatching {
val extractor = SchemaExtractor(this.bindingContext, annotationEntry)
val moduleData = extractor.extract()

val moduleName = annotationEntry.containingKtFile.packageFqName.moduleName()
modules[moduleName]?.let { it.decls += moduleData.decls }
?: run { modules[moduleName] = moduleData }
}.onFailure {
when (it) {
is IgnoredModuleException -> return
else -> throw it
}
}
}

override fun postVisit(root: KtFile) {
val outputDirectory = File(output).also { Path.of(it.absolutePath).createDirectories() }

modules.toModules().forEach {
val file = File(outputDirectory.absolutePath, OUTPUT_FILENAME)
file.createNewFile()
val os = FileOutputStream(file)
os.write(it.encode())
os.close()
}
}

companion object {
const val OUTPUT_FILENAME = "schema.pb"

private fun Map<String, ModuleData>.toModules(): List<Module> {
return this.map {
Module(name = it.key, decls = it.value.decls.sortedBy { it.data_ == null }, comments = it.value.comments)
}
}
}
}

class IgnoredModuleException : Exception()
class SchemaExtractor(val bindingContext: BindingContext, annotation: KtAnnotationEntry) {
private val callMatcher: Regex
private val verb: KtNamedFunction
private val module: KtDeclaration
private val decls: MutableSet<Decl> = mutableSetOf()
fun extract(): ModuleData {
val requestType = verb.valueParameters.last().typeReference!!.resolveType()
val responseType = verb.createTypeBindingForReturnType(bindingContext)!!.type

val metadata = mutableListOf<Metadata>()
extractIngress()?.apply { metadata.add(Metadata(ingress = this)) }
extractCalls(verb, mutableListOf())?.apply { metadata.add(Metadata(calls = this)) }

val verb = Verb(
name = requireNotNull(verb.name) { "Verbs must be named" },
request = requestType.toSchemaType().dataRef,
response = responseType.toSchemaType().dataRef,
metadata = metadata,
comments = verb.comments(),
)

decls.addAll(
listOf(
Decl(verb = verb),
Decl(data_ = requestType.toSchemaData()),
Decl(data_ = responseType.toSchemaData())
)
)

return ModuleData(decls = decls, comments = module.comments())
}

private fun extractIngress(): MetadataIngress? {
return verb.annotationEntries.firstOrNull {
bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Ingress::class.qualifiedName
}?.let {
val argumentLists = it.valueArguments.partition { arg ->
// path arg is named "path" or is of type String (in the case where args are positional rather than named)
arg.getArgumentName()?.asName?.asString() == "path"
|| arg.getArgumentExpression()?.getType(bindingContext)?.fqNameOrNull()
?.asString() == String::class.qualifiedName
}
// NB: trim leading/trailing double quotes because KtStringTemplateExpression.text includes them
val pathArg = argumentLists.first.single().getArgumentExpression()!!.text.trim { it == '\"' }
val methodArg = argumentLists.second.single().getArgumentExpression()!!.text
MetadataIngress(
path = pathArg,
method = methodArg,
)
}
}

private fun extractCalls(func: KtNamedFunction, calls: MutableList<VerbRef>): MetadataCalls? {
val body = func.bodyExpression!!
val imports = func.containingKtFile.importList?.imports?.mapNotNull { it.importedFqName } ?: emptyList()

val refs = callMatcher.findAll(body.text).map {
val req = it.groups["req"]!!.value.trim()
val verbCall = it.groups["fn"]!!.value.trim()
val moduleRefName = imports.filter { it.toString().contains(req) }.first().moduleName()

VerbRef(
name = verbCall.split("::")[1].trim(),
module = moduleRefName,
)
}
calls.addAll(refs)

// Step into function calls inside this expression body to look for transitive calls.
body.children.mapNotNull {
(it as? KtCallExpression)?.getResolvedCall(bindingContext)?.candidateDescriptor?.source?.getPsi() as? KtNamedFunction
}.forEach {
extractCalls(it, calls)
}

return calls.ifNotEmpty { MetadataCalls(calls = this) }
}

private fun KotlinType.toSchemaData(): Data {
return Data(
name = this.toClassDescriptor().name.asString(),
fields = this.memberScope.getDescriptorsFiltered(DescriptorKindFilter.VARIABLES).map { property ->
val param = property.referencedProperty!!.type
Field(
name = property.name.asString(),
type = param.toSchemaType(param.arguments)
)
}.toList(),
comments = this.toClassDescriptor().findKDocString()?.trim()?.let { listOf(it) } ?: emptyList()
)
}

private fun KotlinType.toSchemaType(typeArguments: List<TypeProjection> = emptyList()): Type {
return when (this.fqNameOrNull()?.asString()) {
String::class.qualifiedName -> Type(string = xyz.block.ftl.v1.schema.String())
Int::class.qualifiedName -> Type(int = xyz.block.ftl.v1.schema.Int())
Long::class.qualifiedName -> Type(int = xyz.block.ftl.v1.schema.Int())
Boolean::class.qualifiedName -> Type(bool = xyz.block.ftl.v1.schema.Bool())
OffsetDateTime::class.qualifiedName -> Type(time = xyz.block.ftl.v1.schema.Time())
Map::class.qualifiedName -> {
return Type(
map = xyz.block.ftl.v1.schema.Map(
key = typeArguments.first().let { it.type.toSchemaType(it.type.getTypeArguments()) },
value_ = typeArguments.last().let { it.type.toSchemaType(it.type.getTypeArguments()) },
)
)
}

List::class.qualifiedName -> {
return Type(
array = Array(
element = typeArguments.first().let { it.type.toSchemaType(it.type.getTypeArguments()) }
)
)
}

else -> {
require(this.toClassDescriptor().isData) { "${this.fqNameOrNull()} type is not supported in FTL schema" }

// Make sure any nested data classes are included in the module schema.
decls.add(Decl(data_ = this.toSchemaData()))
return Type(
dataRef = DataRef(
name = this.toClassDescriptor().name.asString(),
module = this.fqNameOrNull()!!.moduleName()
)
)
}
}
}

private fun KtTypeReference.resolveType(): KotlinType =
bindingContext.get(BindingContext.TYPE, this)
?: throw IllegalStateException("Could not resolve type ${this.text}")

init {
val moduleName = annotation.containingKtFile.packageFqName.moduleName()
requireNotNull(annotation.getElementParentDeclaration()) { "Could not extract $moduleName verb definition" }.let {
require(it is KtNamedFunction) { "Verbs must be functions" }
verb = it
}
module = requireNotNull(verb.getElementParentDeclaration()) { "Could not extract $moduleName definition" }

// Skip ignored modules.
if (module.annotationEntries.firstOrNull { it.typeReference?.text == "Ignore" } != null) {
throw IgnoredModuleException()
}

verb.fqName!!.asString().let { fqName ->
require(fqName.split(".").let { it.size >= 2 && it.first() == "ftl" }) {
"Expected @Verb to be in package ftl.<module>, but was $fqName"
}

// Validate parameters
require(verb.valueParameters.size == 2) { "Verbs must have exactly two arguments" }
val ctxParam = verb.valueParameters.first()
val reqParam = verb.valueParameters.last()
require(ctxParam.typeReference?.resolveType()?.fqNameOrNull()?.asString() == Context::class.qualifiedName) {
"First argument of verb must be Context"
}
require(reqParam.typeReference?.resolveType()?.toClassDescriptor()?.isData ?: false) {
"Second argument of verb must be a data class"
}

// Validate return type
val respClass = verb.createTypeBindingForReturnType(bindingContext)?.type?.toClassDescriptor()
?: throw IllegalStateException("Could not resolve verb return type")
require(respClass.isData) { "Return type of verb must be a data class" }

val ctxVarName = ctxParam.text.split(":")[0].trim()
callMatcher = """${ctxVarName}.call\((?<fn>[^)]+),(?<req>[^)]+)\(\)\)""".toRegex(RegexOption.IGNORE_CASE)
}
}

companion object {
private fun KotlinType.getTypeArguments(): List<TypeProjection> =
this.memberScope.getDescriptorsFiltered(DescriptorKindFilter.VARIABLES)
.flatMap { it.referencedProperty!!.type.arguments }

private fun KotlinType.toClassDescriptor(): ClassDescriptor =
this.unwrap().constructor.declarationDescriptor as? ClassDescriptor
?: throw IllegalStateException("could not resolve KotlinType to class")

fun FqName.moduleName(): String {
return this.asString().split(".")[1]
}

private fun KtDeclaration.comments(): List<String> {
return this.docComment?.text?.trim()?.let { listOf(it) } ?: emptyList()
}
}
}

Loading

0 comments on commit 1cfa3d9

Please sign in to comment.