diff --git a/examples/kotlin/ftl-module-ad/src/main/kotlin/ftl/ad/Ad.kt b/examples/kotlin/ftl-module-ad/src/main/kotlin/ftl/ad/Ad.kt index 41c9ae0ce1..1ce71cc9a4 100644 --- a/examples/kotlin/ftl-module-ad/src/main/kotlin/ftl/ad/Ad.kt +++ b/examples/kotlin/ftl-module-ad/src/main/kotlin/ftl/ad/Ad.kt @@ -14,48 +14,46 @@ data class Ad(val redirectUrl: String, val text: String) data class AdRequest(val contextKeys: List? = null) data class AdResponse(val ads: List) -class AdModule { - private val database: Map = loadDatabase() - - @Verb - @HttpIngress(Method.GET, "/get") - fun get(context: Context, req: HttpRequest): HttpResponse { - val ads: List = when { - req.body.contextKeys != null -> contextualAds(req.body.contextKeys) - else -> randomAds() - } - - return HttpResponse(status = 200, headers = emptyMap(), body = AdResponse(ads = ads)) +private val database: Map = loadDatabase() + +@Verb +@HttpIngress(Method.GET, "/get") +fun get(context: Context, req: HttpRequest): HttpResponse { + val ads: List = when { + req.body.contextKeys != null -> contextualAds(req.body.contextKeys) + else -> randomAds() } - private fun contextualAds(contextKeys: List): List { - return contextKeys.map { database[it] ?: throw Exception("no ad registered for this context key") } - } + return HttpResponse(status = 200, headers = emptyMap(), body = AdResponse(ads = ads)) +} - private fun randomAds(): List { - val ads = mutableListOf() - val random = Random() - repeat(MAX_ADS_TO_SERVE) { - ads.add(database.entries.elementAt(random.nextInt(database.size)).value) - } - return ads - } +private fun contextualAds(contextKeys: List): List { + return contextKeys.map { database[it] ?: throw Exception("no ad registered for this context key") } +} - companion object { - private const val MAX_ADS_TO_SERVE = 2 - private val DATABASE = mapOf( - "hair" to Ad("/product/2ZYFJ3GM2N", "Hairdryer for sale. 50% off."), - "clothing" to Ad("/product/66VCHSJNUP", "Tank top for sale. 20% off."), - "accessories" to Ad("/product/1YMWWN1N4O", "Watch for sale. Buy one, get second kit for free"), - "footwear" to Ad("/product/L9ECAV7KIM", "Loafers for sale. Buy one, get second one for free"), - "decor" to Ad("/product/0PUK6V6EV0", "Candle holder for sale. 30% off."), - "kitchen" to Ad("/product/9SIQT8TOJO", "Bamboo glass jar for sale. 10% off.") - ) - - private fun loadDatabase(): Map { - return DATABASE - } - - inline fun Gson.fromJson(json: String) = fromJson(json, object : TypeToken() {}.type) +private fun randomAds(): List { + val ads = mutableListOf() + val random = Random() + repeat(MAX_ADS_TO_SERVE) { + ads.add(database.entries.elementAt(random.nextInt(database.size)).value) } + return ads } + +private const val MAX_ADS_TO_SERVE = 2 +private val DATABASE = mapOf( + "hair" to Ad("/product/2ZYFJ3GM2N", "Hairdryer for sale. 50% off."), + "clothing" to Ad("/product/66VCHSJNUP", "Tank top for sale. 20% off."), + "accessories" to Ad("/product/1YMWWN1N4O", "Watch for sale. Buy one, get second kit for free"), + "footwear" to Ad("/product/L9ECAV7KIM", "Loafers for sale. Buy one, get second one for free"), + "decor" to Ad("/product/0PUK6V6EV0", "Candle holder for sale. 30% off."), + "kitchen" to Ad("/product/9SIQT8TOJO", "Bamboo glass jar for sale. 10% off.") +) + +private fun loadDatabase(): Map { + return DATABASE +} + +inline fun Gson.fromJson(json: String) = fromJson(json, object : TypeToken() {}.type) + + diff --git a/examples/kotlin/ftl-module-api/src/main/kotlin/ftl/api/Api.kt b/examples/kotlin/ftl-module-api/src/main/kotlin/ftl/api/Api.kt index 120cd2b76a..753d831f1a 100644 --- a/examples/kotlin/ftl-module-api/src/main/kotlin/ftl/api/Api.kt +++ b/examples/kotlin/ftl-module-api/src/main/kotlin/ftl/api/Api.kt @@ -53,48 +53,47 @@ private val todos = ConcurrentHashMap() private val idCounter = AtomicInteger() private val gson = makeGson() -class Api { - private val headers = mapOf("Content-Type" to arrayListOf("application/json")) +private val headers = mapOf("Content-Type" to arrayListOf("application/json")) - @Verb - @HttpIngress(Method.GET, "/api/status") - fun status(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse(status = 200, headers = mapOf(), body = GetStatusResponse("OK")) - } +@Verb +@HttpIngress(Method.GET, "/api/status") +fun status(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse(status = 200, headers = mapOf(), body = GetStatusResponse("OK")) +} - @Verb - @HttpIngress(Method.GET, "/api/todos/{id}") - fun getTodo(context: Context, req: HttpRequest): HttpResponse { - val todoId = req.pathParameters["id"]?.toIntOrNull() - val todo = todos[todoId] - - return if (todo != null) { - HttpResponse( - status = 200, - headers = mapOf(), - body = GetTodoResponse(todo) - ) - } else { - HttpResponse(status = 404, headers = mapOf(), body = GetTodoResponse(null)) - } - } +@Verb +@HttpIngress(Method.GET, "/api/todos/{id}") +fun getTodo(context: Context, req: HttpRequest): HttpResponse { + val todoId = req.pathParameters["id"]?.toIntOrNull() + val todo = todos[todoId] - @Verb - @HttpIngress(Method.POST, "/api/todos") - fun addTodo(context: Context, req: HttpRequest): HttpResponse { - val todoReq = req.body - val id = idCounter.incrementAndGet() - todos.put( - id, Todo( - id = id, - title = todoReq.title, - ) + return if (todo != null) { + HttpResponse( + status = 200, + headers = mapOf(), + body = GetTodoResponse(todo) ) + } else { + HttpResponse(status = 404, headers = mapOf(), error = "Not found") + } +} - return HttpResponse( - status = 201, - headers = headers, - body = CreateTodoResponse(id), +@Verb +@HttpIngress(Method.POST, "/api/todos") +fun addTodo(context: Context, req: HttpRequest): HttpResponse { + val todoReq = req.body + val id = idCounter.incrementAndGet() + todos.put( + id, Todo( + id = id, + title = todoReq.title, ) - } + ) + + return HttpResponse( + status = 201, + headers = headers, + body = CreateTodoResponse(id), + ) } + diff --git a/examples/kotlin/ftl-module-echo/src/main/kotlin/ftl/echo/Echo.kt b/examples/kotlin/ftl-module-echo/src/main/kotlin/ftl/echo/Echo.kt index b5cd156d9d..255c4666bd 100644 --- a/examples/kotlin/ftl-module-echo/src/main/kotlin/ftl/echo/Echo.kt +++ b/examples/kotlin/ftl-module-echo/src/main/kotlin/ftl/echo/Echo.kt @@ -1,9 +1,8 @@ package ftl.echo -import ftl.time.TimeModuleClient -import ftl.time.TimeRequest +import ftl.builtin.Empty +import ftl.time.time import xyz.block.ftl.Context -import xyz.block.ftl.Method import xyz.block.ftl.Verb class InvalidInput(val field: String) : Exception() @@ -11,11 +10,9 @@ class InvalidInput(val field: String) : Exception() data class EchoRequest(val name: String?) data class EchoResponse(val message: String) -class Echo { - @Throws(InvalidInput::class) - @Verb - fun echo(context: Context, req: EchoRequest): EchoResponse { - val response = context.call(TimeModuleClient::time, TimeRequest) - return EchoResponse(message = "Hello, ${req.name ?: "anonymous"}! The time is ${response.time}.") - } +@Throws(InvalidInput::class) +@Verb +fun echo(context: Context, req: EchoRequest): EchoResponse { + val response = context.call(::time, Empty()) + return EchoResponse(message = "Hello, ${req.name ?: "anonymous"}! The time is ${response.time}.") } diff --git a/integration/testdata/kotlin/database/Echo.kt b/integration/testdata/kotlin/database/Echo.kt index 4cac4d61f3..8aec137710 100644 --- a/integration/testdata/kotlin/database/Echo.kt +++ b/integration/testdata/kotlin/database/Echo.kt @@ -9,18 +9,16 @@ data class InsertRequest(val data: String) val db = Database("testdb") -class Echo { - - @Verb - fun insert(context: Context, req: InsertRequest): Empty { - persistRequest(req) - return Empty() - } +@Verb +fun insert(context: Context, req: InsertRequest): Empty { + persistRequest(req) + return Empty() +} - fun persistRequest(req: InsertRequest) { - db.conn { - it.prepareStatement( - """ +fun persistRequest(req: InsertRequest) { + db.conn { + it.prepareStatement( + """ CREATE TABLE IF NOT EXISTS requests ( data TEXT, @@ -28,9 +26,8 @@ class Echo { updated_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc') ); """ - ).execute() - it.prepareStatement("INSERT INTO requests (data) VALUES ('${req.data}');") - .execute() - } + ).execute() + it.prepareStatement("INSERT INTO requests (data) VALUES ('${req.data}');") + .execute() } } diff --git a/integration/testdata/kotlin/externalcalls/Echo.kt b/integration/testdata/kotlin/externalcalls/Echo.kt index 528beeb903..8d9ba64154 100644 --- a/integration/testdata/kotlin/externalcalls/Echo.kt +++ b/integration/testdata/kotlin/externalcalls/Echo.kt @@ -1,21 +1,19 @@ package ftl.echo -import ftl.echo2.Echo2ModuleClient +import ftl.echo2.echo as echo2 import xyz.block.ftl.Context import xyz.block.ftl.Verb data class EchoRequest(val name: String) data class EchoResponse(val message: String) -class Echo { - @Verb - fun echo(context: Context, req: EchoRequest): EchoResponse { - return EchoResponse(message = "Hello, ${req.name}!") - } +@Verb +fun echo(context: Context, req: EchoRequest): EchoResponse { + return EchoResponse(message = "Hello, ${req.name}!") +} - @Verb - fun call(context: Context, req: EchoRequest): EchoResponse { - val res = context.call(Echo2ModuleClient::echo, ftl.echo2.EchoRequest(name = req.name)) - return EchoResponse(message = res.message) - } +@Verb +fun call(context: Context, req: EchoRequest): EchoResponse { + val res = context.call(::echo2, ftl.echo2.EchoRequest(name = req.name)) + return EchoResponse(message = res.message) } diff --git a/integration/testdata/kotlin/httpingress/Echo.kt b/integration/testdata/kotlin/httpingress/Echo.kt index 599723c24c..eec54a96cc 100644 --- a/integration/testdata/kotlin/httpingress/Echo.kt +++ b/integration/testdata/kotlin/httpingress/Echo.kt @@ -43,128 +43,129 @@ data class DeleteRequest( @Alias("userId") val userID: String, ) -class Echo { - @Verb - @HttpIngress( - Method.GET, "/users/{userID}/posts/{postID}") - fun `get`(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Get" to arrayListOf("Header from FTL")), - body = GetResponse( - message = "UserID: ${req.body.userID}, PostID: ${req.body.postID}", - nested = Nested(goodStuff = "This is good stuff") - ) - ) - } - - @Verb - @HttpIngress(Method.POST, "/users") - fun post(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 201, - headers = mapOf("Post" to arrayListOf("Header from FTL")), - body = PostResponse(success = true) - ) - } - - @Verb - @HttpIngress(Method.PUT, "/users/{userId}") - fun put(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Put" to arrayListOf("Header from FTL")), - body = Empty() - ) - } - - @Verb - @HttpIngress(Method.DELETE, "/users/{userId}") - fun delete(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Delete" to arrayListOf("Header from FTL")), - body = Empty() - ) - } - - @Verb - @HttpIngress(Method.GET, "/html") - fun html(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Content-Type" to arrayListOf("text/html; charset=utf-8")), - body = "

HTML Page From FTL 🚀!

", - ) - } - - @Verb - @HttpIngress(Method.POST, "/bytes") - fun bytes(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Content-Type" to arrayListOf("application/octet-stream")), - body = req.body, - ) - } - - @Verb - @HttpIngress(Method.GET, "/empty") - fun empty(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Empty" to arrayListOf("Header from FTL")), - body = Unit - ) - } - - @Verb - @HttpIngress(Method.GET, "/string") - fun string(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("String" to arrayListOf("Header from FTL")), - body = req.body - ) - } - - @Verb - @HttpIngress(Method.GET, "/int") - fun int(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Int" to arrayListOf("Header from FTL")), - body = req.body - ) - } - - @Verb - @HttpIngress(Method.GET, "/float") - fun float(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Float" to arrayListOf("Header from FTL")), - body = req.body - ) - } - - @Verb - @HttpIngress(Method.GET, "/bool") - fun bool(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 200, - headers = mapOf("Bool" to arrayListOf("Header from FTL")), - body = req.body - ) - } - - @Verb - @HttpIngress(Method.GET, "/error") - fun error(context: Context, req: HttpRequest): HttpResponse { - return HttpResponse( - status = 500, - headers = mapOf("Error" to arrayListOf("Header from FTL")), - error = "Error from FTL" + +@Verb +@HttpIngress( + Method.GET, "/users/{userID}/posts/{postID}" +) +fun `get`(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Get" to arrayListOf("Header from FTL")), + body = GetResponse( + message = "UserID: ${req.body.userID}, PostID: ${req.body.postID}", + nested = Nested(goodStuff = "This is good stuff") ) - } + ) +} + +@Verb +@HttpIngress(Method.POST, "/users") +fun post(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 201, + headers = mapOf("Post" to arrayListOf("Header from FTL")), + body = PostResponse(success = true) + ) +} + +@Verb +@HttpIngress(Method.PUT, "/users/{userId}") +fun put(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Put" to arrayListOf("Header from FTL")), + body = Empty() + ) +} + +@Verb +@HttpIngress(Method.DELETE, "/users/{userId}") +fun delete(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Delete" to arrayListOf("Header from FTL")), + body = Empty() + ) +} + +@Verb +@HttpIngress(Method.GET, "/html") +fun html(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Content-Type" to arrayListOf("text/html; charset=utf-8")), + body = "

HTML Page From FTL 🚀!

", + ) +} + +@Verb +@HttpIngress(Method.POST, "/bytes") +fun bytes(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Content-Type" to arrayListOf("application/octet-stream")), + body = req.body, + ) +} + +@Verb +@HttpIngress(Method.GET, "/empty") +fun empty(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Empty" to arrayListOf("Header from FTL")), + body = Unit + ) +} + +@Verb +@HttpIngress(Method.GET, "/string") +fun string(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("String" to arrayListOf("Header from FTL")), + body = req.body + ) } + +@Verb +@HttpIngress(Method.GET, "/int") +fun int(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Int" to arrayListOf("Header from FTL")), + body = req.body + ) +} + +@Verb +@HttpIngress(Method.GET, "/float") +fun float(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Float" to arrayListOf("Header from FTL")), + body = req.body + ) +} + +@Verb +@HttpIngress(Method.GET, "/bool") +fun bool(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Bool" to arrayListOf("Header from FTL")), + body = req.body + ) +} + +@Verb +@HttpIngress(Method.GET, "/error") +fun error(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 500, + headers = mapOf("Error" to arrayListOf("Header from FTL")), + error = "Error from FTL" + ) +} + diff --git a/kotlin-runtime/ftl-generator/src/main/kotlin/xyz/block/ftl/generator/ModuleGenerator.kt b/kotlin-runtime/ftl-generator/src/main/kotlin/xyz/block/ftl/generator/ModuleGenerator.kt index 9e476994c1..82cb43aba7 100644 --- a/kotlin-runtime/ftl-generator/src/main/kotlin/xyz/block/ftl/generator/ModuleGenerator.kt +++ b/kotlin-runtime/ftl-generator/src/main/kotlin/xyz/block/ftl/generator/ModuleGenerator.kt @@ -3,7 +3,6 @@ package xyz.block.ftl.generator import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import xyz.block.ftl.Context -import xyz.block.ftl.Ignore import xyz.block.ftl.HttpIngress import xyz.block.ftl.v1.schema.* import java.io.File @@ -13,7 +12,7 @@ import kotlin.io.path.createDirectories import kotlin.io.path.setPosixFilePermissions import kotlin.io.path.writeText -class ModuleGenerator() { +class ModuleGenerator { fun run(schema: Schema, outputDirectory: File, module: String, moduleClientSuffix: String) { val fqOutputDir = outputDirectory.absolutePath prepareFtlRoot(fqOutputDir, module) @@ -29,8 +28,8 @@ class ModuleGenerator() { internal fun generateModule(module: Module, moduleClientSuffix: String = DEFAULT_MODULE_CLIENT_SUFFIX): FileSpec { val namespace = "ftl.${module.name}" - val className = module.name.replaceFirstChar(Char::titlecase) + moduleClientSuffix - val file = FileSpec.builder(namespace, className) + val filename = module.name.replaceFirstChar(Char::titlecase) + moduleClientSuffix + val file = FileSpec.builder(namespace, filename) .addFileComment("Code generated by FTL-Generator, do not edit.") module.comments.let { @@ -38,12 +37,7 @@ class ModuleGenerator() { file.addFileComment(it.joinToString("\n")) } - val moduleClass = TypeSpec.classBuilder(className) - .addAnnotation(AnnotationSpec.builder(Ignore::class).build()) - .primaryConstructor( - FunSpec.constructorBuilder().build() - ) - + val emptyData = mutableSetOf() val types = module.decls.mapNotNull { it.data_ } types.forEach { if (namespace == "ftl.builtin" && it.name == "Empty") { @@ -52,15 +46,16 @@ class ModuleGenerator() { .addKdoc(it.comments.joinToString("\n")) .build() ) + } else if (it.fields.isEmpty()) { + emptyData.add("${namespace}.${it.name}") } else { file.addType(buildDataClass(it, namespace)) } } val verbs = module.decls.mapNotNull { it.verb } - verbs.forEach { moduleClass.addFunction(buildVerbFunction(className, namespace, it)) } + verbs.forEach { file.addFunction(buildVerbFunction(namespace, emptyData, it)) } - file.addType(moduleClass.build()) return file.build() } @@ -99,11 +94,12 @@ class ModuleGenerator() { return dataClassBuilder.build() } - private fun buildVerbFunction(className: String, namespace: String, verb: Verb): FunSpec { + private fun buildVerbFunction(namespace: String, emptyData: Set, verb: Verb): FunSpec { val verbFunBuilder = - FunSpec.builder(verb.name).addKdoc(verb.comments.joinToString("\n")).addAnnotation( - AnnotationSpec.builder(xyz.block.ftl.Verb::class).build() - ) + FunSpec.builder(verb.name) + .addKdoc(verb.comments.joinToString("\n")) + .addAnnotation(AnnotationSpec.builder(xyz.block.ftl.Verb::class).build()) + .addAnnotation(AnnotationSpec.builder(xyz.block.ftl.Ignore::class).build()) verb.metadata.forEach { metadata -> metadata.ingress?.let { @@ -117,19 +113,31 @@ class ModuleGenerator() { } verbFunBuilder.addParameter("context", Context::class) + val emptyDataRef = getTypeClass(Type(dataRef = DataRef(name = "Empty")), "ftl.builtin") verb.request?.let { verbFunBuilder.addParameter( - "req", getTypeClass(it, namespace) + "req", + if (emptyData.contains("${namespace}.${it.dataRef?.name}")) { + emptyDataRef + } else { + getTypeClass(it, namespace) + } ) } verb.response?.let { - verbFunBuilder.returns(getTypeClass(it, namespace)) + verbFunBuilder.returns( + if (emptyData.contains("${namespace}.${it.dataRef?.name}")) { + emptyDataRef + } else { + getTypeClass(it, namespace) + } + ) } val message = - "Verb stubs should not be called directly, instead use context.call($className::${verb.name}, ...)" + "Verb stubs should not be called directly, instead use context.call(::${verb.name}, ...)" verbFunBuilder.addCode("""throw NotImplementedError(%S)""", message) return verbFunBuilder.build() diff --git a/kotlin-runtime/ftl-generator/src/test/kotlin/xyz/block/ftl/generator/ModuleGeneratorTest.kt b/kotlin-runtime/ftl-generator/src/test/kotlin/xyz/block/ftl/generator/ModuleGeneratorTest.kt index 1b14f57bad..095605c528 100644 --- a/kotlin-runtime/ftl-generator/src/test/kotlin/xyz/block/ftl/generator/ModuleGeneratorTest.kt +++ b/kotlin-runtime/ftl-generator/src/test/kotlin/xyz/block/ftl/generator/ModuleGeneratorTest.kt @@ -25,10 +25,6 @@ class ModuleGeneratorTest { // package ftl.test -import xyz.block.ftl.Ignore - -@Ignore -public class TestModule() """ assertEquals(expected, file.toString()) } @@ -112,7 +108,6 @@ import kotlin.String import kotlin.collections.ArrayList import kotlin.collections.Map import xyz.block.ftl.Alias -import xyz.block.ftl.Ignore public data class ParamTestData( public val t: T, @@ -141,9 +136,6 @@ public data class TestResponse( @Alias("a") public val withAlias: String, ) - -@Ignore -public class TestModule() """ assertEquals(expected, file.toString()) } @@ -190,26 +182,25 @@ import xyz.block.ftl.Ignore import xyz.block.ftl.Method.GET import xyz.block.ftl.Verb +/** + * TestVerb comments + */ +@Verb @Ignore -public class TestModule() { - /** - * TestVerb comments - */ - @Verb - public fun TestVerb(context: Context, req: Empty): Empty = throw - NotImplementedError("Verb stubs should not be called directly, instead use context.call(TestModule::TestVerb, ...)") +public fun TestVerb(context: Context, req: Empty): Empty = throw + NotImplementedError("Verb stubs should not be called directly, instead use context.call(::TestVerb, ...)") - /** - * TestIngressVerb comments - */ - @Verb - @HttpIngress( - GET, - "/test", - ) - public fun TestIngressVerb(context: Context, req: Empty): Empty = throw - NotImplementedError("Verb stubs should not be called directly, instead use context.call(TestModule::TestIngressVerb, ...)") -} +/** + * TestIngressVerb comments + */ +@Verb +@Ignore +@HttpIngress( + GET, + "/test", +) +public fun TestIngressVerb(context: Context, req: Empty): Empty = throw + NotImplementedError("Verb stubs should not be called directly, instead use context.call(::TestIngressVerb, ...)") """ assertEquals(expected, file.toString()) } @@ -283,7 +274,6 @@ import kotlin.Long import kotlin.String import kotlin.collections.ArrayList import kotlin.collections.Map -import xyz.block.ftl.Ignore /** * HTTP request structure used for HTTP ingress verbs. @@ -307,9 +297,41 @@ public data class HttpResponse( ) public class Empty +""" + assertEquals(expected, file.toString()) + } + + @Test + fun `generates empty data refs`() { + val file = generator.generateModule( + Module( + name = "test", + decls = listOf( + Decl(data_ = Data(name = "EmptyRequest")), + Decl(data_ = Data(name = "EmptyResponse")), + Decl( + verb = Verb( + name = "EmptyVerb", + request = Type(dataRef = DataRef(name = "EmptyRequest")), + response = Type(dataRef = DataRef(name = "EmptyResponse")) + ) + ) + ) + ) + ) + val expected = """// Code generated by FTL-Generator, do not edit. +// +package ftl.test + +import ftl.builtin.Empty +import xyz.block.ftl.Context +import xyz.block.ftl.Ignore +import xyz.block.ftl.Verb +@Verb @Ignore -public class BuiltinModule() +public fun EmptyVerb(context: Context, req: Empty): Empty = throw + NotImplementedError("Verb stubs should not be called directly, instead use context.call(::EmptyVerb, ...)") """ assertEquals(expected, file.toString()) } diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Context.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Context.kt index fc14f9eeb5..8a8bcc547e 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Context.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Context.kt @@ -23,7 +23,7 @@ class Context( if (verb !is CallableReference) { throw InvalidParameterException("could not determine module from verb name") } - val ftlModule = ftlModuleFromJvmModule(jvmModule, verb.owner.toString().removePrefix("class ")) + val ftlModule = ftlModuleFromJvmModule(jvmModule, verb) val requestJson = gson.toJson(request) val responseJson = routingClient.call(this, VerbRef(ftlModule, verb.name), requestJson) return gson.fromJson(responseJson, R::class.java) diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Ignore.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Ignore.kt index f8b114cd50..f5f2fe23e4 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Ignore.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Ignore.kt @@ -1,9 +1,9 @@ package xyz.block.ftl /** - * Ignore a class or method when registering verbs. + * Ignore a method when registering verbs. */ -@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @MustBeDocumented -annotation class Ignore() +annotation class Ignore diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Verb.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Verb.kt index 5d41cb46aa..7acc9fe04c 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Verb.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Verb.kt @@ -3,4 +3,4 @@ package xyz.block.ftl @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @MustBeDocumented -annotation class Verb(val name: String = "") +annotation class Verb diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/Registry.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/Registry.kt index f0afc140ec..6558b44d30 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/Registry.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/Registry.kt @@ -6,16 +6,13 @@ import xyz.block.ftl.Ignore import xyz.block.ftl.Verb import xyz.block.ftl.logging.Logging import java.util.concurrent.ConcurrentHashMap -import kotlin.reflect.KClass import kotlin.reflect.KFunction -import kotlin.reflect.full.declaredFunctions -import kotlin.reflect.full.findAnnotation -import kotlin.reflect.full.functions -import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.jvm.javaMethod import kotlin.reflect.jvm.kotlinFunction -val defaultJvmModuleName = "ftl" +const val defaultJvmModuleName = "ftl" +fun test() {} data class VerbRef(val module: String, val name: String) { override fun toString() = "$module.$name" } @@ -26,7 +23,7 @@ internal fun xyz.block.ftl.v1.schema.VerbRef.toModel() = VerbRef(module, name) * FTL module registry. * * This will contain all the Verbs that are registered in the module and will be used to dispatch requests to the - * appropriate Verb. It is also used to generate the module schema. + * appropriate Verb. */ class Registry(val jvmModuleName: String = defaultJvmModuleName) { private val logger = Logging.logger(Registry::class) @@ -40,60 +37,40 @@ class Registry(val jvmModuleName: String = defaultJvmModuleName) { return ftlModuleName!! } - /** Register all Verbs in a class. */ - fun register(klass: KClass) { - var count = 0 - for (member in klass.members) { - if (member is KFunction<*>) { - maybeRegisterVerb(klass, member) - count++ - } - } - if (count == 0) throw IllegalArgumentException("Class ${klass.qualifiedName} has no @Verb methods") - } - /** Register all Verbs in the JVM package by walking the class graph. */ fun registerAll() { logger.debug("Scanning for Verbs in ${jvmModuleName}...") ClassGraph() .enableAllInfo() // Scan classes, methods, fields, annotations .acceptPackages(jvmModuleName) - .scan() - .getClassesWithMethodAnnotation(Verb::class.java) - .forEach { - val kClass = it.loadClass().kotlin - if (kClass.hasAnnotation()) { - return@forEach + .scan().use { scanResult -> + scanResult.allClasses.flatMap { + it.loadClass().kotlin.java.declaredMethods.asSequence() + }.filter { + it.isAnnotationPresent(Verb::class.java) && !it.isAnnotationPresent(Ignore::class.java) + }.forEach { + val verb = it.kotlinFunction!! + maybeRegisterVerb(verb) } - kClass.declaredFunctions - .filter { func -> func.hasAnnotation() && !func.hasAnnotation() } - .forEach { verb -> maybeRegisterVerb(kClass, verb) } } } val refs get() = verbs.keys.toList() - private fun maybeRegisterVerb(klass: KClass, function: KFunction<*>) { - val verbAnnotation = function.findAnnotation() ?: return - val verbName = if (verbAnnotation.name == "") function.name else verbAnnotation.name + private fun maybeRegisterVerb(function: KFunction<*>) { if (ftlModuleName == null) { - val qualifiedName = - klass.qualifiedName ?: throw IllegalArgumentException("Class must have a qualified name") - val moduleName = ftlModuleFromJvmModule(jvmModuleName, qualifiedName) - ftlModuleName = moduleName + ftlModuleName = ftlModuleFromJvmModule(jvmModuleName, function) } logger.debug(" @Verb ${function.name}()") - val verbRef = VerbRef(module = ftlModuleName!!, name = verbName) - val verbHandle = VerbHandle(klass, function) + val verbRef = VerbRef(module = ftlModuleName!!, name = function.name) + val verbHandle = VerbHandle(function) if (verbs.containsKey(verbRef)) throw IllegalArgumentException("Duplicate Verb $verbRef") verbs[verbRef] = verbHandle } fun list(): Set = verbs.keys - fun has(verbRef: VerbRef): Boolean = verbs.containsKey(verbRef) - /** Invoke a Verb with JSON-encoded payload and return its JSON-encoded response. */ fun invoke(context: Context, verbRef: VerbRef, request: String): String { val verb = verbs[verbRef] ?: throw IllegalArgumentException("Unknown verb: $verbRef") @@ -102,17 +79,17 @@ class Registry(val jvmModuleName: String = defaultJvmModuleName) { } /** - * Return the FTL module name from a JVM module name and a qualified class name. + * Return the FTL module name from a JVM module name and a top-level KFunction. * - * For example, if the JVM module name is `xyz.block.ftl` and the qualified class name is - * `xyz.block.ftl.core.Foo`, then the FTL module name is `core`. + * For example, if the JVM module name is `ftl` and the qualified function name is + * `ftl.core.foo`, then the FTL module name is `core`. */ -fun ftlModuleFromJvmModule(jvmModuleName: String, qualifiedName: String): String { - val packageSuffix = qualifiedName.removePrefix("$jvmModuleName.") - val parts = packageSuffix.split(".") - val moduleName = parts[0] - if (parts.size < 2) { - throw IllegalArgumentException("Class ${qualifiedName} must be in the form $jvmModuleName..") +fun ftlModuleFromJvmModule(jvmModuleName: String, verb: KFunction<*>): String { + val packageName = verb.javaMethod?.declaringClass?.`package`?.name + ?: throw IllegalArgumentException("No package for $verb") + val qualifiedName = "$packageName.${verb.name}" + require(qualifiedName.startsWith("$jvmModuleName.")) { + "Function $qualifiedName must be in the form $jvmModuleName.." } - return moduleName + return qualifiedName.split(".")[1] } diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/VerbHandle.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/VerbHandle.kt index f23569daac..ce3aefbc58 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/VerbHandle.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/registry/VerbHandle.kt @@ -2,26 +2,21 @@ package xyz.block.ftl.registry import xyz.block.ftl.Context import xyz.block.ftl.serializer.makeGson -import kotlin.reflect.KClass import kotlin.reflect.KFunction -import kotlin.reflect.full.createInstance import kotlin.reflect.jvm.javaType internal class VerbHandle( - private val verbClass: KClass<*>, private val verbFunction: KFunction, ) { private val gson = makeGson() fun invokeVerbInternal(context: Context, argument: String): String { - val instance = verbClass.createInstance() - val arguments = verbFunction.parameters.associateWith { parameter -> when (parameter.type.classifier) { - verbClass -> instance Context::class -> context else -> { - gson.fromJson(argument, parameter.type.javaType) + val deserialized: Any? = gson.fromJson(argument, parameter.type.javaType) + return@associateWith deserialized } } } diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt index 4c01c98977..fb87a3dc3c 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt @@ -4,7 +4,7 @@ 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.cfg.getDeclarationDescriptorIncludingConstructors -import org.jetbrains.kotlin.cfg.getElementParentDeclaration +import org.jetbrains.kotlin.com.intellij.psi.PsiComment import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.descriptors.ClassDescriptor import org.jetbrains.kotlin.descriptors.impl.referencedProperty @@ -22,6 +22,7 @@ import org.jetbrains.kotlin.types.KotlinType import org.jetbrains.kotlin.types.checker.SimpleClassicTypeSystemContext.isTypeParameterTypeConstructor import org.jetbrains.kotlin.types.isNullable import org.jetbrains.kotlin.types.typeUtil.isAny +import org.jetbrains.kotlin.util.containingNonLocalDeclaration import org.jetbrains.kotlin.utils.addToStdlib.ifNotEmpty import xyz.block.ftl.* import xyz.block.ftl.Context @@ -43,11 +44,11 @@ data class ModuleData(val comments: List = emptyList(), val decls: Mutab // Helpers private fun DataRef.compare(module: String, name: String): Boolean = this.name == name && this.module == module -private fun DataRef.text(): String = "${this.module}.${this.name}" @RequiresTypeResolution class ExtractSchemaRule(config: Config) : Rule(config) { private val output: String by config(defaultValue = ".") + private val visited: MutableSet = mutableSetOf() private val modules: MutableMap = mutableMapOf() override val issue = Issue( @@ -67,14 +68,26 @@ class ExtractSchemaRule(config: Config) : Rule(config) { return } + // Skip if the verb is annotated with @Ignore + if ( + annotationEntry.containingNonLocalDeclaration()!!.annotationEntries.any { + bindingContext.get( + BindingContext.ANNOTATION, + it + )?.fqName?.asString() == xyz.block.ftl.Ignore::class.qualifiedName + } + ) { + return + } + runCatching { - val extractor = SchemaExtractor(this.bindingContext, modules, annotationEntry) - extractor.extract() - }.onFailure { - when (it) { - is IgnoredModuleException -> return - else -> throw it + val file = annotationEntry.containingKtFile + if (!visited.contains(file)) { + SchemaExtractor(this.bindingContext, modules, file).extract() + visited.add(file) } + }.onFailure { + throw it } } @@ -104,33 +117,61 @@ class ExtractSchemaRule(config: Config) : Rule(config) { } } -class IgnoredModuleException : Exception() class SchemaExtractor( private val bindingContext: BindingContext, private val modules: MutableMap, - annotation: KtAnnotationEntry + private val file: KtFile ) { - private val verb: KtNamedFunction - private val module: KtDeclaration - private val currentModuleName: String - - init { - currentModuleName = annotation.containingKtFile.packageFqName.extractModuleName() - requireNotNull(annotation.getElementParentDeclaration()) { "Could not extract $currentModuleName verb definition" }.let { - require(it is KtNamedFunction) { "${it.getLineAndColumn()} Failure extracting ${it.name}; verbs must be functions" } - verb = it + private val currentModuleName = file.packageFqName.extractModuleName() + fun extract() { + val moduleComments = file.children + .filterIsInstance() + .flatMap { c -> + // get comments without comment markers + c.text.split("\n") + .filter { it.isNotBlank() } + .joinToString(" ") { it.trimStart('/', '*', ' ').trimEnd('/', '*', ' ') } + .trim() + .let { listOf(it) } + } + + val moduleData = ModuleData( + decls = mutableSetOf( + *extractVerbs().toTypedArray(), + *extractDataDeclarations().toTypedArray(), + *extractDatabases().toTypedArray(), + ), + comments = moduleComments + ) + modules[currentModuleName]?.decls?.addAll(moduleData.decls) ?: run { + modules[currentModuleName] = moduleData } - val verbSourcePos = verb.getLineAndColumn() - module = - requireNotNull(verb.getElementParentDeclaration()) { "$verbSourcePos 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() + } + + private fun extractVerbs(): Set { + val verbs = file.children.mapNotNull { c -> + (c as? KtNamedFunction)?.takeIf { verb -> + verb.annotationEntries.any { + bindingContext.get( + BindingContext.ANNOTATION, + it + )?.fqName?.asString() == xyz.block.ftl.Verb::class.qualifiedName + } && verb.annotationEntries.none { + bindingContext.get( + BindingContext.ANNOTATION, + it + )?.fqName?.asString() == xyz.block.ftl.Ignore::class.qualifiedName + } + } } + return verbs.map { + validateVerb(it) + Decl(verb = extractVerb(it)) + }.toSet() + } + private fun validateVerb(verb: KtNamedFunction) { + val verbSourcePos = verb.getLineAndColumn() requireNotNull(verb.fqName?.asString()) { "Verbs must be defined in a package" }.let { fqName -> @@ -158,52 +199,41 @@ class SchemaExtractor( val respClass = verb.createTypeBindingForReturnType(bindingContext)?.type ?: throw IllegalStateException("$verbSourcePos Could not resolve ${verb.name} return type") require(respClass.toClassDescriptor().isData || respClass.isEmptyBuiltin()) { - "${verbSourcePos}: return type of ${verb.name} must be a data class or builtin.Empty but is ${respClass.fqNameOrNull()?.asString()}" + "${verbSourcePos}: return type of ${verb.name} must be a data class or builtin.Empty but is ${ + respClass.fqNameOrNull()?.asString() + }" } } } - fun extract() { - val filename = verb.containingKtFile.name + private fun extractVerb(verb: KtNamedFunction): Verb { val verbSourcePos = verb.getLineAndColumn() val requestRef = verb.valueParameters.last()?.let { - val position = it.getLineAndColumn().toPosition(filename) + val position = it.getLineAndColumn().toPosition() return@let it.typeReference?.resolveType()?.toSchemaType(position) } requireNotNull(requestRef) { "$verbSourcePos Could not resolve request type for ${verb.name}" } val returnRef = verb.createTypeBindingForReturnType(bindingContext)?.let { - val position = it.psiElement.getLineAndColumn().toPosition(filename) + val position = it.psiElement.getLineAndColumn().toPosition() return@let it.type.toSchemaType(position) } requireNotNull(returnRef) { "$verbSourcePos Could not resolve response type for ${verb.name}" } val metadata = mutableListOf() - extractIngress(requestRef, returnRef)?.apply { metadata.add(Metadata(ingress = this)) } - extractCalls()?.apply { metadata.add(Metadata(calls = this)) } + extractIngress(verb, requestRef, returnRef)?.apply { metadata.add(Metadata(ingress = this)) } + extractCalls(verb)?.apply { metadata.add(Metadata(calls = this)) } - val verb = Verb( + return Verb( name = requireNotNull(verb.name) { "$verbSourcePos Verbs must be named" }, request = requestRef, response = returnRef, metadata = metadata, comments = verb.comments(), ) - - val moduleData = ModuleData( - decls = mutableSetOf( - Decl(verb = verb), - *extractDataDeclarations().toTypedArray(), - *extractDatabases().toTypedArray(), - ), - comments = module.comments() - ) - modules[currentModuleName]?.decls?.addAll(moduleData.decls) ?: run { - modules[currentModuleName] = moduleData - } } private fun extractDatabases(): Set { - return verb.containingKtFile.declarations + return file.declarations .filter { (it as? KtProperty) ?.getDeclarationDescriptorIncludingConstructors(bindingContext)?.referencedProperty?.returnType @@ -221,7 +251,7 @@ class SchemaExtractor( Decl( database = xyz.block.ftl.v1.schema.Database( - pos = sourcePos.toPosition(verb.containingKtFile.name), + pos = sourcePos.toPosition(), name = dbName ) ) @@ -230,7 +260,7 @@ class SchemaExtractor( } private fun extractDataDeclarations(): Set { - return verb.containingKtFile.children + return file.children .filter { (it is KtClass && it.isData()) || it is KtTypeAlias } .mapNotNull { val data = (it as? KtClass)?.toSchemaData() ?: (it as? KtTypeAlias)?.toSchemaData() @@ -239,7 +269,7 @@ class SchemaExtractor( .toSet() } - private fun extractIngress(requestType: Type, responseType: Type): MetadataIngress? { + private fun extractIngress(verb: KtNamedFunction, requestType: Type, responseType: Type): MetadataIngress? { return verb.annotationEntries.firstOrNull { bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == HttpIngress::class.qualifiedName }?.let { annotationEntry -> @@ -277,12 +307,11 @@ class SchemaExtractor( "Could not extract path from ${verb.name} @HttpIngress annotation" } - MetadataIngress( type = "http", path = pathArg, method = methodArg, - pos = sourcePos.toPosition(verb.containingKtFile.name), + pos = sourcePos.toPosition(), ) } } @@ -298,7 +327,7 @@ class SchemaExtractor( } } - private fun extractCalls(): MetadataCalls? { + private fun extractCalls(verb: KtNamedFunction): MetadataCalls? { val verbs = mutableSetOf() extractCalls(verb, verbs) return verbs.ifNotEmpty { MetadataCalls(calls = verbs.toList()) } @@ -318,7 +347,7 @@ class SchemaExtractor( val funcSourcePos = func.getLineAndColumn() val body = requireNotNull(func.bodyExpression) { "$funcSourcePos Function body cannot be empty; was in ${func.name}" } - val imports = func.containingKtFile.importList?.imports?.mapNotNull { it.importedFqName } ?: emptyList() + val imports = func.containingKtFile.importList?.imports ?: emptyList() // Look for all params of type Context and extract a matcher for each based on its variable name. // e.g. fun foo(ctx: Context) { ctx.call(...) } => "ctx.call(...)" @@ -327,21 +356,40 @@ class SchemaExtractor( }.map { ctxParam -> getCallMatcher(ctxParam.text.split(":")[0].trim()) } val refs = callMatchers.flatMap { matcher -> - matcher.findAll(body.text).map { - val req = requireNotNull(it.groups["req"]?.value?.trim()) { - "Error processing function defined at $funcSourcePos: Could not extract request type for outgoing verb call" - } - val verbCall = requireNotNull(it.groups["fn"]?.value?.trim()) { - "Error processing function defined at $funcSourcePos: Could not extract module name for outgoing verb call" + matcher.findAll(body.text).map { match -> + val verbCall = requireNotNull(match.groups["fn"]?.value?.substringAfter("::")?.trim()) { + "Error processing function defined at $funcSourcePos: Could not extract outgoing verb call" } - // 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) } - ?.extractModuleName().takeIf { refModule -> refModule != currentModuleName } + imports.firstOrNull { import -> + // if aliased import, match the alias + (import.text.split(" ").takeIf { it.size > 2 }?.last() + // otherwise match the last part of the import + ?: import.importedFqName?.asString()?.split(".")?.last()) == verbCall + }?.let { import -> + val moduleRefName = import.importedFqName?.asString()?.extractModuleName() + .takeIf { refModule -> refModule != currentModuleName } + VerbRef( + name = import.importedFqName!!.asString().split(".").last(), + module = moduleRefName ?: "", + ) + } ?: let { + // if no matching import, validate that the referenced verb is in the same module + element.containingKtFile.children.singleOrNull { + (it is KtNamedFunction) && it.name == verbCall && it.annotationEntries.any { + bindingContext.get( + BindingContext.ANNOTATION, + it + )?.fqName?.asString() == xyz.block.ftl.Verb::class.qualifiedName + } + } ?: throw IllegalArgumentException( + "Error processing function defined at $funcSourcePos: Could not resolve outgoing verb call" + ) - VerbRef( - name = verbCall.split("::")[1].trim(), - module = moduleRefName ?: "", - ) + VerbRef( + name = verbCall, + module = currentModuleName, + ) + } } } calls.addAll(refs) @@ -359,7 +407,7 @@ class SchemaExtractor( return Data( name = this.name!!, comments = this.comments(), - pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(this.containingKtFile.name), + pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(), ) } @@ -371,7 +419,7 @@ class SchemaExtractor( name = param.name!!, type = param.typeReference?.let { return@let it.resolveType().toSchemaType( - getLineAndColumnInPsiFile(it.containingFile, it.textRange).toPosition(it.containingKtFile.name) + getLineAndColumnInPsiFile(it.containingFile, it.textRange).toPosition() ) }, alias = param.annotationEntries.firstOrNull { @@ -383,10 +431,10 @@ class SchemaExtractor( typeParameters = this.children.flatMap { (it as? KtTypeParameterList)?.parameters ?: emptyList() }.map { TypeParameter( name = it.name!!, - pos = getLineAndColumnInPsiFile(it.containingFile, it.textRange).toPosition(it.containingKtFile.name), + pos = getLineAndColumnInPsiFile(it.containingFile, it.textRange).toPosition(), ) }.toList(), - pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(this.containingKtFile.name), + pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(), ) } @@ -464,17 +512,17 @@ class SchemaExtractor( bindingContext.get(BindingContext.TYPE, this) ?: throw IllegalStateException("${this.getLineAndColumn()} Could not resolve type ${this.text}") + private fun LineAndColumn.toPosition() = + Position( + filename = file.name, + line = this.line.toLong(), + column = this.column.toLong(), + ) + companion object { private fun PsiElement.getLineAndColumn(): LineAndColumn = getLineAndColumnInPsiFile(this.containingFile, this.textRange) - private fun LineAndColumn.toPosition(filename: String) = - Position( - filename = filename, - line = this.line.toLong(), - column = this.column.toLong(), - ) - private fun getCallMatcher(ctxVarName: String): Regex { return """${ctxVarName}\.call\((?[^,]+),\s*(?[^,]+?)\s*[()]""".toRegex(RegexOption.IGNORE_CASE) } diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/ContextTest.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/ContextTest.kt index 54e38d549f..c1ae8c4922 100644 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/ContextTest.kt +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/ContextTest.kt @@ -1,34 +1,12 @@ package xyz.block.ftl import ftl.builtin.Empty +import ftl.test.* import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import xyz.block.ftl.client.LoopbackVerbServiceClient import xyz.block.ftl.registry.Registry -import java.time.OffsetDateTime - -data class EchoRequest(val user: String) -data class EchoResponse(val text: String) - -class Echo { - @Verb - fun echo(context: Context, req: EchoRequest): EchoResponse { - val time = context.call(Time::time, Empty()) - return EchoResponse("Hello ${req.user}, the time is ${time.time}!") - } -} - -data class TimeResponse(val time: OffsetDateTime) - -val staticTime = OffsetDateTime.now() - -class Time { - @Verb - fun time(context: Context, req: Empty): TimeResponse { - return TimeResponse(staticTime) - } -} data class TestCase(val expected: Any, val invoke: (ctx: Context) -> Any) @@ -38,11 +16,11 @@ class ContextTest { fun endToEnd(): List { return listOf( TestCase( - invoke = { ctx -> ctx.call(Echo::echo, EchoRequest("Alice")) }, + invoke = { ctx -> ctx.call(::echo, EchoRequest("Alice")) }, expected = EchoResponse("Hello Alice, the time is $staticTime!"), ), TestCase( - invoke = { ctx -> ctx.call(Time::time, Empty()) }, + invoke = { ctx -> ctx.call(::time, Empty()) }, expected = TimeResponse(staticTime), ), ) @@ -52,11 +30,10 @@ class ContextTest { @ParameterizedTest @MethodSource fun endToEnd(testCase: TestCase) { - val registry = Registry("xyz.block") - registry.register(Echo::class) - registry.register(Time::class) + val registry = Registry("ftl.test") + registry.registerAll() val routingClient = LoopbackVerbServiceClient(registry) - val context = Context("xyz.block", routingClient) + val context = Context("ftl.test", routingClient) val result = testCase.invoke(context) assertEquals(result, testCase.expected) } diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/RegistryTest.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/RegistryTest.kt index fa353e7d55..8c7999ecc6 100644 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/RegistryTest.kt +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/RegistryTest.kt @@ -1,79 +1,42 @@ package xyz.block.ftl.registry +import ftl.test.VerbRequest +import ftl.test.VerbResponse import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test -import xyz.block.ftl.* +import xyz.block.ftl.Context import xyz.block.ftl.client.LoopbackVerbServiceClient import xyz.block.ftl.serializer.makeGson import kotlin.test.assertContentEquals -data class VerbRequest(val text: String = "") -data class VerbResponse(val text: String = "") - -data class RenamedVerbRequest(val text: String = "") -data class RenamedVerbResponse(val text: String = "") - -class RenamedVerb { - @Verb("something") - fun renamed(context: Context, req: RenamedVerbRequest): RenamedVerbResponse { - return RenamedVerbResponse("renamed") - } -} - -class ExampleVerb { - @Verb - @HttpIngress(Method.GET, "/test") - fun verb(context: Context, req: VerbRequest): VerbResponse { - return VerbResponse("test") - } -} - -@Ignore -class IgnoredVerb { - @Verb - fun anotherVerb(context: Context, req: VerbRequest): VerbResponse { - return VerbResponse("ignored") - } -} - class RegistryTest { private val gson = makeGson() - private val verbRef = VerbRef(module = "registry", name = "verb") - private val renamedVerbRef = VerbRef(module = "registry", name = "something") + private val verbRef = VerbRef(module = "test", name = "verb") @Test fun moduleName() { - val registry = Registry("xyz.block.ftl") - registry.register(ExampleVerb::class) - assertEquals("registry", registry.moduleName) + val registry = Registry("ftl.test") + registry.registerAll() + assertEquals("test", registry.moduleName) } @Test - fun register() { - val registry = Registry("xyz.block.ftl") - registry.register(ExampleVerb::class) - registry.register(RenamedVerb::class) - assertContentEquals(listOf(renamedVerbRef, verbRef), registry.refs.sortedBy { it.toString() }) + fun registerAll() { + val registry = Registry("ftl.test") + registry.registerAll() + assertContentEquals(listOf( + VerbRef(module = "test", name = "echo"), + VerbRef(module = "test", name = "time"), + VerbRef(module = "test", name = "verb"), + ), registry.refs.sortedBy { it.toString() }) } @Test fun invoke() { - val registry = Registry("xyz.block.ftl") - registry.register(ExampleVerb::class) - val context = Context("xyz.block.ftl", LoopbackVerbServiceClient(registry)) + val registry = Registry("ftl.test") + registry.registerAll() + val context = Context("ftl.test", LoopbackVerbServiceClient(registry)) val result = registry.invoke(context, verbRef, gson.toJson(VerbRequest("test"))) assertEquals(result, gson.toJson(VerbResponse("test"))) } - - // For some reason "RenamedVerb" does not show up in the scan result. - // I think it's because there's some additional magic that has to be - // done to get the class to load when they're in tests. - @Disabled("Verbs defined in the tests don't seem to be stable.") - @Test - fun registerAll() { - val registry = Registry("xyz.block") - registry.registerAll() - // assertContentEquals(listOf(verbRef), registry.refs.sortedBy { it.toString() }) - } } diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/VerbHandleTest.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/VerbHandleTest.kt deleted file mode 100644 index 306e137b27..0000000000 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/registry/VerbHandleTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package xyz.block.ftl.registry - -import org.junit.jupiter.api.Test -import xyz.block.ftl.serializer.makeGson - -class VerbHandleTest { - val gson = makeGson() - - @Test - fun testInvoke() { - // val requestJson = gson.toJson(VerbRequest("a")) - // val handle = VerbHandle( - // verbClass = ExampleVerb::class, - // verbFunction = ExampleVerb::verb, - // ) - // val response = handle.invokeVerbInternal( - // context = Context(), - // argument = requestJson, - // ) - // assertEquals(gson.toJson(VerbResponse("test")), response) - } -} diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt index 6878f533f6..412ca2dc1b 100644 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRuleTest.kt @@ -15,6 +15,7 @@ import xyz.block.ftl.v1.schema.Array import xyz.block.ftl.v1.schema.Map import java.io.File import kotlin.test.AfterTest +import kotlin.test.assertContains @KotlinCoreEnvironmentTest internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { @@ -28,72 +29,68 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { @Test fun `extracts schema`() { val code = """ - package ftl.echo + // Echo module. + package ftl.echo - import ftl.builtin.Empty - import ftl.builtin.HttpRequest - import ftl.builtin.HttpResponse - import ftl.time.TimeModuleClient - import ftl.time.TimeRequest - import ftl.time.TimeResponse - import xyz.block.ftl.Alias - import xyz.block.ftl.Context - import xyz.block.ftl.HttpIngress - import xyz.block.ftl.Method - import xyz.block.ftl.Verb + import ftl.builtin.Empty + import ftl.builtin.HttpRequest + import ftl.builtin.HttpResponse + import ftl.time.time as verb + import ftl.time.other + import ftl.time.TimeRequest + import ftl.time.TimeResponse + import xyz.block.ftl.Alias + import xyz.block.ftl.Context + import xyz.block.ftl.HttpIngress + import xyz.block.ftl.Method + import xyz.block.ftl.Module + import xyz.block.ftl.Verb - class InvalidInput(val field: String) : Exception() + class InvalidInput(val field: String) : Exception() - data class MapValue(val value: String) - data class EchoMessage(val message: String, val metadata: Map? = null) + data class MapValue(val value: String) + data class EchoMessage(val message: String, val metadata: Map? = null) - /** - * Request to echo a message. - */ - data class EchoRequest(val t: T, val name: String, @Alias("stf") val stuff: Any) - data class EchoResponse(val messages: List) + /** + * Request to echo a message. + */ + data class EchoRequest(val t: T, val name: String, @Alias("stf") val stuff: Any) + data class EchoResponse(val messages: List) - /** - * Echo module. - */ - class Echo { - /** - * Echoes the given message. - */ - @Throws(InvalidInput::class) - @Verb - @HttpIngress(Method.GET, "/echo") - fun echo(context: Context, req: HttpRequest>): HttpResponse { - callTime(context) + /** + * Echoes the given message. + */ + @Throws(InvalidInput::class) + @Verb + @HttpIngress(Method.GET, "/echo") + fun echo(context: Context, req: HttpRequest>): HttpResponse { + callTime(context) - return HttpResponse( - status = 200, - headers = mapOf("Get" to arrayListOf("Header from FTL")), - body = EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) - ) - } + return HttpResponse( + status = 200, + headers = mapOf("Get" to arrayListOf("Header from FTL")), + body = EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) + ) + } - @Verb - fun empty(context: Context, req: Empty): Empty { - return builtin.Empty() - } + @Verb + fun empty(context: Context, req: Empty): Empty { + return builtin.Empty() + } - fun callTime(context: Context): TimeResponse { - return context.call(TimeModuleClient::time, TimeRequest) - } - } - """ + fun callTime(context: Context): TimeResponse { + context.call(::empty, builtin.Empty()) + context.call(::other, builtin.Empty()) + return context.call(::verb, builtin.Empty()) + } + """ ExtractSchemaRule(Config.empty).compileAndLintWithContext(env, code) val file = File(OUTPUT_FILENAME) val module = Module.ADAPTER.decode(file.inputStream()) val expected = Module( name = "echo", - comments = listOf( - """/** - * Echo module. - */""" - ), + comments = listOf("Echo module."), decls = listOf( Decl( data_ = Data( @@ -151,8 +148,8 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { ), comments = listOf( """/** - * Request to echo a message. - */""" + * Request to echo a message. + */""" ), typeParameters = listOf( TypeParameter(name = "T") @@ -184,8 +181,8 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { name = "echo", comments = listOf( """/** - * Echoes the given message. - */""" + * Echoes the given message. + */""" ), request = Type( dataRef = DataRef( @@ -236,6 +233,14 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { Metadata( calls = MetadataCalls( calls = listOf( + VerbRef( + name = "empty", + module = "echo" + ), + VerbRef( + name = "other", + module = "time" + ), VerbRef( name = "time", module = "time" @@ -275,86 +280,82 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { @Test fun `fails if invalid schema type is included`() { - val code = """ - package ftl.echo + val code = """/** + * Echo module. + */ +package ftl.echo - import ftl.time.TimeModuleClient - import ftl.time.TimeRequest - import ftl.time.TimeResponse - import xyz.block.ftl.Context - import xyz.block.ftl.HttpIngress - import xyz.block.ftl.Method - import xyz.block.ftl.Verb +import ftl.builtin.Empty +import ftl.time.time +import ftl.time.TimeRequest +import ftl.time.TimeResponse +import xyz.block.ftl.Context +import xyz.block.ftl.Method +import xyz.block.ftl.Verb - class InvalidInput(val field: String) : Exception() +class InvalidInput(val field: String) : Exception() - data class EchoMessage(val message: String, val metadata: Map? = null) +data class EchoMessage(val message: String, val metadata: Map? = null) - /** - * Request to echo a message. - */ - data class EchoRequest(val name: Char) - data class EchoResponse(val messages: List) +/** + * Request to echo a message. + */ +data class EchoRequest(val name: Char) +data class EchoResponse(val messages: List) - /** - * Echo module. - */ - class Echo { - /** - * Echoes the given message. - */ - @Throws(InvalidInput::class) - @Verb - @HttpIngress(Method.GET, "/echo") - fun echo(context: Context, req: EchoRequest): EchoResponse { - callTime(context) - return EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) - } +/** + * Echoes the given message. + */ +@Throws(InvalidInput::class) +@Verb +fun echo(context: Context, req: EchoRequest): EchoResponse { + callTime(context) + return EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) +} - fun callTime(context: Context): TimeResponse { - return context.call(TimeModuleClient::time, TimeRequest) - } - } - """ - assertThrows(message = "kotlin.Char type is not supported in FTL schema") { +fun callTime(context: Context): TimeResponse { + return context.call(::time, Empty()) +} +""" + val message = assertThrows { ExtractSchemaRule(Config.empty).compileAndLintWithContext(env, code) - } + }.message!! + assertContains(message, "Expected type to be a data class or builtin.Empty, but was kotlin.Char") } @Test fun `fails if http ingress without http request-response types`() { val code = """ - package ftl.echo + /** + * Echo module. + */ +package ftl.echo - import xyz.block.ftl.Context - import xyz.block.ftl.HttpIngress - import xyz.block.ftl.Method - import xyz.block.ftl.Verb +import xyz.block.ftl.Context +import xyz.block.ftl.HttpIngress +import xyz.block.ftl.Method +import xyz.block.ftl.Verb - /** - * Request to echo a message. - */ - data class EchoRequest(val name: String) - data class EchoResponse(val message: String) +/** + * Request to echo a message. + */ +data class EchoRequest(val name: String) +data class EchoResponse(val message: String) - /** - * Echo module. - */ - class Echo { - /** - * Echoes the given message. - */ - @Throws(InvalidInput::class) - @Verb - @HttpIngress(Method.GET, "/echo") - fun echo(context: Context, req: EchoRequest): EchoResponse { - return EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) - } - } +/** + * Echoes the given message. + */ +@Throws(InvalidInput::class) +@Verb +@HttpIngress(Method.GET, "/echo") +fun echo(context: Context, req: EchoRequest): EchoResponse { + return EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) +} """ - assertThrows(message = "@HttpIngress-annotated echo request must be ftl.builtin.HttpRequest") { + val message = assertThrows { ExtractSchemaRule(Config.empty).compileAndLintWithContext(env, code) - } + }.message!! + assertContains(message, "@HttpIngress-annotated echo request must be ftl.builtin.HttpRequest") } @AfterTest diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/BuiltinModuleClient.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/BuiltinModuleClient.kt index dd41b8dcea..05b4afb95a 100644 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/BuiltinModuleClient.kt +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/BuiltinModuleClient.kt @@ -3,12 +3,6 @@ // This is copied from the FTL runtime and is not meant to be edited. package ftl.builtin -import kotlin.Long -import kotlin.String -import kotlin.collections.ArrayList -import kotlin.collections.Map -import xyz.block.ftl.Ignore - /** * HTTP request structure used for HTTP ingress verbs. */ @@ -32,6 +26,3 @@ public data class HttpResponse( ) public class Empty - -@Ignore -public class BuiltinModuleClient() diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt index 8ba396fa35..d4e64cb5ab 100644 --- a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt @@ -2,29 +2,28 @@ // package ftl.time -import xyz.block.ftl.* -import java.time.OffsetDateTime -import kotlin.Unit +import ftl.builtin.Empty +import xyz.block.ftl.Context +import xyz.block.ftl.HttpIngress import xyz.block.ftl.Method.GET - -public data class TimeRequest( - public val _empty: Unit = Unit, -) +import xyz.block.ftl.Verb +import java.time.OffsetDateTime public data class TimeResponse( public val time: OffsetDateTime, ) -@Ignore -public class TimeModuleClient() { - /** - * Time returns the current time. - */ - @Verb - @HttpIngress( - GET, - "/time", - ) - public fun time(context: Context, req: TimeRequest): TimeResponse = throw - NotImplementedError("Verb stubs should not be called directly, instead use context.call(TimeModuleClient::time, ...)") -} +/** + * Time returns the current time. + */ +@Verb +@HttpIngress( + GET, + "/time", +) +public fun time(context: Context, req: Empty): TimeResponse = + throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(TimeModuleClient::time, ...)") + +@Verb +public fun other(context: Context, req: Empty): TimeResponse = + throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(TimeModuleClient::time, ...)") diff --git a/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/testdata/TestModule.kt b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/testdata/TestModule.kt new file mode 100644 index 0000000000..6b7bf195d5 --- /dev/null +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/testdata/TestModule.kt @@ -0,0 +1,40 @@ +package ftl.test + +import ftl.builtin.Empty +import xyz.block.ftl.* +import java.time.OffsetDateTime + + +data class EchoRequest(val user: String) +data class EchoResponse(val text: String) + +@Verb +fun echo(context: Context, req: EchoRequest): EchoResponse { + val time = context.call(::time, Empty()) + return EchoResponse("Hello ${req.user}, the time is ${time.time}!") +} + +data class TimeResponse(val time: OffsetDateTime) + +val staticTime = OffsetDateTime.now() + +@Verb +fun time(context: Context, req: Empty): TimeResponse { + return TimeResponse(staticTime) +} + +data class VerbRequest(val text: String = "") +data class VerbResponse(val text: String = "") + +@Verb +@HttpIngress(Method.GET, "/test") +fun verb(context: Context, req: VerbRequest): VerbResponse { + return VerbResponse("test") +} + + +@Verb +@Ignore +fun anotherVerb(context: Context, req: VerbRequest): VerbResponse { + return VerbResponse("ignored") +} diff --git a/kotlin-runtime/scaffolding/ftl-module-{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt b/kotlin-runtime/scaffolding/ftl-module-{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt index 27b0cd30b5..e2a9edd71f 100644 --- a/kotlin-runtime/scaffolding/ftl-module-{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt +++ b/kotlin-runtime/scaffolding/ftl-module-{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt @@ -7,9 +7,7 @@ import xyz.block.ftl.Verb data class EchoRequest(val name: String? = "anonymous") data class EchoResponse(val message: String) -class {{ .Name | camel }} { - @Verb - fun echo(context: Context, req: EchoRequest): EchoResponse { - return EchoResponse(message = "Hello, ${req.name}!") - } +@Verb +fun echo(context: Context, req: EchoRequest): EchoResponse { + return EchoResponse(message = "Hello, ${req.name}!") }