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

feat: allow empty data classes in kotlin schema #582

Merged
merged 1 commit into from
Nov 10, 2023
Merged
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 @@ -5,6 +5,7 @@ import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution
import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
import org.jetbrains.kotlin.cfg.getElementParentDeclaration
import org.jetbrains.kotlin.descriptors.ClassDescriptor
import org.jetbrains.kotlin.js.descriptorUtils.getKotlinTypeFqName
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.*
import org.jetbrains.kotlin.psi.psiUtil.getValueParameters
Expand All @@ -14,12 +15,14 @@ import org.jetbrains.kotlin.resolve.calls.util.getType
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.getAbbreviation
import org.jetbrains.kotlin.types.typeUtil.requiresTypeAliasExpansion
import org.jetbrains.kotlin.utils.addToStdlib.ifNotEmpty
import xyz.block.ftl.Context
import xyz.block.ftl.Ignore
import xyz.block.ftl.Ingress
import xyz.block.ftl.Method
import xyz.block.ftl.schemaextractor.SchemaExtractor.Companion.moduleName
import xyz.block.ftl.schemaextractor.SchemaExtractor.Companion.extractModuleName
import xyz.block.ftl.v1.schema.*
import xyz.block.ftl.v1.schema.Array
import java.io.File
Expand All @@ -29,15 +32,14 @@ import java.time.OffsetDateTime
import kotlin.Boolean
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()
private var moduleName: String? = null
private val decls: MutableSet<Decl> = mutableSetOf()
private val comments: MutableSet<String> = mutableSetOf()

