diff --git a/backend/schema/metadataingress.go b/backend/schema/metadataingress.go index 8d570316af..17d3d9aa61 100644 --- a/backend/schema/metadataingress.go +++ b/backend/schema/metadataingress.go @@ -12,7 +12,7 @@ import ( type MetadataIngress struct { Pos Position `parser:"" protobuf:"1,optional"` - Type string `parser:"'ingress' @('http' | 'ftl')?" protobuf:"2"` + Type string `parser:"'ingress' @('http')?" protobuf:"2"` Method string `parser:"@('GET' | 'POST' | 'PUT' | 'DELETE')" protobuf:"3"` Path []IngressPathComponent `parser:"('/' @@)+" protobuf:"4"` } diff --git a/backend/schema/normalise.go b/backend/schema/normalise.go index 4fc15e8559..79c0c4c900 100644 --- a/backend/schema/normalise.go +++ b/backend/schema/normalise.go @@ -44,6 +44,7 @@ func Normalise[T Node](n T) T { c.Pos = zero case *DataRef: + c.TypeParameters = normaliseSlice(c.TypeParameters) c.Pos = zero case *Field: diff --git a/backend/schema/schema_test.go b/backend/schema/schema_test.go index f989a0c432..3bbfdd9d50 100644 --- a/backend/schema/schema_test.go +++ b/backend/schema/schema_test.go @@ -41,8 +41,8 @@ module todo { calls todo.destroy - verb destroy(todo.DestroyRequest) todo.DestroyResponse - ingress ftl GET /todo/destroy/{id} + verb destroy(builtin.HttpRequest) builtin.HttpResponse + ingress http GET /todo/destroy/{id} } ` assert.Equal(t, normaliseString(expected), normaliseString(testSchema.String())) @@ -119,7 +119,6 @@ func TestParserRoundTrip(t *testing.T) { assert.NoError(t, err, "%s", testSchema.String()) actual, err = Validate(actual) assert.NoError(t, err) - actual = Normalise(actual) assert.Equal(t, Normalise(testSchema), Normalise(actual), "%s", testSchema.String()) } @@ -230,8 +229,8 @@ func TestParsing(t *testing.T) { message String } - verb echo(echo.EchoRequest) echo.EchoResponse - ingress ftl GET /echo + verb echo(builtin.HttpRequest) builtin.HttpResponse + ingress http GET /echo calls time.time } @@ -244,8 +243,8 @@ func TestParsing(t *testing.T) { time Time } - verb time(time.TimeRequest) time.TimeResponse - ingress ftl GET /time + verb time(builtin.HttpRequest) builtin.HttpResponse + ingress http GET /time } `, expected: &Schema{ @@ -256,10 +255,10 @@ func TestParsing(t *testing.T) { &Data{Name: "EchoResponse", Fields: []*Field{{Name: "message", Type: &String{}}}}, &Verb{ Name: "echo", - Request: &DataRef{Module: "echo", Name: "EchoRequest"}, - Response: &DataRef{Module: "echo", Name: "EchoResponse"}, + Request: &DataRef{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&DataRef{Module: "echo", Name: "EchoRequest"}}}, + Response: &DataRef{Module: "builtin", Name: "HttpResponse", TypeParameters: []Type{&DataRef{Module: "echo", Name: "EchoResponse"}}}, Metadata: []Metadata{ - &MetadataIngress{Type: "ftl", Method: "GET", Path: []IngressPathComponent{&IngressPathLiteral{Text: "echo"}}}, + &MetadataIngress{Type: "http", Method: "GET", Path: []IngressPathComponent{&IngressPathLiteral{Text: "echo"}}}, &MetadataCalls{Calls: []*VerbRef{{Module: "time", Name: "time"}}}, }, }, @@ -271,10 +270,10 @@ func TestParsing(t *testing.T) { &Data{Name: "TimeResponse", Fields: []*Field{{Name: "time", Type: &Time{}}}}, &Verb{ Name: "time", - Request: &DataRef{Module: "time", Name: "TimeRequest"}, - Response: &DataRef{Module: "time", Name: "TimeResponse"}, + Request: &DataRef{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&DataRef{Module: "time", Name: "TimeRequest"}}}, + Response: &DataRef{Module: "builtin", Name: "HttpResponse", TypeParameters: []Type{&DataRef{Module: "time", Name: "TimeResponse"}}}, Metadata: []Metadata{ - &MetadataIngress{Type: "ftl", Method: "GET", Path: []IngressPathComponent{&IngressPathLiteral{Text: "time"}}}, + &MetadataIngress{Type: "http", Method: "GET", Path: []IngressPathComponent{&IngressPathLiteral{Text: "time"}}}, }, }, }, @@ -385,16 +384,14 @@ module todo { } verb create(todo.CreateRequest) todo.CreateResponse calls todo.destroy - verb destroy(todo.DestroyRequest) todo.DestroyResponse - ingress ftl GET /todo/destroy/{id} + verb destroy(builtin.HttpRequest) builtin.HttpResponse + ingress http GET /todo/destroy/{id} } ` actual, err := ParseModuleString("", input) assert.NoError(t, err) actual = Normalise(actual) - fmt.Printf("Modules %v\n", Normalise(testSchema.Modules[1])) - fmt.Printf("Modules %v\n", Normalise(actual)) - assert.Equal(t, Normalise(testSchema.Modules[1]), actual) + assert.Equal(t, Normalise(testSchema.Modules[1]), actual, assert.Exclude[Position]) } var testSchema = MustValidate(&Schema{ @@ -433,11 +430,11 @@ var testSchema = MustValidate(&Schema{ Response: &DataRef{Module: "todo", Name: "CreateResponse"}, Metadata: []Metadata{&MetadataCalls{Calls: []*VerbRef{{Module: "todo", Name: "destroy"}}}}}, &Verb{Name: "destroy", - Request: &DataRef{Module: "todo", Name: "DestroyRequest"}, - Response: &DataRef{Module: "todo", Name: "DestroyResponse"}, + Request: &DataRef{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&DataRef{Module: "todo", Name: "DestroyRequest"}}}, + Response: &DataRef{Module: "builtin", Name: "HttpResponse", TypeParameters: []Type{&DataRef{Module: "todo", Name: "DestroyResponse"}}}, Metadata: []Metadata{ &MetadataIngress{ - Type: "ftl", + Type: "http", Method: "GET", Path: []IngressPathComponent{ &IngressPathLiteral{Text: "todo"}, 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 2df2cb721e..82ff83deb3 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 @@ -18,6 +18,20 @@ data class Todo( val completed: Boolean = false, ) +typealias GetStatusRequest = Unit + +data class GetStatusResponse( + val status: String, +) + +data class GetTodoRequest( + val id: Int, +) + +data class GetTodoResponse( + val todo: Todo?, +) + data class CreateTodoRequest( val title: String, ) @@ -44,32 +58,32 @@ class Api { 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 = "OK".toByteArray()) + @Ingress(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/{todoId}") - fun getTodo(context: Context, req: HttpRequest): HttpResponse { - val todoId = req.pathParameters["todoId"]?.toIntOrNull() + @Ingress(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( + HttpResponse( status = 200, headers = mapOf(), - body = gson.toJson(todo).toByteArray() + body = GetTodoResponse(todo) ) } else { - HttpResponse(status = 404, headers = mapOf(), body = "Todo not found".toByteArray()) + HttpResponse(status = 404, headers = mapOf(), body = GetTodoResponse(null)) } } @Verb - @HttpIngress(Method.POST, "/api/todos") - fun addTodo(context: Context, req: HttpRequest): HttpResponse { - val todoReq = gson.fromJson(req.body.toString(Charsets.UTF_8), CreateTodoRequest::class.java) + @Ingress(Method.POST, "/api/todos") + fun addTodo(context: Context, req: HttpRequest): HttpResponse { + val todoReq = req.body val id = idCounter.incrementAndGet() todos.put( id, Todo( @@ -78,10 +92,10 @@ class Api { ) ) - return HttpResponse( + return HttpResponse( status = 201, headers = headers, - body = gson.toJson(CreateTodoResponse(id)).toByteArray(), + body = CreateTodoResponse(id), ) } } diff --git a/examples/time/time.go b/examples/time/time.go index 16c995268b..edc36254d7 100644 --- a/examples/time/time.go +++ b/examples/time/time.go @@ -4,6 +4,8 @@ package time import ( "context" "time" + + "ftl/builtin" ) type TimeRequest struct{} @@ -14,7 +16,11 @@ type TimeResponse struct { // Time returns the current time. // //ftl:verb -//ftl:ingress GET /timef -func Time(ctx context.Context, req TimeRequest) (TimeResponse, error) { - return TimeResponse{Time: time.Now()}, nil +//ftl:ingress GET /time +func Time(ctx context.Context, req builtin.HttpRequest[TimeRequest]) (builtin.HttpResponse[TimeResponse], error) { + return builtin.HttpResponse[TimeResponse]{ + Status: 200, + Headers: map[string][]string{"Get": {"Header from FTL"}}, + Body: TimeResponse{Time: time.Now()}, + }, nil } diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go index 71f103ca10..3b5bbf8043 100644 --- a/go-runtime/compile/schema.go +++ b/go-runtime/compile/schema.go @@ -225,9 +225,13 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, e isVerb = true case *directiveIngress: + typ := dir.Type + if typ == "" { + typ = "http" + } metadata = append(metadata, &schema.MetadataIngress{ Pos: dir.Pos, - Type: dir.Type, + Type: typ, Method: dir.Method, Path: dir.Path, }) diff --git a/integration/testdata/kotlin/httpingress/Echo.kt b/integration/testdata/kotlin/httpingress/Echo.kt index 80865b74c4..192064fed0 100644 --- a/integration/testdata/kotlin/httpingress/Echo.kt +++ b/integration/testdata/kotlin/httpingress/Echo.kt @@ -6,7 +6,8 @@ import kotlin.String import kotlin.Unit import xyz.block.ftl.Alias import xyz.block.ftl.Context -import xyz.block.ftl.HttpIngress +import xyz.block.ftl.Ingress +import xyz.block.ftl.Ingress.Type.HTTP import xyz.block.ftl.Method import xyz.block.ftl.Verb @@ -47,9 +48,10 @@ data class DeleteResponse( class Echo { @Verb - @HttpIngress( + @Ingress( Method.GET, "/echo/users/{userID}/posts/{postID}", + HTTP ) fun `get`(context: Context, req: HttpRequest): HttpResponse { return HttpResponse( @@ -60,9 +62,10 @@ class Echo { } @Verb - @HttpIngress( + @Ingress( Method.POST, "/echo/users", + HTTP ) fun post(context: Context, req: HttpRequest): HttpResponse { return HttpResponse( @@ -73,9 +76,10 @@ class Echo { } @Verb - @HttpIngress( + @Ingress( Method.PUT, "/echo/users/{userID}", + HTTP ) fun put(context: Context, req: HttpRequest): HttpResponse { return HttpResponse( @@ -86,7 +90,7 @@ class Echo { } @Verb - @HttpIngress(Method.DELETE, "/echo/users/{userID}") + @Ingress(Method.DELETE, "/echo/users/{userID}", HTTP) fun delete(context: Context, req: HttpRequest): HttpResponse { return HttpResponse( status = 200, 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 c00fb1a437..ac4e91519c 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.HttpIngress import xyz.block.ftl.Ignore import xyz.block.ftl.Ingress import xyz.block.ftl.v1.schema.* @@ -109,9 +108,10 @@ class ModuleGenerator() { verb.metadata.forEach { metadata -> metadata.ingress?.let { verbFunBuilder.addAnnotation( - AnnotationSpec.builder(if (it.type == "ftl") Ingress::class else HttpIngress::class) + AnnotationSpec.builder(Ingress::class) .addMember("%T", ClassName("xyz.block.ftl.Method", it.method.replaceBefore(".", ""))) .addMember("%S", ingressPathString(it.path)) + .addMember("%T", ClassName("xyz.block.ftl.Ingress.Type", it.type.uppercase().replaceBefore(".", ""))) .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 a7748eb39a..9cd0315abd 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 @@ -177,7 +177,7 @@ public class TestModule() metadata = listOf( Metadata( ingress = MetadataIngress( - type = "ftl", + type = "http", path = listOf(IngressPathComponent(ingressPathLiteral = IngressPathLiteral(text = "test"))), method = "GET" ) @@ -196,6 +196,7 @@ import kotlin.Unit import xyz.block.ftl.Context import xyz.block.ftl.Ignore import xyz.block.ftl.Ingress +import xyz.block.ftl.Ingress.Type.HTTP import xyz.block.ftl.Method.GET import xyz.block.ftl.Verb @@ -225,6 +226,7 @@ public class TestModule() { @Ingress( GET, "/test", + HTTP, ) public fun TestIngressVerb(context: Context, req: TestRequest): TestResponse = throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(TestModule::TestIngressVerb, ...)") diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/HttpIngress.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/HttpIngress.kt deleted file mode 100644 index f3227451be..0000000000 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/HttpIngress.kt +++ /dev/null @@ -1,11 +0,0 @@ -package xyz.block.ftl - -/** - * A Verb marked as HttpIngress is used to handle raw HTTP requests. - * - * The request and response must be ftl.builtin.HttpRequest and ftl.builtin.HttpResponse respectively. - */ -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -@MustBeDocumented -annotation class HttpIngress(val method: Method, val path: String) diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Ingress.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Ingress.kt index 2610d306aa..a1e95e2f48 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Ingress.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Ingress.kt @@ -10,7 +10,11 @@ enum class Method { @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @MustBeDocumented -annotation class Ingress(val method: Method, val path: String) +annotation class Ingress(val method: Method, val path: String, val type: Type = Type.HTTP) { + enum class Type { + HTTP + } +} /** * A field marked with Alias will be renamed to the specified name on ingress from external inputs. 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 ed925de7e7..6a378e4400 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 @@ -16,6 +16,7 @@ import org.jetbrains.kotlin.psi.* import org.jetbrains.kotlin.psi.psiUtil.getValueParameters import org.jetbrains.kotlin.resolve.BindingContext import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall +import org.jetbrains.kotlin.resolve.calls.util.getType import org.jetbrains.kotlin.resolve.source.getPsi import org.jetbrains.kotlin.resolve.typeBinding.createTypeBindingForReturnType import org.jetbrains.kotlin.types.KotlinType @@ -42,6 +43,7 @@ import kotlin.collections.Map import kotlin.io.path.createDirectories data class ModuleData(val comments: List = emptyList(), val decls: MutableSet = mutableSetOf()) + // 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}" @@ -242,47 +244,62 @@ class SchemaExtractor( private fun extractIngress(requestType: Type, responseType: Type): MetadataIngress? { return verb.annotationEntries.firstOrNull { - listOf( - Ingress::class.qualifiedName, - HttpIngress::class.qualifiedName - ).contains(bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString()) + bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Ingress::class.qualifiedName }?.let { annotationEntry -> - val annotationName = annotationEntry.typeReference?.resolveType()?.fqNameOrNull()?.asString() - val type = if (annotationName == Ingress::class.qualifiedName) "ftl" else "http" val sourcePos = annotationEntry.getLineAndColumn() - require(requestType.dataRef != null) { "$sourcePos ingress ${verb.name} request must be a data class" } require(responseType.dataRef != null) { "$sourcePos ingress ${verb.name} response must be a data class" } - - // If it's HTTP ingress, validate the signature. - if (type == "http") { - require(requestType.dataRef != null && requestType.dataRef.compare("builtin", "HttpRequest")) { - "$sourcePos @HttpIngress-annotated ${verb.name} request must be ftl.builtin.HttpRequest" - } - require(responseType.dataRef != null && responseType.dataRef.compare("builtin", "HttpResponse")) { - "$sourcePos @HttpIngress-annotated ${verb.name} response must be ftl.builtin.HttpResponse" - } - } - require(annotationEntry.valueArguments.size >= 2) { "$sourcePos ${verb.name} @Ingress annotation requires at least 2 arguments" } - val methodArg = requireNotNull(annotationEntry.valueArguments[0].getArgumentExpression()?.text) { - "$sourcePos Could not extract method from ${verb.name} @Ingress annotation" + var typeArg = Ingress.Type.HTTP.name + var pathArg: List? = null + var methodArg: String? = null + annotationEntry.valueArguments.map { arg -> + // Method arg is named "method" or is of type xyz.block.ftl.Method (in the case where args are + // positional rather than named). + if (arg.getArgumentName()?.asName?.asString() == "method" + || arg.getArgumentExpression()?.getType(bindingContext)?.fqNameOrNull() + ?.asString() == Method::class.qualifiedName + ) { + methodArg = arg.getArgumentExpression()?.text?.substringAfter(".") + // Type arg is named "type" or is of type xyz.block.ftl.Ingress.Type. + } else if (arg.getArgumentName()?.asName?.asString() == "type" + || arg.getArgumentExpression()?.getType(bindingContext)?.fqNameOrNull() + ?.asString() == Ingress.Type::class.qualifiedName + ) { + typeArg = requireNotNull(arg.getArgumentExpression()?.text?.substringAfter(".")) { + "$sourcePos Could not extract type from ${verb.name} @Ingress annotation" + } + // Path arg is named "path" or is of type kotlin.String. + } else if (arg.getArgumentName()?.asName?.asString() == "path" + || arg.getArgumentExpression()?.getType(bindingContext)?.fqNameOrNull() + ?.asString() == String::class.qualifiedName + ) { + pathArg = arg.getArgumentExpression()?.text?.let { extractPathComponents(it.trim('\"')) } + } } - val pathArg = requireNotNull(annotationEntry.valueArguments[1].getArgumentExpression()?.text) { - "$sourcePos Could not extract path from ${verb.name} @Ingress annotation" + + // If it's HTTP ingress, validate the signature. + if (typeArg == Ingress.Type.HTTP.name) { + require(requestType.dataRef.compare("builtin", "HttpRequest")) { + "$sourcePos HTTP @Ingress-annotated ${verb.name} request must be ftl.builtin.HttpRequest" + } + require(responseType.dataRef.compare("builtin", "HttpResponse")) { + "$sourcePos HTTP @Ingress-annotated ${verb.name} response must be ftl.builtin.HttpResponse" + } } MetadataIngress( - type = type, - method = methodArg.substringAfter("."), - path = extractPathComponents(pathArg.trim('\"')) + path = requireNotNull(pathArg) { "Could not extract path from ${verb.name} @Ingress annotation" }, + method = requireNotNull(methodArg) { "Could not extract method from ${verb.name} @Ingress annotation" }, + type = typeArg.lowercase(), + pos = sourcePos.toPosition(verb.containingKtFile.name), ) } } 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 c17360792d..b8a4c3f87f 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 @@ -5,6 +5,8 @@ import io.gitlab.arturbosch.detekt.rules.KotlinCoreEnvironmentTest import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext import org.assertj.core.api.Assertions.assertThat import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment +import org.jetbrains.kotlin.cli.jvm.config.addJvmClasspathRoots +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import xyz.block.ftl.schemaextractor.ExtractSchemaRule.Companion.OUTPUT_FILENAME @@ -16,12 +18,20 @@ import kotlin.test.AfterTest @KotlinCoreEnvironmentTest internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { + @BeforeEach + fun setup() { + val dependenciesDir = File("src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies") + val dependencies = dependenciesDir.listFiles { file -> file.extension == "kt" }?.toList() ?: emptyList() + env.configuration.addJvmClasspathRoots(dependencies) + } @Test fun `extracts schema`() { val code = """ package ftl.echo + import ftl.builtin.HttpRequest + import ftl.builtin.HttpResponse import ftl.time.TimeModuleClient import ftl.time.TimeRequest import ftl.time.TimeResponse @@ -52,9 +62,14 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { @Throws(InvalidInput::class) @Verb @Ingress(Method.GET, "/echo") - fun echo(context: Context, req: EchoRequest): EchoResponse { + fun echo(context: Context, req: HttpRequest>): HttpResponse { callTime(context) - return 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!"))) + ) } fun callTime(context: Context): TimeResponse { @@ -168,23 +183,39 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { ), request = Type( dataRef = DataRef( - name = "EchoRequest", + name = "HttpRequest", typeParameters = listOf( - Type(string = xyz.block.ftl.v1.schema.String()) + Type( + dataRef = DataRef( + name = "EchoRequest", + typeParameters = listOf( + Type(string = xyz.block.ftl.v1.schema.String()) + ), + module = "echo" + ) + ) ), - module = "echo" + module = "builtin" ) ), response = Type( dataRef = DataRef( - name = "EchoResponse", - module = "echo" - ) + name = "HttpResponse", + module = "builtin", + typeParameters = listOf( + Type( + dataRef = DataRef( + name = "EchoResponse", + module = "echo" + ) + ) + ), + ), ), metadata = listOf( Metadata( ingress = MetadataIngress( - type = "ftl", + type = "http", method = "GET", path = listOf( IngressPathComponent( @@ -264,6 +295,42 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { } } + @Test + fun `fails if http ingress without http request-response types`() { + val code = """ + package ftl.echo + + import xyz.block.ftl.Context + import xyz.block.ftl.Ingress + 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) + + /** + * Echo module. + */ + class Echo { + /** + * Echoes the given message. + */ + @Throws(InvalidInput::class) + @Verb + @Ingress(Method.GET, "/echo") + fun echo(context: Context, req: EchoRequest): EchoResponse { + return EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) + } + } + """ + assertThrows(message = "HTTP @Ingress-annotated echo request must be ftl.builtin.HttpRequest") { + ExtractSchemaRule(Config.empty).compileAndLintWithContext(env, code) + } + } + @AfterTest fun cleanup() { val file = File(OUTPUT_FILENAME) 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 new file mode 100644 index 0000000000..2fbfab5d03 --- /dev/null +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/BuiltinModuleClient.kt @@ -0,0 +1,33 @@ +// Code generated by FTL-Generator, do not edit. +// Built-in types for FTL. +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. + */ +public data class HttpRequest( + public val method: String, + public val path: String, + public val pathParameters: Map, + public val query: Map>, + public val headers: Map>, + public val body: Body, +) + +/** + * HTTP response structure used for HTTP ingress verbs. + */ +public data class HttpResponse( + public val status: Long, + public val headers: Map>, + public val body: Body, +) + +@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 new file mode 100644 index 0000000000..2a3abaae48 --- /dev/null +++ b/kotlin-runtime/ftl-runtime/src/test/kotlin/xyz/block/ftl/schemaextractor/testdata/dependencies/TimeModuleClient.kt @@ -0,0 +1,30 @@ +// Code generated by FTL-Generator, do not edit. +// +package ftl.time + +import xyz.block.ftl.* +import java.time.OffsetDateTime +import kotlin.Unit +import xyz.block.ftl.Method.GET + +public data class TimeRequest( + public val _empty: Unit = Unit, +) + +public data class TimeResponse( + public val time: OffsetDateTime, +) + +@Ignore +public class TimeModuleClient() { + /** + * Time returns the current time. + */ + @Verb + @Ingress( + 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, ...)") +}