diff --git a/examples/echo-kotlin/pom.xml b/examples/echo-kotlin/pom.xml index 5092fb5e95..cdaba9e35f 100644 --- a/examples/echo-kotlin/pom.xml +++ b/examples/echo-kotlin/pom.xml @@ -62,6 +62,26 @@ + + + ksp + + + + + + + + com.dyescape + kotlin-maven-symbol-processing + 1.6 + + + ${project.groupId} + ftl-runtime + ${ftl.version} + + diff --git a/kotlin-runtime/ftl-runtime/pom.xml b/kotlin-runtime/ftl-runtime/pom.xml index 9a081444c6..5b1ff197c7 100644 --- a/kotlin-runtime/ftl-runtime/pom.xml +++ b/kotlin-runtime/ftl-runtime/pom.xml @@ -32,6 +32,12 @@ 1.6.4 + + com.google.devtools.ksp + symbol-processing-api + 1.9.20-RC-1.0.13 + + io.github.classgraph diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/ksp/SchemaExtractor.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/ksp/SchemaExtractor.kt new file mode 100644 index 0000000000..5e3b00bdb5 --- /dev/null +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/ksp/SchemaExtractor.kt @@ -0,0 +1,183 @@ +package xyz.block.ftl.ksp + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.closestClassDeclaration +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.processing.* +import com.google.devtools.ksp.symbol.* +import com.google.devtools.ksp.validate +import xyz.block.ftl.Context +import xyz.block.ftl.Ignore +import xyz.block.ftl.Ingress +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.io.path.createDirectories +import kotlin.reflect.KClass + +data class ModuleData(val comments: List = emptyList(), val decls: MutableSet) + +class Visitor(val logger: KSPLogger, val modules: MutableMap) : + KSVisitorVoid() { + @OptIn(KspExperimental::class) + override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) { + // Skip ignored classes. + if (function.closestClassDeclaration()?.getAnnotationsByType(Ignore::class)?.firstOrNull() != null) { + return + } + + validateVerb(function) + + val metadata = mutableListOf() + val moduleName = function.qualifiedName!!.moduleName() + val requestType = function.parameters.last().type.resolve().declaration + val responseType = function.returnType!!.resolve().declaration + + function.getAnnotationsByType(Ingress::class).firstOrNull()?.apply { + metadata += Metadata( + ingress = MetadataIngress( + path = this.path, + method = this.method.toString() + ) + ) + + val verb = Verb( + name = function.simpleName.asString(), + request = requestType.toSchemaType().dataRef, + response = responseType.toSchemaType().dataRef, + metadata = metadata, + comments = function.comments(), + ) + + val requestData = Decl(data_ = requestType.closestClassDeclaration()!!.toSchemaData()) + val responseData = Decl(data_ = responseType.closestClassDeclaration()!!.toSchemaData()) + val decls = mutableSetOf(Decl(verb = verb), requestData, responseData) + modules[moduleName]?.let { decls.addAll(it.decls) } + modules[moduleName] = ModuleData( + decls = decls, + comments = function.closestClassDeclaration()?.comments() ?: emptyList(), + ) + } + } + + private fun validateVerb(verb: KSFunctionDeclaration) { + val params = verb.parameters.map { it.type.resolve().declaration } + require(params.size == 2) { "Verbs must have exactly two arguments" } + require(params.first().toKClass() == Context::class) { "First argument of verb must be Context" } + require(params.last().modifiers.contains(Modifier.DATA)) { "Second argument of verb must be a data class" } + require(verb.returnType?.resolve()?.declaration?.modifiers?.contains(Modifier.DATA) == true) { + "Return type of verb must be a data class" + } + + val qualifiedName = verb.qualifiedName!!.asString() + require(qualifiedName.split(".").let { it.size >= 2 && it.first() == "ftl" }) { + "Expected @Verb to be in package ftl., but was $qualifiedName" + } + } + + private fun KSClassDeclaration.toSchemaData(): Data { + return Data( + name = this.simpleName.asString(), + fields = this.getAllProperties() + .map { param -> + Field( + name = param.simpleName.asString(), + type = param.type.resolve().declaration.toSchemaType(param.type.element?.typeArguments) + ) + }.toList(), + comments = this.comments(), + ) + } + + private fun KSDeclaration.toSchemaType(typeArguments: List? = emptyList()): Type { + return when (this.qualifiedName!!.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 = Bool()) + OffsetDateTime::class.qualifiedName -> Type(time = Time()) + Map::class.qualifiedName -> { + return Type( + map = xyz.block.ftl.v1.schema.Map( + key = typeArguments!!.first() + .let { it.type?.resolve()?.declaration?.toSchemaType(it.type?.element?.typeArguments) }, + value_ = typeArguments.last() + .let { it.type?.resolve()?.declaration?.toSchemaType(it.type?.element?.typeArguments) }, + ) + ) + } + + List::class.qualifiedName -> { + return Type( + array = Array( + element = typeArguments!!.first() + .let { it.type?.resolve()?.declaration?.toSchemaType(it.type?.element?.typeArguments) } + ) + ) + } + + else -> { + this.closestClassDeclaration()?.let { + if (it.simpleName != this.simpleName) { + return@let + } + + // Make sure any nested data classes are included in the module schema. + val decl = Decl(data_ = it.toSchemaData()) + val moduleName = it.qualifiedName!!.moduleName() + modules[moduleName]?.decls?.add(decl) ?: { modules[moduleName] = ModuleData(decls = mutableSetOf(decl)) } + } + return Type(dataRef = DataRef(name = this.simpleName.asString())) + } + } + } + + companion object { + private fun KSDeclaration.toKClass(): KClass<*> { + return Class.forName(this.qualifiedName?.asString()).kotlin + } + + private fun KSDeclaration.comments(): List { + return this.docString?.trim()?.let { listOf(it) } ?: emptyList() + } + + private fun KSName.moduleName(): String { + return this.asString().split(".")[1] + } + } +} + +class SchemaExtractor(val logger: KSPLogger, val options: Map) : SymbolProcessor { + override fun process(resolver: Resolver): List { + val dest = requireNotNull(options["dest"]) { "Must provide output directory for generated schemas" } + val outputDirectory = File(dest, "generated-sources/ksp").also { Path.of(it.absolutePath).createDirectories() } + val modules = mutableMapOf() + + val symbols = resolver.getSymbolsWithAnnotation("xyz.block.ftl.Verb") + val ret = symbols.filter { !it.validate() }.toList() + symbols + .filter { it is KSFunctionDeclaration && it.validate() } + .forEach { it.accept(Visitor(logger, modules), Unit) } + + modules.map { + Module(name = it.key, decls = it.value.decls.sortedBy { it.data_ == null }, comments = it.value.comments) + }.forEach { + val file = File(outputDirectory.absolutePath, it.name) + file.createNewFile() + val os = FileOutputStream(file) + os.write(it.encode()) + os.close() + } + + return ret + } +} + +class SchemaExtractorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return SchemaExtractor(environment.logger, environment.options) + } +} diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/SchemaReflector.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/SchemaReflector.kt deleted file mode 100644 index 1f295ab7c8..0000000000 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/SchemaReflector.kt +++ /dev/null @@ -1,34 +0,0 @@ -package xyz.block.ftl.registry - -import xyz.block.ftl.Context -import xyz.block.ftl.Ingress -import xyz.block.ftl.v1.schema.DataRef -import xyz.block.ftl.v1.schema.Metadata -import xyz.block.ftl.v1.schema.MetadataIngress -import xyz.block.ftl.v1.schema.Verb -import kotlin.reflect.KClass -import kotlin.reflect.KFunction -import kotlin.reflect.full.findAnnotation -import kotlin.reflect.full.hasAnnotation - -internal fun reflectSchemaFromFunc(func: KFunction<*>): Verb? { - if (!func.hasAnnotation()) return null - if (func.parameters.size != 3) error("Verbs must have exactly two arguments") - if (func.parameters[1].type.classifier != Context::class) error("First argument of verb must be Context") - val requestType = - func.parameters[2].type.classifier ?: error("Second argument of verb must be a data class") - if (!(requestType as KClass<*>).isData) error("Second argument of verb must be a data class not $requestType") - val returnType = func.returnType.classifier ?: error("Return type of verb must be a data class") - if (!(returnType as KClass<*>).isData) error("Return type of verb must be a data class not $returnType") - - return Verb( - name = func.name, - request = DataRef(name = requestType.simpleName!!), - response = DataRef(name = returnType.simpleName!!), - metadata = buildList { - func.findAnnotation()?.let { - add(Metadata(ingress = MetadataIngress(method = it.method.toString(), path = it.path))) - } - } - ) -} \ No newline at end of file diff --git a/kotlin-runtime/ftl-runtime/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/kotlin-runtime/ftl-runtime/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000000..43305409fe --- /dev/null +++ b/kotlin-runtime/ftl-runtime/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +xyz.block.ftl.ksp.SchemaExtractorProvider \ No newline at end of file diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/SchemaReflectorKtTest.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/SchemaReflectorKtTest.kt deleted file mode 100644 index 7e43018d50..0000000000 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/SchemaReflectorKtTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package xyz.block.ftl.registry - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import xyz.block.ftl.v1.schema.DataRef -import xyz.block.ftl.v1.schema.Metadata -import xyz.block.ftl.v1.schema.MetadataIngress -import xyz.block.ftl.v1.schema.Verb - -class SchemaReflectorKtTest { - @Test - fun reflectSchemaFromFunc() { - val expected = Verb( - name = "verb", - request = DataRef(name = "VerbRequest"), - response = DataRef(name = "VerbResponse"), - metadata = listOf( - Metadata(ingress = MetadataIngress(method = "GET", path = "/test")), - ), - ) - val actual = reflectSchemaFromFunc(ExampleVerb::verb) - assertEquals(expected, actual) - } -} \ No newline at end of file