From 08cf1cc29d93316a96a6d8ce944d09c14810249d Mon Sep 17 00:00:00 2001 From: Elizabeth Worstell Date: Wed, 20 Mar 2024 16:21:42 -0700 Subject: [PATCH] feat: use export annotation - Go: Replaces `ftl:verb` and `ftl:enum` with `ftl:export` - Kotlin: Replaces `@Verb` with `@Export` - Updates runtime schema extractors to pull standalone data decls annotated with `export` Design here: https://hackmd.io/i5COidF3SEynCab8VcJwng fixes #1114 --- buildengine/build_go_test.go | 16 +- buildengine/build_kotlin.go | 8 +- buildengine/build_kotlin_test.go | 28 +- buildengine/testdata/projects/alpha/alpha.go | 3 +- .../src/main/kotlin/ftl/alpha/Alpha.kt | 4 +- .../testdata/projects/another/another.go | 5 +- .../src/main/kotlin/ftl/echo/Echo.kt | 4 +- .../testdata/projects/external/external.go | 2 +- .../ftl/externalkotlin/ExternalKotlin.kt | 4 +- buildengine/testdata/projects/other/other.go | 5 +- examples/go/echo/echo.go | 4 +- examples/go/time/time.go | 3 +- .../echo/src/main/kotlin/ftl/echo/Echo.kt | 4 +- .../time/src/main/kotlin/ftl/time/Time.kt | 4 +- .../external_module.go | 5 +- go-runtime/compile/parser.go | 28 +- go-runtime/compile/schema.go | 92 ++-- go-runtime/compile/schema_test.go | 8 +- .../compile/testdata/failing/failing.go | 3 +- go-runtime/compile/testdata/one/one.go | 17 +- go-runtime/compile/testdata/two/two.go | 13 +- .../{{ .Name | camel | lower }}.go.tmpl | 3 +- integration/testdata/go/database/echo.go | 3 +- integration/testdata/go/externalcalls/echo.go | 5 +- integration/testdata/go/httpingress/echo.go | 29 +- integration/testdata/go/pubsub/echo.go | 7 +- integration/testdata/kotlin/database/Echo.kt | 4 +- .../testdata/kotlin/externalcalls/Echo.kt | 6 +- .../testdata/kotlin/httpingress/Echo.kt | 30 +- integration/testdata/kotlin/pubsub/Echo.kt | 8 +- .../{{ .Name | camel }}.kt | 4 +- .../src/main/kotlin/xyz/block/ftl/Context.kt | 4 +- .../src/main/kotlin/xyz/block/ftl/Export.kt | 6 + .../src/main/kotlin/xyz/block/ftl/Verb.kt | 6 - .../kotlin/xyz/block/ftl/registry/Registry.kt | 4 +- .../ftl/schemaextractor/ExtractSchemaRule.kt | 403 +++++++++--------- .../schemaextractor/ExtractSchemaRuleTest.kt | 128 +++--- .../testdata/dependencies/TimeModuleClient.kt | 6 +- .../xyz/block/ftl/testdata/TestModule.kt | 8 +- .../{{ .Name | camel }}.kt | 4 +- 40 files changed, 456 insertions(+), 472 deletions(-) create mode 100644 kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Export.kt delete mode 100644 kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Verb.kt diff --git a/buildengine/build_go_test.go b/buildengine/build_go_test.go index e3f10776af..13007f4b21 100644 --- a/buildengine/build_go_test.go +++ b/buildengine/build_go_test.go @@ -63,7 +63,6 @@ func TestGenerateGoModule(t *testing.T) { } expected := `// Code generated by FTL. DO NOT EDIT. -//ftl:module other package other import ( @@ -73,7 +72,7 @@ import ( var _ = context.Background -//ftl:enum +//ftl:export type Color string const ( Red Color = "Red" @@ -81,7 +80,7 @@ const ( Green Color = "Green" ) -//ftl:enum +//ftl:export type ColorInt int const ( RedInt ColorInt = 0 @@ -95,7 +94,7 @@ type EchoRequest struct { type EchoResponse struct { } -//ftl:verb +//ftl:export func Echo(context.Context, EchoRequest) (EchoResponse, error) { panic("Verb stubs should not be called directly, instead use github.com/TBD54566975/ftl/runtime-go/ftl.Call()") } @@ -103,7 +102,7 @@ func Echo(context.Context, EchoRequest) (EchoResponse, error) { type SinkReq struct { } -//ftl:verb +//ftl:export func Sink(context.Context, SinkReq) error { panic("Verb stubs should not be called directly, instead use github.com/TBD54566975/ftl/runtime-go/ftl.CallSink()") } @@ -111,12 +110,12 @@ func Sink(context.Context, SinkReq) error { type SourceResp struct { } -//ftl:verb +//ftl:export func Source(context.Context) (SourceResp, error) { panic("Verb stubs should not be called directly, instead use github.com/TBD54566975/ftl/runtime-go/ftl.CallSource()") } -//ftl:verb +//ftl:export func Nothing(context.Context) error { panic("Verb stubs should not be called directly, instead use github.com/TBD54566975/ftl/runtime-go/ftl.CallEmpty()") } @@ -151,7 +150,6 @@ func TestMetadataImportsExcluded(t *testing.T) { } expected := `// Code generated by FTL. DO NOT EDIT. -//ftl:module test package test import ( @@ -166,7 +164,7 @@ type Req struct { type Resp struct { } -//ftl:verb +//ftl:export func Call(context.Context, Req) (Resp, error) { panic("Verb stubs should not be called directly, instead use github.com/TBD54566975/ftl/runtime-go/ftl.Call()") } diff --git a/buildengine/build_kotlin.go b/buildengine/build_kotlin.go index 86aa0d1711..bf4bc39b77 100644 --- a/buildengine/build_kotlin.go +++ b/buildengine/build_kotlin.go @@ -170,8 +170,14 @@ var scaffoldFuncs = scaffolder.FuncMap{ imports := sets.NewSet[string]() _ = schema.VisitExcludingMetadataChildren(m, func(n schema.Node, next func() error) error { switch n.(type) { + case *schema.Data: + imports.Add("xyz.block.ftl.Export") + + case *schema.Enum: + imports.Add("xyz.block.ftl.Export") + case *schema.Verb: - imports.Append("xyz.block.ftl.Context", "xyz.block.ftl.Ignore", "xyz.block.ftl.Verb") + imports.Append("xyz.block.ftl.Context", "xyz.block.ftl.Ignore", "xyz.block.ftl.Export") case *schema.Time: imports.Add("java.time.OffsetDateTime") diff --git a/buildengine/build_kotlin_test.go b/buildengine/build_kotlin_test.go index 2951c198e0..3abd2d0f92 100644 --- a/buildengine/build_kotlin_test.go +++ b/buildengine/build_kotlin_test.go @@ -114,11 +114,14 @@ func TestGenerateAllTypes(t *testing.T) { package ftl.test import java.time.OffsetDateTime +import xyz.block.ftl.Export +@Export data class ParamTestData( val t: T, ) +@Export data class TestRequest( val field: Long, ) @@ -126,6 +129,7 @@ data class TestRequest( /** * Response comments */ +@Export data class TestResponse( val int: Long, val float: Float, @@ -193,9 +197,10 @@ func TestGenerateAllVerbs(t *testing.T) { package ftl.test import xyz.block.ftl.Context +import xyz.block.ftl.Export import xyz.block.ftl.Ignore -import xyz.block.ftl.Verb +@Export data class Request( val data: Long, ) @@ -203,7 +208,7 @@ data class Request( /** * TestVerb comments */ -@Verb +@Export @Ignore fun testVerb(context: Context, req: Request): ftl.builtin.Empty = throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(::testVerb, ...)") @@ -233,9 +238,12 @@ func TestGenerateBuiltins(t *testing.T) { */ package ftl.builtin +import xyz.block.ftl.Export + /** * HTTP request structure used for HTTP ingress verbs. */ +@Export data class HttpRequest( val method: String, val path: String, @@ -248,6 +256,7 @@ data class HttpRequest( /** * HTTP response structure used for HTTP ingress verbs. */ +@Export data class HttpResponse( val status: Long, val headers: Map>, @@ -255,6 +264,7 @@ data class HttpResponse( val error: Error? = null, ) +@Export class Empty ` bctx := buildContext{ @@ -293,10 +303,10 @@ func TestGenerateEmptyRefs(t *testing.T) { package ftl.test import xyz.block.ftl.Context +import xyz.block.ftl.Export import xyz.block.ftl.Ignore -import xyz.block.ftl.Verb -@Verb +@Export @Ignore fun emptyVerb(context: Context, req: ftl.builtin.Empty): ftl.builtin.Empty = throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(::emptyVerb, ...)") @@ -355,26 +365,28 @@ func TestGenerateSourcesAndSinks(t *testing.T) { package ftl.test import xyz.block.ftl.Context +import xyz.block.ftl.Export import xyz.block.ftl.Ignore -import xyz.block.ftl.Verb +@Export data class SinkReq( val data: Long, ) -@Verb +@Export @Ignore fun sink(context: Context, req: SinkReq): Unit = throw NotImplementedError("Verb stubs should not be called directly, instead use context.callSink(::sink, ...)") +@Export data class SourceResp( val data: Long, ) -@Verb +@Export @Ignore fun source(context: Context): SourceResp = throw NotImplementedError("Verb stubs should not be called directly, instead use context.callSource(::source, ...)") -@Verb +@Export @Ignore fun nothing(context: Context): Unit = throw NotImplementedError("Verb stubs should not be called directly, instead use context.callEmpty(::nothing, ...)") diff --git a/buildengine/testdata/projects/alpha/alpha.go b/buildengine/testdata/projects/alpha/alpha.go index 44d87ba3a0..b17ab8fe8c 100644 --- a/buildengine/testdata/projects/alpha/alpha.go +++ b/buildengine/testdata/projects/alpha/alpha.go @@ -1,4 +1,3 @@ -//ftl:module alpha package alpha import ( @@ -18,7 +17,7 @@ type EchoResponse struct { Message string `json:"message"` } -//ftl:verb +//ftl:export func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { ftl.Call(ctx, other.Echo, other.EchoRequest{}) return EchoResponse{Message: fmt.Sprintf("Hello, %s!", req.Name.Default("anonymous"))}, nil diff --git a/buildengine/testdata/projects/alphakotlin/src/main/kotlin/ftl/alpha/Alpha.kt b/buildengine/testdata/projects/alphakotlin/src/main/kotlin/ftl/alpha/Alpha.kt index 722a78f183..fe20667cde 100644 --- a/buildengine/testdata/projects/alphakotlin/src/main/kotlin/ftl/alpha/Alpha.kt +++ b/buildengine/testdata/projects/alphakotlin/src/main/kotlin/ftl/alpha/Alpha.kt @@ -3,12 +3,12 @@ package ftl.alpha import ftl.builtin.Empty import ftl.other.Other import xyz.block.ftl.Context -import xyz.block.ftl.Verb +import xyz.block.ftl.Export data class EchoRequest(val name: String?) data class EchoResponse(val message: String) -@Verb +@Export fun echo(context: Context, req: EchoRequest): EchoResponse { val other = Other() return EchoResponse(message = "Hello, ${req.name ?: "anonymous"}!.") diff --git a/buildengine/testdata/projects/another/another.go b/buildengine/testdata/projects/another/another.go index 471e64eb87..73c82e9d76 100644 --- a/buildengine/testdata/projects/another/another.go +++ b/buildengine/testdata/projects/another/another.go @@ -1,4 +1,3 @@ -//ftl:module another package another import ( @@ -16,7 +15,7 @@ type EchoResponse struct { Message string `json:"message"` } -//ftl:verb +//ftl:export func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { return EchoResponse{Message: fmt.Sprintf("Hello, %s!", req.Name.Default("anonymous"))}, nil -} \ No newline at end of file +} diff --git a/buildengine/testdata/projects/echokotlin/src/main/kotlin/ftl/echo/Echo.kt b/buildengine/testdata/projects/echokotlin/src/main/kotlin/ftl/echo/Echo.kt index 08f08bebf9..3028a3451f 100644 --- a/buildengine/testdata/projects/echokotlin/src/main/kotlin/ftl/echo/Echo.kt +++ b/buildengine/testdata/projects/echokotlin/src/main/kotlin/ftl/echo/Echo.kt @@ -2,12 +2,12 @@ package ftl.echo import xyz.block.ftl.Context import xyz.block.ftl.Method -import xyz.block.ftl.Verb +import xyz.block.ftl.Export data class EchoRequest(val name: String? = "anonymous") data class EchoResponse(val message: String) -@Verb +@Export fun echo(context: Context, req: EchoRequest): EchoResponse { return EchoResponse(message = "Hello, ${req.name}!") } diff --git a/buildengine/testdata/projects/external/external.go b/buildengine/testdata/projects/external/external.go index 93feca28d1..92ae63c4fd 100644 --- a/buildengine/testdata/projects/external/external.go +++ b/buildengine/testdata/projects/external/external.go @@ -13,7 +13,7 @@ type ExternalResponse struct { // External returns the current month as an external type. // -//ftl:verb +//ftl:export func Time(ctx context.Context, req ExternalRequest) (ExternalResponse, error) { return ExternalResponse{Month: time.Now().Month()}, nil } diff --git a/buildengine/testdata/projects/externalkotlin/src/main/kotlin/ftl/externalkotlin/ExternalKotlin.kt b/buildengine/testdata/projects/externalkotlin/src/main/kotlin/ftl/externalkotlin/ExternalKotlin.kt index 7bd35258e2..277e37e232 100644 --- a/buildengine/testdata/projects/externalkotlin/src/main/kotlin/ftl/externalkotlin/ExternalKotlin.kt +++ b/buildengine/testdata/projects/externalkotlin/src/main/kotlin/ftl/externalkotlin/ExternalKotlin.kt @@ -2,7 +2,7 @@ package ftl.externalkotlin import com.google.type.DayOfWeek import xyz.block.ftl.Context -import xyz.block.ftl.Verb +import xyz.block.ftl.Export import xyz.block.ftl.v1.schema.Optional class InvalidInput(val field: String) : Exception() @@ -11,7 +11,7 @@ data class ExternalRequest(val name: String?, val dayOfWeek: DayOfWeek) data class ExternalResponse(val message: String) @Throws(InvalidInput::class) -@Verb +@Export fun external(context: Context, req: ExternalRequest): ExternalResponse { return ExternalResponse(message = "Hello, ${req.name ?: "anonymous"}!") } diff --git a/buildengine/testdata/projects/other/other.go b/buildengine/testdata/projects/other/other.go index c75dd1c5cb..295d1a98c2 100644 --- a/buildengine/testdata/projects/other/other.go +++ b/buildengine/testdata/projects/other/other.go @@ -1,4 +1,3 @@ -//ftl:module other package other import ( @@ -16,7 +15,7 @@ type EchoResponse struct { Message string `json:"message"` } -//ftl:verb +//ftl:export func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { return EchoResponse{Message: fmt.Sprintf("Hello, %s!", req.Name.Default("anonymous"))}, nil -} \ No newline at end of file +} diff --git a/examples/go/echo/echo.go b/examples/go/echo/echo.go index 8c8fa03131..175eeaca47 100644 --- a/examples/go/echo/echo.go +++ b/examples/go/echo/echo.go @@ -1,6 +1,4 @@ // This is the echo module. -// -//ftl:module echo package echo import ( @@ -25,7 +23,7 @@ type EchoResponse struct { // Echo returns a greeting with the current time. // -//ftl:verb +//ftl:export func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { tresp, err := ftl.Call(ctx, time.Time, time.TimeRequest{}) if err != nil { diff --git a/examples/go/time/time.go b/examples/go/time/time.go index ed9ff61ed0..91c55bce04 100644 --- a/examples/go/time/time.go +++ b/examples/go/time/time.go @@ -1,4 +1,3 @@ -//ftl:module time package time import ( @@ -13,7 +12,7 @@ type TimeResponse struct { // Time returns the current time. // -//ftl:verb +//ftl:export func Time(ctx context.Context, req TimeRequest) (TimeResponse, error) { return TimeResponse{Time: time.Now()}, nil } diff --git a/examples/kotlin/echo/src/main/kotlin/ftl/echo/Echo.kt b/examples/kotlin/echo/src/main/kotlin/ftl/echo/Echo.kt index 255c4666bd..3bebf67f3d 100644 --- a/examples/kotlin/echo/src/main/kotlin/ftl/echo/Echo.kt +++ b/examples/kotlin/echo/src/main/kotlin/ftl/echo/Echo.kt @@ -3,7 +3,7 @@ package ftl.echo import ftl.builtin.Empty import ftl.time.time import xyz.block.ftl.Context -import xyz.block.ftl.Verb +import xyz.block.ftl.Export class InvalidInput(val field: String) : Exception() @@ -11,7 +11,7 @@ data class EchoRequest(val name: String?) data class EchoResponse(val message: String) @Throws(InvalidInput::class) -@Verb +@Export 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/examples/kotlin/time/src/main/kotlin/ftl/time/Time.kt b/examples/kotlin/time/src/main/kotlin/ftl/time/Time.kt index ea3b25af7b..7158bc5a38 100644 --- a/examples/kotlin/time/src/main/kotlin/ftl/time/Time.kt +++ b/examples/kotlin/time/src/main/kotlin/ftl/time/Time.kt @@ -2,12 +2,12 @@ package ftl.time import ftl.builtin.Empty import xyz.block.ftl.Context -import xyz.block.ftl.Verb +import xyz.block.ftl.Export import java.time.OffsetDateTime data class TimeResponse(val time: OffsetDateTime) -@Verb +@Export fun time(context: Context, req: Empty): TimeResponse { return TimeResponse(time = OffsetDateTime.now()) } diff --git a/go-runtime/compile/external-module-template/_ftl/go/modules/{{ range .NonMainModules }}{{ push .Name . }}{{ end }}/external_module.go b/go-runtime/compile/external-module-template/_ftl/go/modules/{{ range .NonMainModules }}{{ push .Name . }}{{ end }}/external_module.go index 22713846a3..c300ab83c2 100644 --- a/go-runtime/compile/external-module-template/_ftl/go/modules/{{ range .NonMainModules }}{{ push .Name . }}{{ end }}/external_module.go +++ b/go-runtime/compile/external-module-template/_ftl/go/modules/{{ range .NonMainModules }}{{ push .Name . }}{{ end }}/external_module.go @@ -1,6 +1,5 @@ // Code generated by FTL. DO NOT EDIT. -//ftl:module {{.Name}} package {{.Name}} import ( @@ -15,7 +14,7 @@ var _ = context.Background {{- range .Decls }} {{if is "Enum" . }} {{$enumName := .Name -}} -//ftl:enum +//ftl:export type {{.Name|title}} {{ type $ .Type }} const ( {{- range .Variants }} @@ -37,7 +36,7 @@ type {{.Name|title}} {{.Comments|comment }} {{if .Comments}}// {{end -}} -//ftl:verb +//ftl:export {{- if and (eq (type $ .Request) "ftl.Unit") (eq (type $ .Response) "ftl.Unit")}} func {{.Name|title}}(context.Context) error { panic("Verb stubs should not be called directly, instead use github.com/TBD54566975/ftl/runtime-go/ftl.CallEmpty()") diff --git a/go-runtime/compile/parser.go b/go-runtime/compile/parser.go index 66aadfe8ff..64579f021a 100644 --- a/go-runtime/compile/parser.go +++ b/go-runtime/compile/parser.go @@ -24,14 +24,14 @@ type directiveWrapper struct { //sumtype:decl type directive interface{ directive() } -type directiveVerb struct { +type directiveExport struct { Pos lexer.Position - Verb bool `parser:"@'verb'"` + Export bool `parser:"@'export'"` } -func (*directiveVerb) directive() {} -func (d *directiveVerb) String() string { return "ftl:verb" } +func (*directiveExport) directive() {} +func (d *directiveExport) String() string { return "ftl:export" } type directiveIngress struct { Pos schema.Position @@ -51,30 +51,12 @@ func (d *directiveIngress) String() string { return w.String() } -type directiveModule struct { - Pos lexer.Position - - Name string `parser:"'module' @Ident"` -} - -func (*directiveModule) directive() {} -func (d *directiveModule) String() string { return "ftl:module" } - -type directiveEnum struct { - Pos lexer.Position - - Enum bool `parser:"@'enum'"` -} - -func (*directiveEnum) directive() {} -func (d *directiveEnum) String() string { return "ftl:enum" } - var directiveParser = participle.MustBuild[directiveWrapper]( participle.Lexer(schema.Lexer), participle.Elide("Whitespace"), participle.Unquote(), participle.UseLookahead(2), - participle.Union[directive](&directiveVerb{}, &directiveIngress{}, &directiveModule{}, &directiveEnum{}), + participle.Union[directive](&directiveExport{}, &directiveIngress{}), participle.Union[schema.IngressPathComponent](&schema.IngressPathLiteral{}, &schema.IngressPathParameter{}), ) diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go index 40f58183f7..fbec362da1 100644 --- a/go-runtime/compile/schema.go +++ b/go-runtime/compile/schema.go @@ -84,9 +84,7 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { } case *ast.File: - if err := visitFile(pctx, node); err != nil { - return err - } + visitFile(pctx, node) case *ast.FuncDecl: verb, err := visitFuncDecl(pctx, node) @@ -124,9 +122,6 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { if len(merr) > 0 { return nil, nil, errors.Join(merr...) } - if module.Name == "" { - return nil, nil, fmt.Errorf("//ftl:module directive is required") - } return nativeNames, module, schema.ValidateModule(module) } @@ -221,28 +216,11 @@ func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) err return nil } -func visitFile(pctx *parseContext, node *ast.File) error { +func visitFile(pctx *parseContext, node *ast.File) { if node.Doc == nil { - return nil - } - directives, err := parseDirectives(fset, node.Doc) - if err != nil { - return err + return } pctx.module.Comments = visitComments(node.Doc) - for _, dir := range directives { - switch dir := dir.(type) { - case *directiveModule: - if dir.Name != pctx.pkg.Name { - return errorf(node.Pos(), "%s: FTL module name %q does not match Go package name %q", dir, dir.Name, pctx.pkg.Name) - } - pctx.module.Name = dir.Name - - default: - return errorf(node.Pos(), "%s: invalid directive", dir) - } - } - return nil } func isType[T types.Type](t types.Type) bool { @@ -315,23 +293,31 @@ func visitGenDecl(pctx *parseContext, node *ast.GenDecl) error { } for _, dir := range directives { switch dir.(type) { - case *directiveEnum: - enum := &schema.Enum{ - Pos: goPosToSchemaPos(node.Pos()), - Comments: visitComments(node.Doc), - } + case *directiveExport: if len(node.Specs) != 1 { - return errorf(node.Pos(), "error parsing ftl enum %s: expected exactly one type spec", enum.Name) + return errorf(node.Pos(), "error parsing ftl export directive: expected exactly one type "+ + "declaration") + } + if pctx.module.Name == "" { + pctx.module.Name = pctx.pkg.Name + } else if pctx.module.Name != pctx.pkg.Name { + return errorf(node.Pos(), "type export directive must be in the module package") } if t, ok := node.Specs[0].(*ast.TypeSpec); ok { - pctx.enums[t.Name.Name] = enum + if _, ok := pctx.pkg.TypesInfo.TypeOf(t.Type).Underlying().(*types.Basic); ok { + enum := &schema.Enum{ + Pos: goPosToSchemaPos(node.Pos()), + Comments: visitComments(node.Doc), + } + pctx.enums[t.Name.Name] = enum + } err := visitTypeSpec(pctx, t) if err != nil { return err } } - case *directiveIngress, *directiveModule, *directiveVerb: + case *directiveIngress: } } return nil @@ -363,17 +349,21 @@ func visitGenDecl(pctx *parseContext, node *ast.GenDecl) error { } func visitTypeSpec(pctx *parseContext, node *ast.TypeSpec) error { - enum := pctx.enums[node.Name.Name] - if enum == nil { - return nil - } - typ, err := visitType(pctx, node.Pos(), pctx.pkg.TypesInfo.TypeOf(node.Type)) - if err != nil { - return err + if enum, ok := pctx.enums[node.Name.Name]; ok { + typ, err := visitType(pctx, node.Pos(), pctx.pkg.TypesInfo.TypeOf(node.Type)) + if err != nil { + return err + } + + enum.Name = strcase.ToUpperCamel(node.Name.Name) + enum.Type = typ + pctx.nativeNames[enum] = node.Name.Name + } else { + _, err := visitType(pctx, node.Pos(), pctx.pkg.TypesInfo.Defs[node.Name].Type()) + if err != nil { + return err + } } - enum.Name = strcase.ToUpperCamel(node.Name.Name) - enum.Type = typ - pctx.nativeNames[enum] = node.Name.Name return nil } @@ -415,12 +405,13 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, e isVerb := false for _, dir := range directives { switch dir := dir.(type) { - case *directiveEnum: - - case *directiveModule: - - case *directiveVerb: + case *directiveExport: isVerb = true + if pctx.module.Name == "" { + pctx.module.Name = pctx.pkg.Name + } else if pctx.module.Name != pctx.pkg.Name { + return nil, errorf(node.Pos(), "function export directive must be in the module package") + } case *directiveIngress: typ := dir.Type @@ -433,9 +424,6 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, e Method: dir.Method, Path: dir.Path, }) - - default: - panic(fmt.Sprintf("unsupported directive %T", dir)) } } if !isVerb { @@ -444,7 +432,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, e fnt := pctx.pkg.TypesInfo.Defs[node.Name].(*types.Func) //nolint:forcetypeassert sig := fnt.Type().(*types.Signature) //nolint:forcetypeassert if sig.Recv() != nil { - return nil, errorf(node.Pos(), "ftl:verb cannot be a method") + return nil, errorf(node.Pos(), "ftl:export cannot be a method") } params := sig.Params() results := sig.Results() diff --git a/go-runtime/compile/schema_test.go b/go-runtime/compile/schema_test.go index 621982192e..2719e4c431 100644 --- a/go-runtime/compile/schema_test.go +++ b/go-runtime/compile/schema_test.go @@ -128,6 +128,9 @@ func TestExtractModuleSchemaTwo(t *testing.T) { Red("Red") Blue("Blue") Green("Green") + } + + data Exported { } data Payload { @@ -159,8 +162,7 @@ func TestParseDirectives(t *testing.T) { input string expected directive }{ - {name: "Module", input: "ftl:module foo", expected: &directiveModule{Name: "foo"}}, - {name: "Verb", input: "ftl:verb", expected: &directiveVerb{Verb: true}}, + {name: "Export", input: "ftl:export", expected: &directiveExport{Export: true}}, {name: "Ingress", input: `ftl:ingress GET /foo`, expected: &directiveIngress{ Method: "GET", Path: []schema.IngressPathComponent{ @@ -229,5 +231,5 @@ func normaliseString(s string) string { func TestErrorReporting(t *testing.T) { pwd, _ := os.Getwd() _, _, err := ExtractModuleSchema("testdata/failing") - assert.EqualError(t, err, filepath.Join(pwd, `testdata/failing/failing.go`)+`:15:2: call must have exactly three arguments`) + assert.EqualError(t, err, filepath.Join(pwd, `testdata/failing/failing.go`)+`:14:2: call must have exactly three arguments`) } diff --git a/go-runtime/compile/testdata/failing/failing.go b/go-runtime/compile/testdata/failing/failing.go index f1bfd07f1b..e86f6615af 100644 --- a/go-runtime/compile/testdata/failing/failing.go +++ b/go-runtime/compile/testdata/failing/failing.go @@ -1,4 +1,3 @@ -//ftl:module failing package failing import ( @@ -10,7 +9,7 @@ import ( type Request struct{} type Response struct{} -//ftl:verb +//ftl:export func FailingVerb(ctx context.Context, req Request) (Response, error) { ftl.Call(ctx, "failing", "failingVerb", req) return Response{}, nil diff --git a/go-runtime/compile/testdata/one/one.go b/go-runtime/compile/testdata/one/one.go index 845a84c16b..41dc2cc36b 100644 --- a/go-runtime/compile/testdata/one/one.go +++ b/go-runtime/compile/testdata/one/one.go @@ -1,4 +1,3 @@ -//ftl:module one package one import ( @@ -10,7 +9,7 @@ import ( "github.com/TBD54566975/ftl/go-runtime/ftl" ) -//ftl:enum +//ftl:export type Color string const ( @@ -21,7 +20,7 @@ const ( // Comments about ColorInt. // -//ftl:enum +//ftl:export type ColorInt int const ( @@ -32,7 +31,7 @@ const ( GreenInt ColorInt = 2 ) -//ftl:enum +//ftl:export type SimpleIota int const ( @@ -41,7 +40,7 @@ const ( Two ) -//ftl:enum +//ftl:export type IotaExpr int const ( @@ -76,7 +75,7 @@ type Config struct { var configValue = ftl.Config[Config]("configValue") var secretValue = ftl.Secret[string]("secretValue") -//ftl:verb +//ftl:export func Verb(ctx context.Context, req Req) (Resp, error) { return Resp{}, nil } @@ -87,19 +86,19 @@ const YellowInt ColorInt = 3 type SinkReq struct{} -//ftl:verb +//ftl:export func Sink(ctx context.Context, req SinkReq) error { return nil } type SourceResp struct{} -//ftl:verb +//ftl:export func Source(ctx context.Context) (SourceResp, error) { return SourceResp{}, nil } -//ftl:verb +//ftl:export func Nothing(ctx context.Context) error { return nil } diff --git a/go-runtime/compile/testdata/two/two.go b/go-runtime/compile/testdata/two/two.go index 079edd3a7c..c823117ca8 100644 --- a/go-runtime/compile/testdata/two/two.go +++ b/go-runtime/compile/testdata/two/two.go @@ -1,4 +1,3 @@ -//ftl:module two package two import ( @@ -7,7 +6,7 @@ import ( "github.com/TBD54566975/ftl/go-runtime/ftl" ) -//ftl:enum +//ftl:export type TwoEnum string const ( @@ -16,6 +15,10 @@ const ( Green TwoEnum = "Green" ) +//ftl:export +type Exported struct { +} + type User struct { Name string } @@ -28,17 +31,17 @@ type UserResponse struct { User User } -//ftl:verb +//ftl:export func Two(ctx context.Context, req Payload[string]) (Payload[string], error) { return Payload[string]{}, nil } -//ftl:verb +//ftl:export func CallsTwo(ctx context.Context, req Payload[string]) (Payload[string], error) { return ftl.Call(ctx, Two, req) } -//ftl:verb +//ftl:export func ReturnsUser(ctx context.Context) (ftl.Option[UserResponse], error) { return ftl.Some[UserResponse](UserResponse{ User: User{ diff --git a/go-runtime/scaffolding/{{ .Name | camel | lower }}/{{ .Name | camel | lower }}.go.tmpl b/go-runtime/scaffolding/{{ .Name | camel | lower }}/{{ .Name | camel | lower }}.go.tmpl index e82a3c655e..6ce86d10ef 100644 --- a/go-runtime/scaffolding/{{ .Name | camel | lower }}/{{ .Name | camel | lower }}.go.tmpl +++ b/go-runtime/scaffolding/{{ .Name | camel | lower }}/{{ .Name | camel | lower }}.go.tmpl @@ -1,4 +1,3 @@ -//ftl:module {{ .Name | camel | lower }} package {{ .Name | camel | lower }} import ( @@ -16,7 +15,7 @@ type EchoResponse struct { Message string `json:"message"` } -//ftl:verb +//ftl:export func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { return EchoResponse{Message: fmt.Sprintf("Hello, %s!", req.Name.Default("anonymous"))}, nil } \ No newline at end of file diff --git a/integration/testdata/go/database/echo.go b/integration/testdata/go/database/echo.go index 86df85e7c4..047072de2a 100644 --- a/integration/testdata/go/database/echo.go +++ b/integration/testdata/go/database/echo.go @@ -1,4 +1,3 @@ -//ftl:module echo package echo import ( @@ -15,7 +14,7 @@ type InsertRequest struct { type InsertResponse struct{} -//ftl:verb +//ftl:export func Insert(ctx context.Context, req InsertRequest) (InsertResponse, error) { err := persistRequest(req) if err != nil { diff --git a/integration/testdata/go/externalcalls/echo.go b/integration/testdata/go/externalcalls/echo.go index 16371480a4..725abf8809 100644 --- a/integration/testdata/go/externalcalls/echo.go +++ b/integration/testdata/go/externalcalls/echo.go @@ -1,4 +1,3 @@ -//ftl:module echo package echo import ( @@ -18,12 +17,12 @@ type EchoResponse struct { Message string `json:"message"` } -//ftl:verb +//ftl:export func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { return EchoResponse{Message: fmt.Sprintf("Hello, %s!", req.Name.Default("anonymous"))}, nil } -//ftl:verb +//ftl:export func Call(ctx context.Context, req EchoRequest) (EchoResponse, error) { res, err := ftl.Call(ctx, echo2.Echo, echo2.EchoRequest{Name: req.Name}) if err != nil { diff --git a/integration/testdata/go/httpingress/echo.go b/integration/testdata/go/httpingress/echo.go index 96d028f3c9..32085d845b 100644 --- a/integration/testdata/go/httpingress/echo.go +++ b/integration/testdata/go/httpingress/echo.go @@ -1,4 +1,3 @@ -//ftl:module echo package echo import ( @@ -24,7 +23,7 @@ type GetResponse struct { Nested Nested `json:"nested"` } -//ftl:verb +//ftl:export //ftl:ingress http GET /users/{userId}/posts/{postId} func Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, string], error) { return builtin.HttpResponse[GetResponse, string]{ @@ -47,7 +46,7 @@ type PostResponse struct { Success bool `json:"success"` } -//ftl:verb +//ftl:export //ftl:ingress http POST /users func Post(ctx context.Context, req builtin.HttpRequest[PostRequest]) (builtin.HttpResponse[PostResponse, string], error) { return builtin.HttpResponse[PostResponse, string]{ @@ -64,7 +63,7 @@ type PutRequest struct { type PutResponse struct{} -//ftl:verb +//ftl:export //ftl:ingress http PUT /users/{userId} func Put(ctx context.Context, req builtin.HttpRequest[PutRequest]) (builtin.HttpResponse[builtin.Empty, string], error) { return builtin.HttpResponse[builtin.Empty, string]{ @@ -79,7 +78,7 @@ type DeleteRequest struct { type DeleteResponse struct{} -//ftl:verb +//ftl:export //ftl:ingress http DELETE /users/{userId} func Delete(ctx context.Context, req builtin.HttpRequest[DeleteRequest]) (builtin.HttpResponse[builtin.Empty, string], error) { return builtin.HttpResponse[builtin.Empty, string]{ @@ -91,7 +90,7 @@ func Delete(ctx context.Context, req builtin.HttpRequest[DeleteRequest]) (builti type HtmlRequest struct{} -//ftl:verb +//ftl:export //ftl:ingress http GET /html func Html(ctx context.Context, req builtin.HttpRequest[HtmlRequest]) (builtin.HttpResponse[string, string], error) { return builtin.HttpResponse[string, string]{ @@ -100,43 +99,43 @@ func Html(ctx context.Context, req builtin.HttpRequest[HtmlRequest]) (builtin.Ht }, nil } -//ftl:verb +//ftl:export //ftl:ingress http POST /bytes func Bytes(ctx context.Context, req builtin.HttpRequest[[]byte]) (builtin.HttpResponse[[]byte, string], error) { return builtin.HttpResponse[[]byte, string]{Body: ftl.Some(req.Body)}, nil } -//ftl:verb +//ftl:export //ftl:ingress http GET /empty func Empty(ctx context.Context, req builtin.HttpRequest[ftl.Unit]) (builtin.HttpResponse[ftl.Unit, string], error) { return builtin.HttpResponse[ftl.Unit, string]{Body: ftl.Some(ftl.Unit{})}, nil } -//ftl:verb +//ftl:export //ftl:ingress http GET /string func String(ctx context.Context, req builtin.HttpRequest[string]) (builtin.HttpResponse[string, string], error) { return builtin.HttpResponse[string, string]{Body: ftl.Some(req.Body)}, nil } -//ftl:verb +//ftl:export //ftl:ingress http GET /int func Int(ctx context.Context, req builtin.HttpRequest[int]) (builtin.HttpResponse[int, string], error) { return builtin.HttpResponse[int, string]{Body: ftl.Some(req.Body)}, nil } -//ftl:verb +//ftl:export //ftl:ingress http GET /float func Float(ctx context.Context, req builtin.HttpRequest[float64]) (builtin.HttpResponse[float64, string], error) { return builtin.HttpResponse[float64, string]{Body: ftl.Some(req.Body)}, nil } -//ftl:verb +//ftl:export //ftl:ingress http GET /bool func Bool(ctx context.Context, req builtin.HttpRequest[bool]) (builtin.HttpResponse[bool, string], error) { return builtin.HttpResponse[bool, string]{Body: ftl.Some(req.Body)}, nil } -//ftl:verb +//ftl:export //ftl:ingress http GET /error func Error(ctx context.Context, req builtin.HttpRequest[ftl.Unit]) (builtin.HttpResponse[ftl.Unit, string], error) { return builtin.HttpResponse[ftl.Unit, string]{ @@ -145,7 +144,7 @@ func Error(ctx context.Context, req builtin.HttpRequest[ftl.Unit]) (builtin.Http }, nil } -//ftl:verb +//ftl:export //ftl:ingress http GET /array/string func ArrayString(ctx context.Context, req builtin.HttpRequest[[]string]) (builtin.HttpResponse[[]string, string], error) { return builtin.HttpResponse[[]string, string]{ @@ -157,7 +156,7 @@ type ArrayType struct { Item string `json:"item"` } -//ftl:verb +//ftl:export //ftl:ingress http POST /array/data func ArrayData(ctx context.Context, req builtin.HttpRequest[[]ArrayType]) (builtin.HttpResponse[[]ArrayType, string], error) { return builtin.HttpResponse[[]ArrayType, string]{ diff --git a/integration/testdata/go/pubsub/echo.go b/integration/testdata/go/pubsub/echo.go index d950af8835..4a57b85c4a 100644 --- a/integration/testdata/go/pubsub/echo.go +++ b/integration/testdata/go/pubsub/echo.go @@ -1,4 +1,3 @@ -//ftl:module echo package echo import ( @@ -13,7 +12,7 @@ type EchoResponse struct { Name string } -//ftl:verb +//ftl:export func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { err := ftl.CallSink(ctx, Sink, SinkRequest{}) if err != nil { @@ -34,7 +33,7 @@ type SourceResponse struct { Name string } -//ftl:verb +//ftl:export func Source(ctx context.Context) (SourceResponse, error) { return SourceResponse{ Name: "source", @@ -43,7 +42,7 @@ func Source(ctx context.Context) (SourceResponse, error) { type SinkRequest struct{} -//ftl:verb +//ftl:export func Sink(ctx context.Context, req SinkRequest) error { return nil } diff --git a/integration/testdata/kotlin/database/Echo.kt b/integration/testdata/kotlin/database/Echo.kt index 8aec137710..3595928686 100644 --- a/integration/testdata/kotlin/database/Echo.kt +++ b/integration/testdata/kotlin/database/Echo.kt @@ -2,14 +2,14 @@ package ftl.echo import ftl.builtin.Empty import xyz.block.ftl.Context -import xyz.block.ftl.Verb +import xyz.block.ftl.Export import xyz.block.ftl.Database data class InsertRequest(val data: String) val db = Database("testdb") -@Verb +@Export fun insert(context: Context, req: InsertRequest): Empty { persistRequest(req) return Empty() diff --git a/integration/testdata/kotlin/externalcalls/Echo.kt b/integration/testdata/kotlin/externalcalls/Echo.kt index 8d9ba64154..ccbc7ff753 100644 --- a/integration/testdata/kotlin/externalcalls/Echo.kt +++ b/integration/testdata/kotlin/externalcalls/Echo.kt @@ -2,17 +2,17 @@ package ftl.echo import ftl.echo2.echo as echo2 import xyz.block.ftl.Context -import xyz.block.ftl.Verb +import xyz.block.ftl.Export data class EchoRequest(val name: String) data class EchoResponse(val message: String) -@Verb +@Export fun echo(context: Context, req: EchoRequest): EchoResponse { return EchoResponse(message = "Hello, ${req.name}!") } -@Verb +@Export 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 da7c7892ef..5dd6276e44 100644 --- a/integration/testdata/kotlin/httpingress/Echo.kt +++ b/integration/testdata/kotlin/httpingress/Echo.kt @@ -9,7 +9,7 @@ import xyz.block.ftl.Json import xyz.block.ftl.Context import xyz.block.ftl.HttpIngress import xyz.block.ftl.Method -import xyz.block.ftl.Verb +import xyz.block.ftl.Export data class GetRequest( @Json("userId") val userID: String, @@ -47,7 +47,7 @@ data class ArrayType( @Json("item") val item: String, ) -@Verb +@Export @HttpIngress(Method.GET, "/users/{userID}/posts/{postID}") fun `get`(context: Context, req: HttpRequest): HttpResponse { return HttpResponse( @@ -60,7 +60,7 @@ fun `get`(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -70,7 +70,7 @@ fun post(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -80,7 +80,7 @@ fun put(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -90,7 +90,7 @@ fun delete(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -100,7 +100,7 @@ fun html(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -110,7 +110,7 @@ fun bytes(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -120,7 +120,7 @@ fun empty(context: Context, req: HttpRequest): HttpResponse ) } -@Verb +@Export @HttpIngress(Method.GET, "/string") fun string(context: Context, req: HttpRequest): HttpResponse { return HttpResponse( @@ -130,7 +130,7 @@ fun string(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -140,7 +140,7 @@ fun int(context: Context, req: HttpRequest): HttpResponse { ) } -@Verb +@Export @HttpIngress(Method.GET, "/float") fun float(context: Context, req: HttpRequest): HttpResponse { return HttpResponse( @@ -150,7 +150,7 @@ fun float(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -160,7 +160,7 @@ fun bool(context: Context, req: HttpRequest): HttpResponse): HttpResponse { return HttpResponse( @@ -170,7 +170,7 @@ fun error(context: Context, req: HttpRequest): HttpResponse>): HttpResponse, String> { return HttpResponse( @@ -180,7 +180,7 @@ fun arrayString(context: Context, req: HttpRequest>): HttpResponse< ) } -@Verb +@Export @HttpIngress(Method.POST, "/array/data") fun arrayData(context: Context, req: HttpRequest>): HttpResponse, String> { return HttpResponse( diff --git a/integration/testdata/kotlin/pubsub/Echo.kt b/integration/testdata/kotlin/pubsub/Echo.kt index be3b16f5ae..00aca86774 100644 --- a/integration/testdata/kotlin/pubsub/Echo.kt +++ b/integration/testdata/kotlin/pubsub/Echo.kt @@ -2,11 +2,11 @@ package ftl.echo import ftl.builtin.Empty import xyz.block.ftl.Context -import xyz.block.ftl.Verb +import xyz.block.ftl.Export data class EchoResponse(val name: String) -@Verb +@Export fun echo(context: Context, req: Empty): EchoResponse { context.callSink(::sink, Empty()) val resp = context.callSource(::source) @@ -15,11 +15,11 @@ fun echo(context: Context, req: Empty): EchoResponse { data class SourceResponse(val name: String) -@Verb +@Export fun source(context: Context): EchoResponse { return EchoResponse(name = "source") } -@Verb +@Export fun sink(context: Context, req: Empty) { } diff --git a/kotlin-runtime/external-module-template/target.tmpl/generated-sources/ftl/{{ range .ExternalModules }}{{ push .Name . }}{{ end }}/{{ .Name | camel }}.kt b/kotlin-runtime/external-module-template/target.tmpl/generated-sources/ftl/{{ range .ExternalModules }}{{ push .Name . }}{{ end }}/{{ .Name | camel }}.kt index 577c2fa2e9..a7a3c7677d 100644 --- a/kotlin-runtime/external-module-template/target.tmpl/generated-sources/ftl/{{ range .ExternalModules }}{{ push .Name . }}{{ end }}/{{ .Name | camel }}.kt +++ b/kotlin-runtime/external-module-template/target.tmpl/generated-sources/ftl/{{ range .ExternalModules }}{{ push .Name . }}{{ end }}/{{ .Name | camel }}.kt @@ -16,9 +16,11 @@ import {{$import}} {{- if is "Data" . }} {{- if and (eq $moduleName "builtin") (eq .Name "Empty")}} {{.Comments|comment -}} +@Export class Empty {{- else if .Fields}} {{.Comments|comment -}} +@Export data class {{.Name|title}} {{- if .TypeParameters}}< {{- range $i, $tp := .TypeParameters}} @@ -32,7 +34,7 @@ data class {{.Name|title}} {{end}} {{- else if is "Verb" . }} -{{.Comments|comment -}}@Verb +{{.Comments|comment -}}@Export @Ignore {{- if and (eq (type $ .Request) "Unit") (eq (type $ .Response) "Unit")}} fun {{.Name|lowerCamel}}(context: Context): Unit = throw 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 0041ed4ba9..b46ba45d8f 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 @@ -17,8 +17,8 @@ class Context( /// Class method with Context. inline fun call(verb: KFunction, request: Any): R { - if (!verb.hasAnnotation()) throw InvalidParameterException( - "verb must be annotated with @Verb" + if (!verb.hasAnnotation()) throw InvalidParameterException( + "verb must be annotated with @Export" ) if (verb !is CallableReference) { throw InvalidParameterException("could not determine module from verb name") diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Export.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Export.kt new file mode 100644 index 0000000000..460fe14c49 --- /dev/null +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Export.kt @@ -0,0 +1,6 @@ +package xyz.block.ftl + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +annotation class Export 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 deleted file mode 100644 index 7acc9fe04c..0000000000 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/Verb.kt +++ /dev/null @@ -1,6 +0,0 @@ -package xyz.block.ftl - -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -@MustBeDocumented -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 40c8b5d120..5a04e4009e 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 @@ -3,7 +3,7 @@ package xyz.block.ftl.registry import io.github.classgraph.ClassGraph import xyz.block.ftl.Context import xyz.block.ftl.Ignore -import xyz.block.ftl.Verb +import xyz.block.ftl.Export import xyz.block.ftl.logging.Logging import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KFunction @@ -47,7 +47,7 @@ class Registry(val jvmModuleName: String = defaultJvmModuleName) { scanResult.allClasses.flatMap { it.loadClass().kotlin.java.declaredMethods.asSequence() }.filter { - it.isAnnotationPresent(Verb::class.java) && !it.isAnnotationPresent(Ignore::class.java) + it.isAnnotationPresent(Export::class.java) && !it.isAnnotationPresent(Ignore::class.java) }.forEach { val verb = it.kotlinFunction!! maybeRegisterVerb(verb) 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 905e3822fb..07500e7b8c 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 @@ -1,6 +1,5 @@ package xyz.block.ftl.schemaextractor -import io.gitlab.arturbosch.detekt.api.Config import io.gitlab.arturbosch.detekt.api.Debt import io.gitlab.arturbosch.detekt.api.Issue import io.gitlab.arturbosch.detekt.api.Rule @@ -8,6 +7,7 @@ import io.gitlab.arturbosch.detekt.api.Severity import io.gitlab.arturbosch.detekt.api.config import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution import io.gitlab.arturbosch.detekt.rules.fqNameOrNull +import org.jetbrains.kotlin.backend.jvm.ir.psiElement import org.jetbrains.kotlin.cfg.getDeclarationDescriptorIncludingConstructors import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange import org.jetbrains.kotlin.com.intellij.psi.PsiComment @@ -31,7 +31,6 @@ import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtSuperTypeCallEntry -import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.psi.KtTypeParameterList import org.jetbrains.kotlin.psi.KtTypeReference import org.jetbrains.kotlin.psi.KtValueArgument @@ -61,25 +60,50 @@ import xyz.block.ftl.Database import xyz.block.ftl.HttpIngress import xyz.block.ftl.Json import xyz.block.ftl.Method -import xyz.block.ftl.secrets.Secret -import xyz.block.ftl.v1.schema.* +import xyz.block.ftl.schemaextractor.SchemaExtractor.Companion.extractModuleName +import xyz.block.ftl.v1.schema.Array +import xyz.block.ftl.v1.schema.Config +import xyz.block.ftl.v1.schema.Data +import xyz.block.ftl.v1.schema.Decl import xyz.block.ftl.v1.schema.Enum +import xyz.block.ftl.v1.schema.EnumVariant +import xyz.block.ftl.v1.schema.Field +import xyz.block.ftl.v1.schema.IngressPathComponent +import xyz.block.ftl.v1.schema.IngressPathLiteral +import xyz.block.ftl.v1.schema.IngressPathParameter +import xyz.block.ftl.v1.schema.IntValue +import xyz.block.ftl.v1.schema.Metadata +import xyz.block.ftl.v1.schema.MetadataAlias +import xyz.block.ftl.v1.schema.MetadataCalls +import xyz.block.ftl.v1.schema.MetadataIngress +import xyz.block.ftl.v1.schema.Module +import xyz.block.ftl.v1.schema.Optional +import xyz.block.ftl.v1.schema.Position +import xyz.block.ftl.v1.schema.Ref +import xyz.block.ftl.v1.schema.Secret +import xyz.block.ftl.v1.schema.StringValue +import xyz.block.ftl.v1.schema.Type +import xyz.block.ftl.v1.schema.TypeParameter +import xyz.block.ftl.v1.schema.Unit +import xyz.block.ftl.v1.schema.Value +import xyz.block.ftl.v1.schema.Verb import java.io.File import java.io.FileOutputStream import java.nio.file.Path import java.time.OffsetDateTime import kotlin.io.path.createDirectories +import io.gitlab.arturbosch.detekt.api.Config as DetektConfig -data class ModuleData(val comments: List = emptyList(), val decls: MutableSet = mutableSetOf()) +data class ModuleData(var comments: List = emptyList(), val decls: MutableSet = mutableSetOf()) // Helpers private fun Ref.compare(module: String, name: String): Boolean = this.name == name && this.module == module @RequiresTypeResolution -class ExtractSchemaRule(config: Config) : Rule(config) { +class ExtractSchemaRule(config: DetektConfig) : Rule(config) { private val output: String by config(defaultValue = ".") - private val visited: MutableSet = mutableSetOf() private val modules: MutableMap = mutableMapOf() + private var extractor = SchemaExtractor(modules) override val issue = Issue( javaClass.simpleName, @@ -88,17 +112,22 @@ class ExtractSchemaRule(config: Config) : Rule(config) { Debt.FIVE_MINS, ) + override fun preVisit(root: KtFile) { + extractor.setBindingContext(bindingContext) + extractor.addModuleComments(root) + } + override fun visitAnnotationEntry(annotationEntry: KtAnnotationEntry) { if ( bindingContext.get( BindingContext.ANNOTATION, annotationEntry - )?.fqName?.asString() != xyz.block.ftl.Verb::class.qualifiedName + )?.fqName?.asString() != xyz.block.ftl.Export::class.qualifiedName ) { return } - // Skip if the verb is annotated with @Ignore + // Skip if annotated with @Ignore if ( annotationEntry.containingNonLocalDeclaration()!!.annotationEntries.any { bindingContext.get( @@ -110,41 +139,44 @@ class ExtractSchemaRule(config: Config) : Rule(config) { return } - runCatching { - val file = annotationEntry.containingKtFile - if (!visited.contains(file)) { - SchemaExtractor(this.bindingContext, modules, file).extract() - visited.add(file) + when (val element = annotationEntry.parent.parent) { + is KtNamedFunction -> extractor.addVerbToSchema(element) + is KtClass -> { + when { + element.isData() -> extractor.addDataToSchema(element) + element.isEnum() -> extractor.addEnumToSchema(element) + } } - }.onFailure { - throw it + } + } + + override fun visitProperty(property: KtProperty) { + when (property.getDeclarationDescriptorIncludingConstructors(bindingContext)?.referencedProperty?.returnType + ?.fqNameOrNull()?.asString()) { + Database::class.qualifiedName -> extractor.addDatabaseToSchema(property) + xyz.block.ftl.secrets.Secret::class.qualifiedName -> extractor.addSecretToSchema(property) + xyz.block.ftl.config.Config::class.qualifiedName -> extractor.addConfigToSchema(property) } } override fun postVisit(root: KtFile) { - modules.toModules().ifNotEmpty { - require(modules.size == 1) { - "Each FTL module must define its own pom.xml; cannot be shared across" + - " multiple modules" - } - val outputDirectory = File(output).also { Path.of(it.absolutePath).createDirectories() } + val moduleName = root.extractModuleName() + modules[moduleName]?.let { + val module = it.toModule(moduleName) + val outputDirectory = File(output).also { f -> Path.of(f.absolutePath).createDirectories() } val file = File(outputDirectory.absolutePath, OUTPUT_FILENAME) file.createNewFile() val os = FileOutputStream(file) - os.write(this.single().encode()) + os.write(module.encode()) os.close() } } - private fun Map.toModules(): List { - return this.map { - xyz.block.ftl.v1.schema.Module( - name = it.key, - decls = it.value.decls.sortedBy { it.data_ == null }, - comments = it.value.comments - ) - } - } + private fun ModuleData.toModule(moduleName: String): Module = Module( + name = moduleName, + decls = this.decls.sortedBy { it.data_ == null }, + comments = this.comments + ) companion object { const val OUTPUT_FILENAME = "schema.pb" @@ -152,52 +184,89 @@ class ExtractSchemaRule(config: Config) : Rule(config) { } class SchemaExtractor( - private val bindingContext: BindingContext, private val modules: MutableMap, - private val file: KtFile ) { - private val currentModuleName = file.packageFqName.extractModuleName() - fun extract() { - val moduleComments = file.children + private var bindingContext = BindingContext.EMPTY + + fun setBindingContext(bindingContext: BindingContext) { + this.bindingContext = bindingContext + } + + fun addModuleComments(file: KtFile) { + val module = file.extractModuleName() + val comments = file.children .filterIsInstance() .flatMap { it.text.normalizeFromDocComment() } + modules[module]?.let { it.comments = comments } ?: run { + modules[module] = ModuleData(comments = comments) + } + } - val moduleData = ModuleData( - decls = mutableSetOf( - *extractVerbs().toTypedArray(), - *extractDataDeclarations().toTypedArray(), - *extractDatabases().toTypedArray(), - *extractEnums().toTypedArray(), - *extractConfigs().toTypedArray(), - *extractSecrets().toTypedArray(), - ), - comments = moduleComments - ) - modules[currentModuleName]?.decls?.addAll(moduleData.decls) ?: run { - modules[currentModuleName] = moduleData + fun addVerbToSchema(verb: KtNamedFunction) { + validateVerb(verb) + addDecl(verb.extractModuleName(), Decl(verb = extractVerb(verb))) + } + + fun addDataToSchema(data: KtClass) { + addDecl(data.extractModuleName(), Decl(data_ = data.toSchemaData())) + } + + fun addEnumToSchema(enum: KtClass) { + addDecl(enum.extractModuleName(), Decl(enum_ = enum.toSchemaEnum())) + } + + fun addConfigToSchema(config: KtProperty) { + extractSecretOrConfig(config).let { + val decl = Decl( + config = Config( + pos = it.position, + name = it.name, + type = it.type + ) + ) + + addDecl(config.extractModuleName(), decl) } } - 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 - } + fun addSecretToSchema(secret: KtProperty) { + extractSecretOrConfig(secret).let { + val decl = Decl( + secret = Secret( + pos = it.position, + name = it.name, + type = it.type + ) + ) + + addDecl(secret.extractModuleName(), decl) + } + } + + fun addDatabaseToSchema(database: KtProperty) { + val decl = database.children.single().let { + val sourcePos = it.getPosition() + val dbName = (it as? KtCallExpression).getResolvedCall(bindingContext)?.valueArguments?.entries?.single { e -> + e.key.name.asString() == "name" } + ?.value?.toString() + ?.trim('"') + requireNotNull(dbName) { "$sourcePos $dbName Could not extract database name" } + + Decl( + database = xyz.block.ftl.v1.schema.Database( + pos = sourcePos, + name = dbName + ) + ) + } + addDecl(database.extractModuleName(), decl) + } + + private fun addDecl(module: String, decl: Decl) { + modules[module]?.decls?.add(decl) ?: run { + modules[module] = ModuleData(decls = mutableSetOf(decl)) } - return verbs.map { - validateVerb(it) - Decl(verb = extractVerb(it)) - }.toSet() } private fun validateVerb(verb: KtNamedFunction) { @@ -206,7 +275,7 @@ class SchemaExtractor( "Verbs must be defined in a package" }.let { fqName -> require(fqName.split(".").let { it.size >= 2 && it.first() == "ftl" }) { - "$verbSourcePos Expected @Verb to be in package ftl., but was $fqName" + "$verbSourcePos Expected exported function to be in package ftl., but was $fqName" } // Validate parameters @@ -244,14 +313,14 @@ class SchemaExtractor( val verbSourcePos = verb.getLineAndColumn() val requestRef = verb.valueParameters.takeIf { it.size > 1 }?.last()?.let { - val position = it.getLineAndColumn().toPosition() + val position = it.getPosition() return@let it.typeReference?.resolveType()?.toSchemaType(position) } ?: Type(unit = xyz.block.ftl.v1.schema.Unit()) val returnRef = verb.createTypeBindingForReturnType(bindingContext)?.let { - val position = it.psiElement.getLineAndColumn().toPosition() + val position = it.psiElement.getPosition() return@let it.type.toSchemaType(position) - } ?: Type(unit = xyz.block.ftl.v1.schema.Unit()) + } ?: Type(unit = Unit()) val metadata = mutableListOf() extractIngress(verb, requestRef, returnRef)?.apply { metadata.add(Metadata(ingress = this)) } @@ -266,114 +335,38 @@ class SchemaExtractor( ) } - private fun extractDatabases(): Set { - return file.declarations - .filter { - (it as? KtProperty) - ?.getDeclarationDescriptorIncludingConstructors(bindingContext)?.referencedProperty?.returnType - ?.fqNameOrNull()?.asString() == Database::class.qualifiedName - } - .flatMap { it.children.asSequence() } - .map { - val sourcePos = it.getLineAndColumn() - val dbName = (it as? KtCallExpression).getResolvedCall(bindingContext)?.valueArguments?.entries?.single { e -> - e.key.name.asString() == "name" - } - ?.value?.toString() - ?.trim('"') - requireNotNull(dbName) { "$sourcePos $dbName Could not extract database name" } - - Decl( - database = xyz.block.ftl.v1.schema.Database( - pos = sourcePos.toPosition(), - name = dbName - ) - ) - } - .toSet() - } - - private fun extractConfigs(): Set { - return extractSecretsOrConfigs(xyz.block.ftl.config.Config::class.qualifiedName!!).map { - Decl( - config = Config( - pos = it.position, - name = it.name, - type = it.type - ) - ) - }.toSet() - } - - private fun extractSecrets(): Set { - return extractSecretsOrConfigs(Secret::class.qualifiedName!!).map { - Decl( - secret = Secret( - pos = it.position, - name = it.name, - type = it.type - ) - ) - }.toSet() - } - data class SecretConfigData(val name: String, val type: Type, val position: Position) - private fun extractSecretsOrConfigs(qualifiedPropertyName: String): List { - return file.declarations - .filter { - (it as? KtProperty) - ?.getDeclarationDescriptorIncludingConstructors(bindingContext)?.referencedProperty?.returnType - ?.fqNameOrNull()?.asString() == qualifiedPropertyName - }.flatMap { it.children.asSequence() } - .map { - val position = it.getLineAndColumn().toPosition() - var type: KotlinType? = null - var name = "" - when (it) { - is KtCallExpression -> { - it.getResolvedCall(bindingContext)?.valueArguments?.entries?.forEach { arg -> - if (arg.key.name.asString() == "name") { - name = arg.value.toString().trim('"') - } else if (arg.key.name.asString() == "cls") { - type = (arg.key.varargElementType ?: arg.key.type).arguments.single().type - } - } - } - - is KtDotQualifiedExpression -> { - it.getResolvedCall(bindingContext)?.let { call -> - name = call.valueArguments.entries.single().value.toString().trim('"') - type = call.typeArguments.values.single() + private fun extractSecretOrConfig(property: KtProperty): SecretConfigData { + return property.children.single().let { + val position = it.getPosition() + var type: KotlinType? = null + var name = "" + when (it) { + is KtCallExpression -> { + it.getResolvedCall(bindingContext)?.valueArguments?.entries?.forEach { arg -> + if (arg.key.name.asString() == "name") { + name = arg.value.toString().trim('"') + } else if (arg.key.name.asString() == "cls") { + type = (arg.key.varargElementType ?: arg.key.type).arguments.single().type } } + } - else -> { - throw IllegalArgumentException("$position: Could not extract secret or config") + is KtDotQualifiedExpression -> { + it.getResolvedCall(bindingContext)?.let { call -> + name = call.valueArguments.entries.single().value.toString().trim('"') + type = call.typeArguments.values.single() } } - SecretConfigData(name, type!!.toSchemaType(position), position) - } - } - - private fun extractDataDeclarations(): Set { - return file.children - .filter { (it is KtClass && it.isData()) || it is KtTypeAlias } - .mapNotNull { - val data = (it as? KtClass)?.toSchemaData() ?: (it as? KtTypeAlias)?.toSchemaData() - data?.let { Decl(data_ = data) } + else -> { + throw IllegalArgumentException("$position: Could not extract secret or config") + } } - .toSet() - } - private fun extractEnums(): Set { - return file.children - .filter { it is KtClass && it.isEnum() } - .mapNotNull { - (it as? KtClass)?.toSchemaEnum()?.let { enum -> Decl(enum_ = enum) } - } - .toSet() + SecretConfigData(name, type!!.toSchemaType(position), position) + } } private fun extractIngress(verb: KtNamedFunction, requestType: Type, responseType: Type): MetadataIngress? { @@ -418,7 +411,7 @@ class SchemaExtractor( type = "http", path = pathArg, method = methodArg, - pos = sourcePos.toPosition(), + pos = sourcePos.toPosition(verb.containingFile.name), ) } } @@ -482,19 +475,19 @@ class SchemaExtractor( ?: import.importedFqName?.asString()?.split(".")?.last()) == verbCall }?.let { import -> val moduleRefName = import.importedFqName?.asString()?.extractModuleName() - .takeIf { refModule -> refModule != currentModuleName } + .takeIf { refModule -> refModule != element.extractModuleName() } Ref( 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 { + element.containingFile.children.singleOrNull { (it is KtNamedFunction) && it.name == verbCall && it.annotationEntries.any { bindingContext.get( BindingContext.ANNOTATION, it - )?.fqName?.asString() == xyz.block.ftl.Verb::class.qualifiedName + )?.fqName?.asString() == xyz.block.ftl.Export::class.qualifiedName } } ?: throw IllegalArgumentException( "Error processing function defined at $funcSourcePos: Could not resolve outgoing verb call" @@ -502,7 +495,7 @@ class SchemaExtractor( Ref( name = verbCall, - module = currentModuleName, + module = element.extractModuleName(), ) } } @@ -518,14 +511,6 @@ class SchemaExtractor( } } - private fun KtTypeAlias.toSchemaData(): Data { - return Data( - name = this.name!!, - comments = this.comments(), - pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(), - ) - } - private fun KtClass.toSchemaData(): Data { return Data( name = this.name!!, @@ -539,9 +524,7 @@ class SchemaExtractor( Field( name = param.name!!, type = param.typeReference?.let { - return@let it.resolveType().toSchemaType( - getLineAndColumnInPsiFile(it.containingFile, it.textRange).toPosition() - ) + return@let it.resolveType().toSchemaType(it.getPosition()) }, metadata = metadata, ) @@ -550,17 +533,17 @@ class SchemaExtractor( typeParameters = this.children.flatMap { (it as? KtTypeParameterList)?.parameters ?: emptyList() }.map { TypeParameter( name = it.name!!, - pos = getLineAndColumnInPsiFile(it.containingFile, it.textRange).toPosition(), + pos = getLineAndColumnInPsiFile(it.containingFile, it.textRange).toPosition(this.containingFile.name), ) }.toList(), - pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(), + pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(this.containingFile.name), ) } private fun KtClass.toSchemaEnum(): Enum { val variants: List require(this.getValueParameters().isEmpty() || this.getValueParameters().size == 1) { - "${this.getLineAndColumn().toPosition()}: Enums can have at most one value parameter, of type string or number" + "${this.getLineAndColumn()}: Enums can have at most one value parameter, of type string or number" } if (this.getValueParameters().isEmpty()) { @@ -576,7 +559,7 @@ class SchemaExtractor( } } else { variants = this.declarations.filterIsInstance().map { entry -> - val pos: Position = entry.getLineAndColumn().toPosition() + val pos: Position = entry.getPosition() val name: String = entry.name!! val arg: ValueArgument = entry.initializerList?.initializers?.single().let { (it as KtSuperTypeCallEntry).valueArguments.single() @@ -608,7 +591,7 @@ class SchemaExtractor( name = this.name!!, variants = variants, comments = this.comments(), - pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(), + pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(this.containingFile.name), ) } @@ -627,6 +610,7 @@ class SchemaExtractor( type.isSubtypeOf(builtIns.stringType) -> Type(string = xyz.block.ftl.v1.schema.String()) type.isSubtypeOf(builtIns.intType) -> Type(int = xyz.block.ftl.v1.schema.Int()) type.isSubtypeOf(builtIns.longType) -> Type(int = xyz.block.ftl.v1.schema.Int()) + type.isSubtypeOf(builtIns.floatType) -> Type(float = xyz.block.ftl.v1.schema.Float()) type.isSubtypeOf(builtIns.doubleType) -> Type(float = xyz.block.ftl.v1.schema.Float()) type.isSubtypeOf(builtIns.booleanType) -> Type(bool = xyz.block.ftl.v1.schema.Bool()) type.isSubtypeOf(builtIns.unitType) -> Type(unit = xyz.block.ftl.v1.schema.Unit()) @@ -656,9 +640,10 @@ class SchemaExtractor( ?.asString() == OffsetDateTime::class.qualifiedName -> Type(time = xyz.block.ftl.v1.schema.Time()) else -> { + val descriptor = this.toClassDescriptor() require( - this.toClassDescriptor().isData - || this.toClassDescriptor().kind == ClassKind.ENUM_CLASS + descriptor.isData + || descriptor.kind == ClassKind.ENUM_CLASS || this.isEmptyBuiltin() ) { "(${position.line},${position.column}) Expected type to be a data class or builtin.Empty, but was ${ @@ -666,13 +651,22 @@ class SchemaExtractor( }" } - val refName = this.toClassDescriptor().name.asString() + val refName = descriptor.name.asString() val fqName = this.fqNameOrNull()!!.asString() require(fqName.startsWith("ftl.")) { "(${position.line},${position.column}) Expected module name to be in the form ftl., " + "but was ${this.fqNameOrNull()?.asString()}" } + // add all referenced types to the schema + // TODO: remove once we require explicit exporting of types + (descriptor.psiElement as? KtClass)?.let { + when { + it.isData() -> addDecl(it.extractModuleName(), Decl(data_ = it.toSchemaData())) + it.isEnum() -> addDecl(it.extractModuleName(), Decl(enum_ = it.toSchemaEnum())) + } + } + Type( ref = Ref( name = refName, @@ -696,17 +690,20 @@ 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 KtElement.getPosition() = this.getLineAndColumn().toPosition(this.containingFile.name) + + private fun PsiElement.getPosition() = this.getLineAndColumn().toPosition(this.containingFile.name) 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) } @@ -715,6 +712,10 @@ class SchemaExtractor( this.unwrap().constructor.declarationDescriptor as? ClassDescriptor ?: throw IllegalStateException("Could not resolve KotlinType to class") + fun KtElement.extractModuleName(): String { + return this.containingKtFile.packageFqName.extractModuleName() + } + fun FqName.extractModuleName(): String { return this.asString().extractModuleName() } 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 af9dfdfd37..fe185e554e 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 @@ -66,7 +66,7 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { import xyz.block.ftl.HttpIngress import xyz.block.ftl.Method import xyz.block.ftl.Module - import xyz.block.ftl.Verb + import xyz.block.ftl.Export class InvalidInput(val field: String) : Exception() @@ -89,7 +89,7 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { * Echoes the given message. */ @Throws(InvalidInput::class) - @Verb + @Export @HttpIngress(Method.GET, "/echo") fun echo(context: Context, req: HttpRequest>): HttpResponse { callTime(context) @@ -101,7 +101,7 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { ) } - @Verb + @Export fun empty(context: Context, req: Empty): Empty { return builtin.Empty() } @@ -114,13 +114,13 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { return context.call(::verb, builtin.Empty()) } - @Verb + @Export fun sink(context: Context, req: Empty) {} - @Verb + @Export fun source(context: Context): Empty {} - @Verb + @Export fun emptyVerb(context: Context) {} """ ExtractSchemaRule(Config.empty).compileAndLintWithContext(env, code) @@ -131,6 +131,32 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { name = "echo", comments = listOf("Echo module."), decls = listOf( + Decl( + data_ = Data( + name = "EchoRequest", + fields = listOf( + Field( + name = "t", + type = Type(ref = Ref(name = "T")) + ), + Field( + name = "name", + type = Type(string = xyz.block.ftl.v1.schema.String()) + ), + Field( + name = "stuff", + type = Type(any = xyz.block.ftl.v1.schema.Any()), + metadata = listOf(Metadata(alias = MetadataAlias(alias = "stf"))), + ) + ), + comments = listOf( + "Request to echo a message.", "", "More comments." + ), + typeParameters = listOf( + TypeParameter(name = "T") + ) + ), + ), Decl( data_ = Data( name = "MapValue", @@ -171,32 +197,6 @@ internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) { ), ), ), - Decl( - data_ = Data( - name = "EchoRequest", - fields = listOf( - Field( - name = "t", - type = Type(ref = Ref(name = "T")) - ), - Field( - name = "name", - type = Type(string = xyz.block.ftl.v1.schema.String()) - ), - Field( - name = "stuff", - type = Type(any = xyz.block.ftl.v1.schema.Any()), - metadata = listOf(Metadata(alias = MetadataAlias(alias = "stf"))), - ) - ), - comments = listOf( - "Request to echo a message.", "", "More comments." - ), - typeParameters = listOf( - TypeParameter(name = "T") - ) - ), - ), Decl( data_ = Data( name = "EchoResponse", @@ -369,7 +369,7 @@ import ftl.time.TimeRequest import ftl.time.TimeResponse import xyz.block.ftl.Context import xyz.block.ftl.Method -import xyz.block.ftl.Verb +import xyz.block.ftl.Export class InvalidInput(val field: String) : Exception() @@ -385,7 +385,7 @@ data class EchoResponse(val messages: List) * Echoes the given message. */ @Throws(InvalidInput::class) -@Verb +@Export fun echo(context: Context, req: EchoRequest): EchoResponse { callTime(context) return EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) @@ -412,7 +412,7 @@ 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.Export /** * Request to echo a message. @@ -424,7 +424,7 @@ data class EchoResponse(val message: String) * Echoes the given message. */ @Throws(InvalidInput::class) -@Verb +@Export @HttpIngress(Method.GET, "/echo") fun echo(context: Context, req: EchoRequest): EchoResponse { return EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) @@ -447,7 +447,7 @@ 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.Export /** * Request to echo a message. @@ -459,7 +459,7 @@ data class EchoResponse(val message: String) * Echoes the given message. */ @Throws(InvalidInput::class) -@Verb +@Export @HttpIngress(Method.GET, "/echo") fun echo(context: Context, req: EchoRequest): EchoResponse { return EchoResponse(messages = listOf(EchoMessage(message = "Hello!"))) @@ -486,10 +486,11 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { import xyz.block.ftl.Json import xyz.block.ftl.Context import xyz.block.ftl.Method - import xyz.block.ftl.Verb + import xyz.block.ftl.Export class InvalidInput(val field: String) : Exception() + @Export enum class Thing { /** * A comment. @@ -502,6 +503,7 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { /** * Comments. */ + @Export enum class StringThing(val value: String) { /** * A comment. @@ -514,6 +516,7 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { C("C"), } + @Export enum class IntThing(val value: Int) { A(1), B(2), @@ -532,7 +535,7 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { data class Response(val message: String) - @Verb + @Export fun something(context: Context, req: Request): Response { return Response(message = "response") } @@ -578,13 +581,6 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { ), ), ), - Decl( - verb = Verb( - name = "something", - request = Type(ref = Ref(name = "Request", module = "things")), - response = Type(ref = Ref(name = "Response", module = "things")), - ), - ), Decl( enum_ = Enum( name = "Thing", @@ -624,6 +620,13 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { ), ), ), + Decl( + verb = Verb( + name = "something", + request = Type(ref = Ref(name = "Request", module = "things")), + response = Type(ref = Ref(name = "Response", module = "things")), + ), + ), ) ) @@ -644,7 +647,7 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { import xyz.block.ftl.Json import xyz.block.ftl.Context import xyz.block.ftl.Method - import xyz.block.ftl.Verb + import xyz.block.ftl.Export import xyz.block.ftl.config.Config import xyz.block.ftl.secrets.Secret @@ -660,7 +663,7 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { data class Response(val message: String) - @Verb + @Export fun something(context: Context, req: Request): Response { return Response(message = "response") } @@ -706,10 +709,16 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { ), ), Decl( - verb = Verb( - name = "something", - request = Type(ref = Ref(name = "Request", module = "test")), - response = Type(ref = Ref(name = "Response", module = "test")), + secret = xyz.block.ftl.v1.schema.Secret( + name = "secret", + type = Type(string = String()) + ), + ), + + Decl( + secret = xyz.block.ftl.v1.schema.Secret( + name = "anotherSecret", + type = Type(string = String()) ), ), Decl( @@ -725,15 +734,10 @@ fun echo(context: Context, req: EchoRequest): EchoResponse { ), ), Decl( - secret = xyz.block.ftl.v1.schema.Secret( - name = "secret", - type = Type(string = String()) - ), - ), - Decl( - secret = xyz.block.ftl.v1.schema.Secret( - name = "anotherSecret", - type = Type(string = String()) + verb = Verb( + name = "something", + request = Type(ref = Ref(name = "Request", module = "test")), + response = Type(ref = Ref(name = "Response", module = "test")), ), ), ) 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 cabba3c570..f2d94abda8 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 @@ -4,7 +4,7 @@ import ftl.builtin.Empty import xyz.block.ftl.Context import xyz.block.ftl.HttpIngress import xyz.block.ftl.Method.GET -import xyz.block.ftl.Verb +import xyz.block.ftl.Export import java.time.OffsetDateTime data class TimeResponse( @@ -20,7 +20,7 @@ enum class Color { /** * Time returns the current time. */ -@Verb +@Export @HttpIngress( GET, "/time", @@ -28,6 +28,6 @@ enum class Color { fun time(context: Context, req: Empty): TimeResponse = throw NotImplementedError("Verb stubs should not be called directly, instead use context.call(TimeModuleClient::time, ...)") -@Verb +@Export 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 index 6b7bf195d5..a372bdd3a9 100644 --- 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 @@ -8,7 +8,7 @@ import java.time.OffsetDateTime data class EchoRequest(val user: String) data class EchoResponse(val text: String) -@Verb +@Export fun echo(context: Context, req: EchoRequest): EchoResponse { val time = context.call(::time, Empty()) return EchoResponse("Hello ${req.user}, the time is ${time.time}!") @@ -18,7 +18,7 @@ data class TimeResponse(val time: OffsetDateTime) val staticTime = OffsetDateTime.now() -@Verb +@Export fun time(context: Context, req: Empty): TimeResponse { return TimeResponse(staticTime) } @@ -26,14 +26,14 @@ fun time(context: Context, req: Empty): TimeResponse { data class VerbRequest(val text: String = "") data class VerbResponse(val text: String = "") -@Verb +@Export @HttpIngress(Method.GET, "/test") fun verb(context: Context, req: VerbRequest): VerbResponse { return VerbResponse("test") } -@Verb +@Export @Ignore fun anotherVerb(context: Context, req: VerbRequest): VerbResponse { return VerbResponse("ignored") diff --git a/kotlin-runtime/scaffolding/{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt b/kotlin-runtime/scaffolding/{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt index e2a9edd71f..a248761c57 100644 --- a/kotlin-runtime/scaffolding/{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt +++ b/kotlin-runtime/scaffolding/{{ .Name | lower }}/src/main/kotlin/ftl/{{ .Name | camel | lower }}/{{ .Name | camel }}.kt @@ -2,12 +2,12 @@ package ftl.{{ .Name | camel | lower }} import xyz.block.ftl.Context import xyz.block.ftl.Method -import xyz.block.ftl.Verb +import xyz.block.ftl.Export data class EchoRequest(val name: String? = "anonymous") data class EchoResponse(val message: String) -@Verb +@Export fun echo(context: Context, req: EchoRequest): EchoResponse { return EchoResponse(message = "Hello, ${req.name}!") }