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