override val issue = Issue(
javaClass.simpleName,
Expand All @@ -57,8 +59,8 @@ class ExtractSchemaRule(config: Config) : Rule(config) {
}

runCatching {
val currentModuleName = annotationEntry.containingKtFile.packageFqName.moduleName()
val extractor = SchemaExtractor(this.bindingContext, modules, currentModuleName, annotationEntry)
moduleName = annotationEntry.containingKtFile.packageFqName.extractModuleName()
val extractor = SchemaExtractor(this.bindingContext, decls, comments, moduleName!!, annotationEntry)
extractor.extract()
}.onFailure {
when (it) {
Expand All @@ -70,42 +72,82 @@ class ExtractSchemaRule(config: Config) : Rule(config) {

override fun postVisit(root: KtFile) {
val outputDirectory = File(output).also { Path.of(it.absolutePath).createDirectories() }
val file = File(outputDirectory.absolutePath, OUTPUT_FILENAME)
file.createNewFile()
val os = FileOutputStream(file)
os.write(toModule().encode())
os.close()
}

modules.toModules().forEach {
val file = File(outputDirectory.absolutePath, OUTPUT_FILENAME)
file.createNewFile()
val os = FileOutputStream(file)
os.write(it.encode())
os.close()
}
private fun toModule(): Module {
return Module(name = moduleName!!, decls = decls.sortedBy { it.data_ == null }, comments = comments.toList())
}

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(
private val bindingContext: BindingContext,
private val modules: MutableMap<String, ModuleData>,
private val decls: MutableSet<Decl>,
private val comments: MutableSet<String>,
private val currentModuleName: String,
annotation: KtAnnotationEntry
) {
private val verb: KtNamedFunction
private val module: KtDeclaration

init {
requireNotNull(annotation.getElementParentDeclaration()) { "Could not extract $currentModuleName verb definition" }.let {
require(it is KtNamedFunction) { "Failure extracting ${it.name}; verbs must be functions" }
verb = it
}
module = requireNotNull(verb.getElementParentDeclaration()) { "Could not extract $currentModuleName definition" }

// Skip ignored modules.
if (module.annotationEntries.firstOrNull {
bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Ignore::class.qualifiedName
} != null) {
throw IgnoredModuleException()
}

requireNotNull(verb.fqName?.asString()) {
"Verbs must be defined in a package"
}.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, ${verb.name} did not" }
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()
?.let { it.toClassDescriptor().isData || it.isEmptyClassTypeAlias() }
?: false
) {
"Second argument of ${verb.name} must be a data class or typealias of Unit"
}

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

fun extract() {
val requestType = requireNotNull(verb.valueParameters.last().typeReference?.resolveType()) {
"Could not resolve verb request type"
"Could not resolve request type for ${verb.name}"
}
val responseType = requireNotNull(verb.createTypeBindingForReturnType(bindingContext)?.type) {
"Could not resolve verb response type"
"Could not resolve response type for ${verb.name}"
}

val metadata = mutableListOf<Metadata>()
Expand All @@ -120,19 +162,17 @@ class SchemaExtractor(
comments = verb.comments(),
)

val moduleData = ModuleData(
decls = mutableSetOf(Decl(verb = verb), *extractDataDeclarations().toTypedArray()),
comments = module.comments()
)
modules[currentModuleName]?.decls?.addAll(moduleData.decls) ?: run {
modules[currentModuleName] = moduleData
}
decls += setOf(Decl(verb = verb), *extractDataDeclarations().toTypedArray())
comments += module.comments()
}

private fun extractDataDeclarations(): Set<Decl> {
return verb.containingKtFile.children
.filter { it is KtClass && it.isData() }
.map { Decl(data_ = (it as KtClass).toSchemaData()) }
.filter { (it is KtClass && it.isData()) || it is KtTypeAlias }
.mapNotNull {
val data = (it as? KtClass)?.toSchemaData() ?: (it as? KtTypeAlias)?.toSchemaData()
data?.let { Decl(data_ = data) }
}
.toSet()
}

Expand Down Expand Up @@ -178,7 +218,7 @@ class SchemaExtractor(

val func = element as? KtNamedFunction
if (func != null) {
val body = requireNotNull(func.bodyExpression) { "Could not parse empty function body" }
val body = requireNotNull(func.bodyExpression) { "Function body cannot be empty; was in ${func.name}" }
val imports = func.containingKtFile.importList?.imports?.mapNotNull { it.importedFqName } ?: emptyList()

// Look for all params of type Context and extract a matcher for each based on its variable name.
Expand All @@ -197,7 +237,7 @@ class SchemaExtractor(
}
// TODO(worstell): Figure out how to get module name when not imported from another Kt file
val moduleRefName = imports.firstOrNull { import -> import.toString().contains(req) }
?.moduleName().takeIf { refModule -> refModule != currentModuleName }
?.extractModuleName().takeIf { refModule -> refModule != currentModuleName }

VerbRef(
name = verbCall.split("::")[1].trim(),
Expand All @@ -216,6 +256,13 @@ class SchemaExtractor(
}
}

private fun KtTypeAlias.toSchemaData(): Data {
return Data(
name = this.name!!,
comments = this.comments()
)
}

private fun KtClass.toSchemaData(): Data {
return Data(
name = this.name!!,
Expand Down Expand Up @@ -255,14 +302,30 @@ class SchemaExtractor(
}

else -> {
require(
this.toClassDescriptor().isData
&& (this.fqNameOrNull()?.asString()?.startsWith("ftl.") ?: false)
) { "Expected module name to be in the form ftl.<module>, but was ${this.fqNameOrNull()?.asString()}" }
require(this.toClassDescriptor().isData || this.isEmptyClassTypeAlias()) {
"Expected type to be a data class or typealias of Unit, but was ${this.fqNameOrNull()?.asString()}"
}

var refName: String
var fqName: String
if (this.isEmptyClassTypeAlias()) {
this.unwrap().getAbbreviation()!!.run {
fqName = this.getKotlinTypeFqName(false)
refName = this.constructor.declarationDescriptor?.name?.asString()!!
}
} else {
fqName = this.fqNameOrNull()!!.asString()
refName = this.toClassDescriptor().name.asString()
}

require(fqName.startsWith("ftl.")) {
"Expected module name to be in the form ftl.<module>, but was ${this.fqNameOrNull()?.asString()}"
}

return Type(
dataRef = DataRef(
name = this.toClassDescriptor().name.asString(),
module = this.fqNameOrNull()!!.moduleName().takeIf { it != currentModuleName } ?: "",
name = refName,
module = fqName.extractModuleName().takeIf { it != currentModuleName } ?: "",
)
)
}
Expand All @@ -273,45 +336,6 @@ class SchemaExtractor(
bindingContext.get(BindingContext.TYPE, this)
?: throw IllegalStateException("Could not resolve type ${this.text}")

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

// Skip ignored modules.
if (module.annotationEntries.firstOrNull {
bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Ignore::class.qualifiedName
} != null) {
throw IgnoredModuleException()
}

requireNotNull(verb.fqName?.asString()) {
"Verbs must be defined in a package"
}.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" }
}
}

companion object {
private fun getCallMatcher(ctxVarName: String): Regex {
return """${ctxVarName}.call\((?<fn>[^)]+),(?<req>[^)]+)\(.*\)\)""".toRegex(RegexOption.IGNORE_CASE)
Expand All @@ -321,13 +345,24 @@ class SchemaExtractor(
this.unwrap().constructor.declarationDescriptor as? ClassDescriptor
?: throw IllegalStateException("Could not resolve KotlinType to class")

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

private fun String.extractModuleName(): String {
return this.split(".")[1]
}

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

// `typealias <name> = Unit` can be used in Kotlin to declare an empty FTL data type.
// This is a workaround to support empty objects in the FTL schema despite being unsupported by Kotlin data classes.
private fun KotlinType.isEmptyClassTypeAlias(): Boolean {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic!

return this.fqNameOrNull()?.asString() == Unit::class.qualifiedName
&& (this.unwrap().getAbbreviation()?.requiresTypeAliasExpansion() ?: false)
}
}
}