diff --git a/backend/controller/dal/fsm_integration_test.go b/backend/controller/dal/fsm_integration_test.go index d78e210615..d52c61c083 100644 --- a/backend/controller/dal/fsm_integration_test.go +++ b/backend/controller/dal/fsm_integration_test.go @@ -26,34 +26,34 @@ func TestFSM(t *testing.T) { in.CopyModule("fsm"), in.Deploy("fsm"), - in.Call("fsm", "sendOne", in.Obj{"instance": "1"}, nil), - in.Call("fsm", "sendOne", in.Obj{"instance": "2"}, nil), + in.Call[in.Obj, in.Obj]("fsm", "sendOne", in.Obj{"instance": "1"}, nil), + in.Call[in.Obj, in.Obj]("fsm", "sendOne", in.Obj{"instance": "2"}, nil), in.FileContains(logFilePath, "start 1"), in.FileContains(logFilePath, "start 2"), fsmInState("1", "running", "fsm.start"), fsmInState("2", "running", "fsm.start"), - in.Call("fsm", "sendOne", in.Obj{"instance": "1"}, nil), + in.Call[in.Obj, in.Obj]("fsm", "sendOne", in.Obj{"instance": "1"}, nil), in.FileContains(logFilePath, "middle 1"), fsmInState("1", "running", "fsm.middle"), - in.Call("fsm", "sendOne", in.Obj{"instance": "1"}, nil), + in.Call[in.Obj, in.Obj]("fsm", "sendOne", in.Obj{"instance": "1"}, nil), in.FileContains(logFilePath, "end 1"), fsmInState("1", "completed", "fsm.end"), - in.Fail(in.Call("fsm", "sendOne", in.Obj{"instance": "1"}, nil), + in.Fail(in.Call[in.Obj, in.Obj]("fsm", "sendOne", in.Obj{"instance": "1"}, nil), "FSM instance 1 is already in state fsm.end"), // Invalid state transition - in.Fail(in.Call("fsm", "sendTwo", in.Obj{"instance": "2"}, nil), + in.Fail(in.Call[in.Obj, in.Obj]("fsm", "sendTwo", in.Obj{"instance": "2"}, nil), "invalid state transition"), - in.Call("fsm", "sendOne", in.Obj{"instance": "2"}, nil), + in.Call[in.Obj, in.Obj]("fsm", "sendOne", in.Obj{"instance": "2"}, nil), in.FileContains(logFilePath, "middle 2"), fsmInState("2", "running", "fsm.middle"), // Invalid state transition - in.Fail(in.Call("fsm", "sendTwo", in.Obj{"instance": "2"}, nil), + in.Fail(in.Call[in.Obj, in.Obj]("fsm", "sendTwo", in.Obj{"instance": "2"}, nil), "invalid state transition"), ) } @@ -86,14 +86,14 @@ func TestFSMRetry(t *testing.T) { in.Build("fsmretry"), in.Deploy("fsmretry"), // start 2 FSM instances - in.Call("fsmretry", "start", in.Obj{"id": "1"}, func(t testing.TB, response in.Obj) {}), - in.Call("fsmretry", "start", in.Obj{"id": "2"}, func(t testing.TB, response in.Obj) {}), + in.Call("fsmretry", "start", in.Obj{"id": "1"}, func(t testing.TB, response any) {}), + in.Call("fsmretry", "start", in.Obj{"id": "2"}, func(t testing.TB, response any) {}), in.Sleep(2*time.Second), // transition the FSM, should fail each time. - in.Call("fsmretry", "startTransitionToTwo", in.Obj{"id": "1"}, func(t testing.TB, response in.Obj) {}), - in.Call("fsmretry", "startTransitionToThree", in.Obj{"id": "2"}, func(t testing.TB, response in.Obj) {}), + in.Call("fsmretry", "startTransitionToTwo", in.Obj{"id": "1"}, func(t testing.TB, response any) {}), + in.Call("fsmretry", "startTransitionToThree", in.Obj{"id": "2"}, func(t testing.TB, response any) {}), in.Sleep(8*time.Second), //5s is longest run of retries diff --git a/backend/controller/ingress/ingress.go b/backend/controller/ingress/ingress.go index fbcafd7192..cd9e74af35 100644 --- a/backend/controller/ingress/ingress.go +++ b/backend/controller/ingress/ingress.go @@ -50,13 +50,12 @@ func matchSegments(pattern, urlPath string, onMatch func(segment, value string)) } func ValidateCallBody(body []byte, verb *schema.Verb, sch *schema.Schema) error { - var requestMap map[string]any - err := json.Unmarshal(body, &requestMap) + var root any + err := json.Unmarshal(body, &root) if err != nil { - return fmt.Errorf("HTTP request body is not valid JSON: %w", err) + return fmt.Errorf("request body is not valid JSON: %w", err) } - - err = schema.ValidateJSONValue(verb.Request, []string{verb.Request.String()}, requestMap, sch) + err = schema.ValidateJSONValue(verb.Request, []string{verb.Request.String()}, root, sch) if err != nil { return fmt.Errorf("could not validate HTTP request body: %w", err) } diff --git a/backend/controller/sql/database_integration_test.go b/backend/controller/sql/database_integration_test.go index 95996fa55c..57e355beb2 100644 --- a/backend/controller/sql/database_integration_test.go +++ b/backend/controller/sql/database_integration_test.go @@ -15,7 +15,7 @@ func TestDatabase(t *testing.T) { in.CopyModule("database"), in.CreateDBAction("database", "testdb", false), in.Deploy("database"), - in.Call("database", "insert", in.Obj{"data": "hello"}, nil), + in.Call[in.Obj, in.Obj]("database", "insert", in.Obj{"data": "hello"}, nil), in.QueryRow("testdb", "SELECT data FROM requests", "hello"), // run tests which should only affect "testdb_test" diff --git a/cmd/ftl/integration_test.go b/cmd/ftl/integration_test.go index 3789c89a8f..d8319535ac 100644 --- a/cmd/ftl/integration_test.go +++ b/cmd/ftl/integration_test.go @@ -28,7 +28,7 @@ func TestBox(t *testing.T) { CopyModule("echo"), Exec("ftl", "box", "echo", "--compose=echo-compose.yml"), Exec("docker", "compose", "-f", "echo-compose.yml", "up", "--wait"), - Call("echo", "echo", Obj{"name": "Alice"}, nil), + Call[Obj, Obj]("echo", "echo", Obj{"name": "Alice"}, nil), Exec("docker", "compose", "-f", "echo-compose.yml", "down", "--rmi", "local"), ) } diff --git a/go-runtime/compile/compile_integration_test.go b/go-runtime/compile/compile_integration_test.go index 35102a56a7..567eca4cc3 100644 --- a/go-runtime/compile/compile_integration_test.go +++ b/go-runtime/compile/compile_integration_test.go @@ -4,6 +4,7 @@ package compile_test import ( "testing" + "time" "github.com/alecthomas/assert/v2" @@ -47,3 +48,15 @@ func TestNonFTLTypes(t *testing.T) { }), ) } + +func TestNonStructRequestResponse(t *testing.T) { + in.Run(t, "", + in.CopyModule("two"), + in.Deploy("two"), + in.CopyModule("one"), + in.Deploy("one"), + in.Call("one", "stringToTime", "1985-04-12T23:20:50.52Z", func(t testing.TB, response time.Time) { + assert.Equal(t, time.Date(1985, 04, 12, 23, 20, 50, 520_000_000, time.UTC), response) + }), + ) +} diff --git a/go-runtime/compile/schema_test.go b/go-runtime/compile/schema_test.go index 0269551b34..d4d585f7c6 100644 --- a/go-runtime/compile/schema_test.go +++ b/go-runtime/compile/schema_test.go @@ -45,9 +45,9 @@ func TestExtractModuleSchema(t *testing.T) { if testing.Short() { t.SkipNow() } - assert.NoError(t, prebuildTestModule(t, "testdata/one", "testdata/two")) + assert.NoError(t, prebuildTestModule(t, "testdata/go/one", "testdata/go/two")) - r, err := ExtractModuleSchema("testdata/one", &schema.Schema{}) + r, err := ExtractModuleSchema("testdata/go/one", &schema.Schema{}) assert.NoError(t, err) actual := schema.Normalise(r.Module) expected := `module one { @@ -159,6 +159,8 @@ func TestExtractModuleSchema(t *testing.T) { data WithoutDirectiveStruct { } + verb batchStringToTime([String]) [Time] + export verb http(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /get @@ -168,6 +170,8 @@ func TestExtractModuleSchema(t *testing.T) { verb source(Unit) one.SourceResp + verb stringToTime(String) Time + verb verb(one.Req) one.Resp } ` @@ -179,9 +183,9 @@ func TestExtractModuleSchemaTwo(t *testing.T) { t.SkipNow() } - assert.NoError(t, prebuildTestModule(t, "testdata/two")) + assert.NoError(t, prebuildTestModule(t, "testdata/go/two")) - r, err := ExtractModuleSchema("testdata/two", &schema.Schema{}) + r, err := ExtractModuleSchema("testdata/go/two", &schema.Schema{}) assert.NoError(t, err) for _, e := range r.Errors { // only warns @@ -541,7 +545,6 @@ func TestErrorReporting(t *testing.T) { `45:1-2: must have at most two parameters (context.Context, struct)`, `45:69-69: unsupported response type "ftl/failing.Response"`, `50:22-27: first parameter must be of type context.Context but is ftl/failing.Request`, - `50:37-43: second parameter must be a struct but is string`, `50:53-53: unsupported response type "ftl/failing.Response"`, `55:43-47: second parameter must not be ftl.Unit`, `55:59-59: unsupported response type "ftl/failing.Response"`, @@ -554,7 +557,6 @@ func TestErrorReporting(t *testing.T) { `74:35-35: unsupported request type "ftl/failing.Request"`, `74:48-48: must return an error but is ftl/failing.Response`, `79:41-41: unsupported request type "ftl/failing.Request"`, - `79:55-55: first result must be a struct but is string`, `79:63-63: must return an error but is string`, `79:63-63: second result must not be ftl.Unit`, // `86:1-2: duplicate declaration of "WrongResponse" at 79:6`, TODO: fix this diff --git a/go-runtime/compile/testdata/one/ftl.toml b/go-runtime/compile/testdata/go/one/ftl.toml similarity index 100% rename from go-runtime/compile/testdata/one/ftl.toml rename to go-runtime/compile/testdata/go/one/ftl.toml diff --git a/go-runtime/compile/testdata/one/go.mod b/go-runtime/compile/testdata/go/one/go.mod similarity index 97% rename from go-runtime/compile/testdata/one/go.mod rename to go-runtime/compile/testdata/go/one/go.mod index 0bfce4765a..2e08588b16 100644 --- a/go-runtime/compile/testdata/one/go.mod +++ b/go-runtime/compile/testdata/go/one/go.mod @@ -2,7 +2,7 @@ module ftl/one go 1.22.2 -replace github.com/TBD54566975/ftl => ../../../.. +replace github.com/TBD54566975/ftl => ../../../../.. require github.com/TBD54566975/ftl v0.150.3 diff --git a/go-runtime/compile/testdata/one/go.sum b/go-runtime/compile/testdata/go/one/go.sum similarity index 100% rename from go-runtime/compile/testdata/one/go.sum rename to go-runtime/compile/testdata/go/one/go.sum diff --git a/go-runtime/compile/testdata/one/one.go b/go-runtime/compile/testdata/go/one/one.go similarity index 87% rename from go-runtime/compile/testdata/one/one.go rename to go-runtime/compile/testdata/go/one/one.go index 70ff78e0d2..9195a76633 100644 --- a/go-runtime/compile/testdata/one/one.go +++ b/go-runtime/compile/testdata/go/one/one.go @@ -182,3 +182,21 @@ type NonFTLStruct struct { } func (NonFTLStruct) NonFTLInterface() {} + +//ftl:verb +func StringToTime(ctx context.Context, input string) (time.Time, error) { + return time.Parse(time.RFC3339, input) +} + +//ftl:verb +func BatchStringToTime(ctx context.Context, input []string) ([]time.Time, error) { + var output = []time.Time{} + for _, s := range input { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil, err + } + output = append(output, t) + } + return output, nil +} diff --git a/go-runtime/compile/testdata/two/ftl.toml b/go-runtime/compile/testdata/go/two/ftl.toml similarity index 100% rename from go-runtime/compile/testdata/two/ftl.toml rename to go-runtime/compile/testdata/go/two/ftl.toml diff --git a/go-runtime/compile/testdata/two/go.mod b/go-runtime/compile/testdata/go/two/go.mod similarity index 97% rename from go-runtime/compile/testdata/two/go.mod rename to go-runtime/compile/testdata/go/two/go.mod index 4d067ca5fb..759f910538 100644 --- a/go-runtime/compile/testdata/two/go.mod +++ b/go-runtime/compile/testdata/go/two/go.mod @@ -2,7 +2,7 @@ module ftl/two go 1.22.2 -replace github.com/TBD54566975/ftl => ../../../.. +replace github.com/TBD54566975/ftl => ../../../../.. require github.com/TBD54566975/ftl v0.150.3 diff --git a/go-runtime/compile/testdata/two/go.sum b/go-runtime/compile/testdata/go/two/go.sum similarity index 100% rename from go-runtime/compile/testdata/two/go.sum rename to go-runtime/compile/testdata/go/two/go.sum diff --git a/go-runtime/compile/testdata/two/two.go b/go-runtime/compile/testdata/go/two/two.go similarity index 100% rename from go-runtime/compile/testdata/two/two.go rename to go-runtime/compile/testdata/go/two/two.go diff --git a/go-runtime/internal/integration_test.go b/go-runtime/internal/integration_test.go index 0736266cb2..9c7f07b566 100644 --- a/go-runtime/internal/integration_test.go +++ b/go-runtime/internal/integration_test.go @@ -20,7 +20,7 @@ func TestRealMap(t *testing.T) { Call("mapper", "get", Obj{}, func(t testing.TB, response Obj) { assert.Equal(t, Obj{"underlyingCounter": 2.0, "mapCounter": 1.0, "mapped": "0"}, response) }), - Call("mapper", "inc", Obj{}, nil), + Call[Obj, Obj]("mapper", "inc", Obj{}, nil), Call("mapper", "get", Obj{}, func(t testing.TB, response Obj) { assert.Equal(t, Obj{"underlyingCounter": 3.0, "mapCounter": 2.0, "mapped": "1"}, response) }), diff --git a/go-runtime/schema/verb/analyzer.go b/go-runtime/schema/verb/analyzer.go index ff91515548..fb93c52498 100644 --- a/go-runtime/schema/verb/analyzer.go +++ b/go-runtime/schema/verb/analyzer.go @@ -87,9 +87,6 @@ func checkSignature(pass *analysis.Pass, node *ast.FuncDecl, sig *types.Signatur } if params.Len() == 2 { - if !common.IsType[*types.Struct](params.At(1).Type()) { - common.TokenErrorf(pass, params.At(1).Pos(), params.At(1).Name(), "second parameter must be a struct but is %s", params.At(1).Type()) - } if params.At(1).Type().String() == common.FtlUnitTypePath { common.TokenErrorf(pass, params.At(1).Pos(), params.At(1).Name(), "second parameter must not be ftl.Unit") } @@ -106,9 +103,6 @@ func checkSignature(pass *analysis.Pass, node *ast.FuncDecl, sig *types.Signatur common.TokenErrorf(pass, results.At(results.Len()-1).Pos(), results.At(results.Len()-1).Name(), "must return an error but is %s", results.At(0).Type()) } if results.Len() == 2 { - if !common.IsType[*types.Struct](results.At(0).Type()) { - common.TokenErrorf(pass, results.At(0).Pos(), results.At(0).Name(), "first result must be a struct but is %s", results.At(0).Type()) - } if results.At(1).Type().String() == common.FtlUnitTypePath { common.TokenErrorf(pass, results.At(1).Pos(), results.At(1).Name(), "second result must not be ftl.Unit") } diff --git a/integration/actions.go b/integration/actions.go index 772ebda325..c1be523edc 100644 --- a/integration/actions.go +++ b/integration/actions.go @@ -15,6 +15,7 @@ import ( "strings" "testing" "time" + "unicode" "connectrpc.com/connect" "github.com/alecthomas/assert/v2" @@ -284,9 +285,10 @@ type Obj map[string]any // Call a verb. // // "check" may be nil -func Call(module, verb string, request Obj, check func(t testing.TB, response Obj)) Action { +func Call[Req any, Resp any](module, verb string, request Req, check func(t testing.TB, response Resp)) Action { return func(t testing.TB, ic TestContext) { Infof("Calling %s.%s", module, verb) + assert.False(t, unicode.IsUpper([]rune(verb)[0]), "verb %q must start with an lowercase letter", verb) data, err := json.Marshal(request) assert.NoError(t, err) resp, err := ic.Verbs.Call(ic, connect.NewRequest(&ftlv1.CallRequest{ @@ -294,7 +296,7 @@ func Call(module, verb string, request Obj, check func(t testing.TB, response Ob Body: data, })) assert.NoError(t, err) - var response Obj + var response Resp assert.Zero(t, resp.Msg.GetError(), "verb failed: %s", resp.Msg.GetError().GetMessage()) err = json.Unmarshal(resp.Msg.GetBody(), &response) assert.NoError(t, err)