diff --git a/.golangci.yml b/.golangci.yml index a7ec44c29f..76d698d3cf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -134,3 +134,4 @@ issues: - unused-parameter - "^loopclosure:" - 'shadow: declaration of "ctx" shadows declaration at' + - 'shadow: declaration of "ok" shadows declaration' diff --git a/Justfile b/Justfile index 1d5690b3a5..ca200bded5 100644 --- a/Justfile +++ b/Justfile @@ -72,4 +72,10 @@ npm-install: # Regenerate protos build-protos: npm-install - @mk {{SCHEMA_OUT}} : backend/schema -- "ftl-schema > {{SCHEMA_OUT}} && buf format -w && buf lint && cd backend/protos && buf generate" \ No newline at end of file + @mk {{SCHEMA_OUT}} : backend/schema -- "ftl-schema > {{SCHEMA_OUT}} && buf format -w && buf lint && cd backend/protos && buf generate" + +integration-tests *test: + #!/bin/bash + set -euo pipefail + testName=${1:-} + go test -fullpath -count 1 -v -tags integration -run "$testName" ./integration \ No newline at end of file diff --git a/backend/controller/ingress/alias_test.go b/backend/controller/ingress/alias_test.go index 456d4c6cc6..d435b67628 100644 --- a/backend/controller/ingress/alias_test.go +++ b/backend/controller/ingress/alias_test.go @@ -17,10 +17,10 @@ func TestTransformFromAliasedFields(t *testing.T) { data Test { scalar String +alias json "bar" - inner Inner - array [Inner] - map {String: Inner} - optional Inner + inner test.Inner + array [test.Inner] + map {String: test.Inner} + optional test.Inner } } ` @@ -77,10 +77,10 @@ func TestTransformToAliasedFields(t *testing.T) { data Test { scalar String +alias json "bar" - inner Inner - array [Inner] - map {String: Inner} - optional Inner + inner test.Inner + array [test.Inner] + map {String: test.Inner} + optional test.Inner } } ` diff --git a/backend/controller/ingress/handler_test.go b/backend/controller/ingress/handler_test.go index dfd544dbef..a782a6d758 100644 --- a/backend/controller/ingress/handler_test.go +++ b/backend/controller/ingress/handler_test.go @@ -42,16 +42,16 @@ func TestIngress(t *testing.T) { foo String } - verb getAlias(HttpRequest) HttpResponse + verb getAlias(HttpRequest) HttpResponse +ingress http GET /getAlias - verb getPath(HttpRequest) HttpResponse + verb getPath(HttpRequest) HttpResponse +ingress http GET /getPath/{username} - verb postMissingTypes(HttpRequest) HttpResponse + verb postMissingTypes(HttpRequest) HttpResponse +ingress http POST /postMissingTypes - verb postJsonPayload(HttpRequest) HttpResponse + verb postJsonPayload(HttpRequest) HttpResponse +ingress http POST /postJsonPayload } `) diff --git a/backend/controller/ingress/ingress_test.go b/backend/controller/ingress/ingress_test.go index a2417c133a..a9cc10c265 100644 --- a/backend/controller/ingress/ingress_test.go +++ b/backend/controller/ingress/ingress_test.go @@ -79,7 +79,7 @@ func TestValidation(t *testing.T) { schema: `module test { data Test { mapValue {String: String} } }`, request: obj{"mapValue": obj{"key1": "value1", "key2": "value2"}}}, {name: "DataRef", - schema: `module test { data Nested { intValue Int } data Test { dataRef Nested } }`, + schema: `module test { data Nested { intValue Int } data Test { dataRef test.Nested } }`, request: obj{"dataRef": obj{"intValue": 10.0}}}, {name: "Optional", schema: `module test { data Test { intValue Int? } }`, @@ -88,10 +88,10 @@ func TestValidation(t *testing.T) { schema: `module test { data Test { intValue Int? } }`, request: obj{"intValue": 10.0}}, {name: "ArrayDataRef", - schema: `module test { data Nested { intValue Int } data Test { arrayValue [Nested] } }`, + schema: `module test { data Nested { intValue Int } data Test { arrayValue [test.Nested] } }`, request: obj{"arrayValue": []any{obj{"intValue": 10.0}, obj{"intValue": 20.0}}}}, {name: "MapDataRef", - schema: `module test { data Nested { intValue Int } data Test { mapValue {String: Nested} } }`, + schema: `module test { data Nested { intValue Int } data Test { mapValue {String: test.Nested} } }`, request: obj{"mapValue": obj{"key1": obj{"intValue": 10.0}, "key2": obj{"intValue": 20.0}}}}, {name: "OtherModuleRef", schema: `module other { data Other { intValue Int } } module test { data Test { otherRef other.Other } }`, diff --git a/backend/controller/ingress/request_test.go b/backend/controller/ingress/request_test.go index 283b663c6a..3c7ff88971 100644 --- a/backend/controller/ingress/request_test.go +++ b/backend/controller/ingress/request_test.go @@ -68,16 +68,16 @@ func TestBuildRequestBody(t *testing.T) { foo String } - verb getAlias(HttpRequest) HttpResponse + verb getAlias(HttpRequest) HttpResponse +ingress http GET /getAlias - verb getPath(HttpRequest) HttpResponse + verb getPath(HttpRequest) HttpResponse +ingress http GET /getPath/{username} - verb postMissingTypes(HttpRequest) HttpResponse + verb postMissingTypes(HttpRequest) HttpResponse +ingress http POST /postMissingTypes - verb postJsonPayload(HttpRequest) HttpResponse + verb postJsonPayload(HttpRequest) HttpResponse +ingress http POST /postJsonPayload } `) diff --git a/backend/schema/data.go b/backend/schema/data.go index 906e67a869..a447c860b9 100644 --- a/backend/schema/data.go +++ b/backend/schema/data.go @@ -199,7 +199,7 @@ func maybeMonomorphiseType(t Type, typeParameters map[string]Type) (Type, error) if tp, ok := typeParameters[t.Name]; ok { return tp, nil } - return nil, fmt.Errorf("%s: unknown type parameter %q", t.Position(), t.Name) + return nil, fmt.Errorf("%s: unknown type parameter %q", t.Position(), t) } return t, nil } diff --git a/backend/schema/module.go b/backend/schema/module.go index fb3ce580d3..76f7eb2afe 100644 --- a/backend/schema/module.go +++ b/backend/schema/module.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + "github.com/alecthomas/types/optional" "golang.org/x/exp/maps" "google.golang.org/protobuf/proto" @@ -43,33 +44,6 @@ func (m *Module) Scan(src any) error { } } -// Scope returns a scope containing all the declarations in this module. -func (m *Module) Scope() Scope { - scope := Scope{} - for _, d := range m.Decls { - switch d := d.(type) { - case *Data: - scope[d.Name] = ModuleDecl{m, d} - - case *Verb: - scope[d.Name] = ModuleDecl{m, d} - - case *Enum: - scope[d.Name] = ModuleDecl{m, d} - - case *Config: - scope[d.Name] = ModuleDecl{m, d} - - case *Secret: - scope[d.Name] = ModuleDecl{m, d} - - case *Database: - scope[d.Name] = ModuleDecl{m, d} - } - } - return scope -} - // Resolve returns the declaration in this module with the given name, or nil func (m *Module) Resolve(ref Ref) *ModuleDecl { if ref.Module != "" && ref.Module != m.Name { @@ -77,7 +51,7 @@ func (m *Module) Resolve(ref Ref) *ModuleDecl { } for _, d := range m.Decls { if d.GetName() == ref.Name { - return &ModuleDecl{m, d} + return &ModuleDecl{optional.Some(m), d} } } return nil diff --git a/backend/schema/schema_test.go b/backend/schema/schema_test.go index c23d9c3301..9afa028f80 100644 --- a/backend/schema/schema_test.go +++ b/backend/schema/schema_test.go @@ -44,7 +44,7 @@ module todo { } verb create(todo.CreateRequest) todo.CreateResponse - +calls todo.destroy +database calls todo.testdb + +calls todo.destroy +database calls todo.testdb verb destroy(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /todo/destroy/{name} @@ -81,7 +81,7 @@ func TestImports(t *testing.T) { data Data { ref other.Data ref another.Data - ref Generic + ref test.Generic } verb myVerb(test.Data) test.Data +calls verbose.verb @@ -174,8 +174,8 @@ func TestParsing(t *testing.T) { data CreateListResponse {} // Create a new list - verb createList(todo.CreateListRequest) CreateListResponse - +calls createList + verb createList(todo.CreateListRequest) todo.CreateListResponse + +calls todo.createList } `, expected: &Schema{ @@ -305,7 +305,7 @@ func TestParsing(t *testing.T) { value T } - verb test(Data) Data + verb test(test.Data) test.Data } `, expected: &Schema{ diff --git a/backend/schema/typeresolver.go b/backend/schema/typeresolver.go index 2db6863b5c..047a64e9b2 100644 --- a/backend/schema/typeresolver.go +++ b/backend/schema/typeresolver.go @@ -2,8 +2,9 @@ package schema import ( "fmt" - "runtime/debug" "strings" + + "github.com/alecthomas/types/optional" ) // Resolver may be implemented be a node in the AST to resolve references within itself. @@ -22,7 +23,7 @@ type Scope map[string]ModuleDecl // ModuleDecl is a declaration associated with a module. type ModuleDecl struct { - Module *Module // May be nil. + Module optional.Option[*Module] Symbol Symbol } @@ -70,10 +71,14 @@ func NewScopes() Scopes { builtins := Builtins() // Empty scope tail for builtins. scopes := Scopes{primitivesScope, Scope{}} - if err := scopes.Add(nil, builtins.Name, builtins); err != nil { + if err := scopes.Add(optional.None[*Module](), builtins.Name, builtins); err != nil { panic(err) } - scopes = scopes.PushScope(builtins.Scope()) + for _, decl := range builtins.Decls { + if err := scopes.Add(optional.Some(builtins), decl.GetName(), decl); err != nil { + panic(err) + } + } // Push an empty scope for other modules to be added to. scopes = scopes.Push() return scopes @@ -111,11 +116,8 @@ func (s Scopes) Push() Scopes { } // Add a declaration to the current scope. -func (s *Scopes) Add(owner *Module, name string, symbol Symbol) error { +func (s *Scopes) Add(owner optional.Option[*Module], name string, symbol Symbol) error { end := len(*s) - 1 - if name == "destroy" { - debug.PrintStack() - } if prev, ok := (*s)[end][name]; ok { return fmt.Errorf("%s: duplicate declaration of %q at %s", symbol.Position(), name, prev.Symbol.Position()) } @@ -132,7 +134,7 @@ func (s Scopes) ResolveType(t Type) *ModuleDecl { return s.Resolve(*t) case Symbol: - return &ModuleDecl{nil, t} + return &ModuleDecl{optional.None[*Module](), t} default: return nil @@ -160,9 +162,11 @@ func (s Scopes) Resolve(ref Ref) *ModuleDecl { return decl } } else { - for _, d := range mdecl.Module.Decls { - if d.GetName() == ref.Name { - return &ModuleDecl{mdecl.Module, d} + if module, ok := mdecl.Module.Get(); ok { + for _, d := range module.Decls { + if d.GetName() == ref.Name { + return &ModuleDecl{mdecl.Module, d} + } } } } diff --git a/backend/schema/typeresolver_test.go b/backend/schema/typeresolver_test.go index 8724c5cff7..9c9820cb60 100644 --- a/backend/schema/typeresolver_test.go +++ b/backend/schema/typeresolver_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/alecthomas/assert/v2" + "github.com/alecthomas/types/optional" ) func TestTypeResolver(t *testing.T) { @@ -12,12 +13,13 @@ func TestTypeResolver(t *testing.T) { data Request { t T } - verb test(Request) Empty + verb test(test.Request) Empty } `) assert.NoError(t, err) scopes := NewScopes() - scopes = scopes.PushScope(module.Scope()) + err = scopes.Add(optional.None[*Module](), module.Name, module) + assert.NoError(t, err) // Resolve a builtin. actualInt, _ := ResolveAs[*Int](scopes, Ref{Name: "Int"}) diff --git a/backend/schema/validate.go b/backend/schema/validate.go index 54a749fb3a..ac63f1b1d6 100644 --- a/backend/schema/validate.go +++ b/backend/schema/validate.go @@ -1,3 +1,4 @@ +//nolint:nakedret package schema import ( @@ -10,6 +11,7 @@ import ( "github.com/alecthomas/participle/v2" "github.com/alecthomas/participle/v2/lexer" + "github.com/alecthomas/types/optional" xreflect "golang.design/x/reflect" "golang.org/x/exp/maps" @@ -69,7 +71,7 @@ func Validate(schema *Schema) (*Schema, error) { if module == builtins { continue } - if err := scopes.Add(nil, module.Name, module); err != nil { + if err := scopes.Add(optional.None[*Module](), module.Name, module); err != nil { merr = append(merr, err) } } @@ -103,16 +105,15 @@ func Validate(schema *Schema) (*Schema, error) { if mdecl := scopes.Resolve(*n); mdecl != nil { switch decl := mdecl.Symbol.(type) { case *Verb, *Enum, *Database, *Config, *Secret: - if mdecl.Module != nil { - n.Module = mdecl.Module.Name + if module, ok := mdecl.Module.Get(); ok { + n.Module = module.Name } if len(n.TypeParameters) != 0 { - merr = append(merr, errorf(n, "reference to %s %q cannot have type parameters", - reflect.TypeOf(decl).Elem().Name(), n.Name)) + merr = append(merr, errorf(n, "reference to %s %q cannot have type parameters", typeName(decl), n.Name)) } case *Data: - if mdecl.Module != nil { - n.Module = mdecl.Module.Name + if module, ok := mdecl.Module.Get(); ok { + n.Module = module.Name } if len(n.TypeParameters) != len(decl.TypeParameters) { merr = append(merr, errorf(n, "reference to data structure %s has %d type parameters, but %d were expected", @@ -198,9 +199,9 @@ func ValidateModule(module *Module) error { merr = append(merr, errorf(module, "module name %q is invalid", module.Name)) } if module.Builtin && module.Name != "builtin" { - merr = append(merr, errorf(module, "only the \"ftl\" module can be marked as builtin")) + merr = append(merr, errorf(module, "the \"builtin\" module is reserved")) } - if err := scopes.Add(nil, module.Name, module); err != nil { + if err := scopes.Add(optional.None[*Module](), module.Name, module); err != nil { merr = append(merr, err) } scopes = scopes.Push() @@ -212,19 +213,25 @@ func ValidateModule(module *Module) error { } switch n := n.(type) { case *Ref: + if scopes.Resolve(*n) == nil && n.Module == "" { + merr = append(merr, errorf(n, "unknown reference %q", n)) + } if mdecl := scopes.Resolve(*n); mdecl != nil { + moduleName := "" + if m, ok := mdecl.Module.Get(); ok { + moduleName = m.Name + } switch decl := mdecl.Symbol.(type) { case *Verb, *Enum, *Database, *Config, *Secret: if n.Module == "" { - n.Module = mdecl.Module.Name + n.Module = moduleName } if len(n.TypeParameters) != 0 { - merr = append(merr, errorf(n, "reference to %s %q cannot have type parameters", - reflect.TypeOf(decl).Elem().Name(), n.Name)) + merr = append(merr, errorf(n, "reference to %s %q cannot have type parameters", typeName(decl), n.Name)) } case *Data: if n.Module == "" { - n.Module = mdecl.Module.Name + n.Module = moduleName } if len(n.TypeParameters) != len(decl.TypeParameters) { merr = append(merr, errorf(n, "reference to data structure %s has %d type parameters, but %d were expected", @@ -233,9 +240,8 @@ func ValidateModule(module *Module) error { case *TypeParameter: default: if n.Module == "" { - merr = append(merr, errorf(n, "unqualified reference to invalid %s %q", reflect.TypeOf(decl).Elem().Name(), n)) + merr = append(merr, errorf(n, "unqualified reference to invalid %s %q", typeName(decl), n)) } - n.Module = mdecl.Module.Name } } else if n.Module == "" || n.Module == module.Name { // Don't report errors for external modules. merr = append(merr, errorf(n, "unknown reference %q", n)) @@ -469,30 +475,38 @@ func validateVerbMetadata(scopes Scopes, n *Verb) (merr []error) { func validateIngressRequestOrResponse(scopes Scopes, n *Verb, reqOrResp string, r Type) (fieldType Type, body Symbol, merr []error) { rref, _ := r.(*Ref) resp, sym := ResolveTypeAs[*Data](scopes, r) - if sym == nil || sym.Module == nil || sym.Module.Name != "builtin" || resp.Name != "Http"+strings.Title(reqOrResp) { + module, _ := sym.Module.Get() + if sym == nil || module == nil || module.Name != "builtin" || resp.Name != "Http"+strings.Title(reqOrResp) { merr = append(merr, errorf(r, "ingress verb %s: %s type %s must be builtin.HttpRequest", n.Name, reqOrResp, r)) - } else { - resp, err := resp.Monomorphise(rref) //nolint:govet - if err != nil { - merr = append(merr, errorf(r, "ingress verb %s: %s type %s could not be monomorphised: %v", n.Name, reqOrResp, r, err)) - } else { - scopes = scopes.PushScope(resp.Scope()) - fieldType = resp.FieldByName("body").Type - if opt, ok := fieldType.(*Optional); ok { - fieldType = opt.Type - } - bodySym := scopes.ResolveType(fieldType) - if bodySym == nil { - merr = append(merr, errorf(resp, "ingress verb %s: couldn't resolve %s body type %s", n.Name, reqOrResp, fieldType)) - } else { - body = bodySym.Symbol - switch bodySym.Symbol.(type) { - case *Bytes, *String, *Data: // Valid HTTP response payload types. - default: - merr = append(merr, errorf(r, "ingress verb %s: %s type %s must have a body of type Bytes, String or Data, not %s", n.Name, reqOrResp, r, bodySym.Symbol)) - } - } - } + return + } + + resp, err := resp.Monomorphise(rref) //nolint:govet + if err != nil { + merr = append(merr, errorf(r, "ingress verb %s: %s type %s: %v", n.Name, reqOrResp, r, err)) + return + } + + scopes = scopes.PushScope(resp.Scope()) + fieldType = resp.FieldByName("body").Type + if opt, ok := fieldType.(*Optional); ok { + fieldType = opt.Type + } + bodySym := scopes.ResolveType(fieldType) + if bodySym == nil { + merr = append(merr, errorf(resp, "ingress verb %s: couldn't resolve %s body type %s", n.Name, reqOrResp, fieldType)) + return + } + body = bodySym.Symbol + switch bodySym.Symbol.(type) { + case *Bytes, *String, *Data, *Unit, *Float, *Int, *Bool, *Map, *Array: // Valid HTTP response payload types. + default: + merr = append(merr, errorf(r, "ingress verb %s: %s type %s must have a body of bytes, string, data structure, unit, float, int, bool, map, or array not %s", n.Name, reqOrResp, r, bodySym.Symbol)) } return } + +// Give a type a human-readable name. +func typeName(v any) string { + return reflect.Indirect(reflect.ValueOf(v)).Type().Name() +} diff --git a/backend/schema/validate_test.go b/backend/schema/validate_test.go index 98a5ca963c..20da9858d1 100644 --- a/backend/schema/validate_test.go +++ b/backend/schema/validate_test.go @@ -115,17 +115,13 @@ func TestValidate(t *testing.T) { +ingress http GET /data // Invalid types. - verb int(HttpRequest) HttpResponse - +ingress http GET /int - verb bool(HttpRequest) HttpResponse - +ingress http GET /bool verb any(HttpRequest) HttpResponse +ingress http GET /any verb path(HttpRequest) HttpResponse +ingress http GET /path/{invalid} - verb pathMissing(HttpRequest) HttpResponse + verb pathMissing(HttpRequest) HttpResponse +ingress http GET /path/{missing} - verb pathFound(HttpRequest) HttpResponse + verb pathFound(HttpRequest) HttpResponse +ingress http GET /path/{parameter} data Path { @@ -134,17 +130,21 @@ func TestValidate(t *testing.T) { } `, errs: []string{ - "11:15: ingress verb int: request type HttpRequest must have a body of type Bytes, String or Data, not Int", - "11:33: ingress verb int: response type HttpResponse must have a body of type Bytes, String or Data, not Int", - "13:16: ingress verb bool: request type HttpRequest must have a body of type Bytes, String or Data, not Bool", - "13:35: ingress verb bool: response type HttpResponse must have a body of type Bytes, String or Data, not Bool", - "15:15: ingress verb any: request type HttpRequest must have a body of type Bytes, String or Data, not Any", - "15:33: ingress verb any: response type HttpResponse must have a body of type Bytes, String or Data, not Any", - "18:31: ingress verb path: cannot use path parameter \"invalid\" with request type String, expected Data type", - "20:7: duplicate http ingress GET /path/{} for 21:6:\"pathFound\" and 19:6:\"pathMissing\"", - "20:31: ingress verb pathMissing: request type Path does not contain a field corresponding to the parameter \"missing\"", - "22:7: duplicate http ingress GET /path/{} for 17:6:\"path\" and 21:6:\"pathFound\"", - }, + "11:15: ingress verb any: request type HttpRequest must have a body of bytes, string, data structure, unit, float, int, bool, map, or array not Any", + "11:33: ingress verb any: response type HttpResponse must have a body of bytes, string, data structure, unit, float, int, bool, map, or array not Any", + "14:31: ingress verb path: cannot use path parameter \"invalid\" with request type String, expected Data type", + "16:7: duplicate http ingress GET /path/{} for 17:6:\"pathFound\" and 15:6:\"pathMissing\"", + "16:31: ingress verb pathMissing: request type one.Path does not contain a field corresponding to the parameter \"missing\"", + "18:7: duplicate http ingress GET /path/{} for 13:6:\"path\" and 17:6:\"pathFound\"", + }}, + {name: "Array", + schema: ` + module one { + data Data {} + verb one(HttpRequest<[one.Data]>) HttpResponse<[one.Data], Empty> + +ingress http GET /one + } + `, }, } diff --git a/buildengine/build_go_test.go b/buildengine/build_go_test.go index ecc380dec7..3a259632ea 100644 --- a/buildengine/build_go_test.go +++ b/buildengine/build_go_test.go @@ -5,9 +5,10 @@ import ( "os" "testing" + "github.com/alecthomas/assert/v2" + "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/internal/log" - "github.com/alecthomas/assert/v2" ) func TestGenerateGoModule(t *testing.T) { @@ -179,6 +180,9 @@ func Call(context.Context, Req) (Resp, error) { } func TestExternalType(t *testing.T) { + if testing.Short() { + t.SkipNow() + } moduleDir := "testdata/projects/external" buildDir := "_ftl" diff --git a/buildengine/build_kotlin_test.go b/buildengine/build_kotlin_test.go index 3abd2d0f92..70dafe9b4a 100644 --- a/buildengine/build_kotlin_test.go +++ b/buildengine/build_kotlin_test.go @@ -6,9 +6,10 @@ import ( "os" "testing" + "github.com/alecthomas/assert/v2" + "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/internal/log" - "github.com/alecthomas/assert/v2" ) func TestGenerateBasicModule(t *testing.T) { @@ -402,6 +403,9 @@ fun nothing(context: Context): Unit = throw } func TestKotlinExternalType(t *testing.T) { + if testing.Short() { + t.SkipNow() + } moduleDir := "testdata/projects/externalkotlin" buildDir := "_ftl" diff --git a/buildengine/deploy_test.go b/buildengine/deploy_test.go index de9c0e2e18..8186e1be76 100644 --- a/buildengine/deploy_test.go +++ b/buildengine/deploy_test.go @@ -6,11 +6,12 @@ import ( "testing" "connectrpc.com/connect" + "github.com/alecthomas/assert/v2" + ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/sha256" - "github.com/alecthomas/assert/v2" ) type mockDeployClient struct { @@ -47,6 +48,9 @@ func (m *mockDeployClient) Status(context.Context, *connect.Request[ftlv1.Status } func TestDeploy(t *testing.T) { + if testing.Short() { + t.SkipNow() + } sch := &schema.Schema{ Modules: []*schema.Module{ schema.Builtins(), diff --git a/buildengine/engine_test.go b/buildengine/engine_test.go index e4252d894f..f0386ae783 100644 --- a/buildengine/engine_test.go +++ b/buildengine/engine_test.go @@ -12,6 +12,9 @@ import ( ) func TestEngine(t *testing.T) { + if testing.Short() { + t.SkipNow() + } ctx := log.ContextWithNewDefaultLogger(context.Background()) engine, err := buildengine.New(ctx, nil, []string{"testdata/projects/alpha", "testdata/projects/another"}, nil) assert.NoError(t, err) diff --git a/buildengine/watch_test.go b/buildengine/watch_test.go index da30f8a5c8..6ee08df319 100644 --- a/buildengine/watch_test.go +++ b/buildengine/watch_test.go @@ -15,6 +15,9 @@ import ( ) func TestWatch(t *testing.T) { + if testing.Short() { + t.SkipNow() + } ctx := log.ContextWithNewDefaultLogger(context.Background()) dir := t.TempDir() diff --git a/cmd/ftl/cmd_init.go b/cmd/ftl/cmd_init.go index 8000bf856c..b30187aa9c 100644 --- a/cmd/ftl/cmd_init.go +++ b/cmd/ftl/cmd_init.go @@ -102,7 +102,7 @@ func scaffold(hermit bool, source *zip.Reader, destination string, ctx any, opti } opts = append(opts, options...) if err := internal.ScaffoldZip(source, destination, ctx, opts...); err != nil { - return fmt.Errorf("%s: %w", "failed to scaffold", err) + return fmt.Errorf("failed to scaffold: %w", err) } return nil } diff --git a/examples/go/echo/echo.go b/examples/go/echo/echo.go index 175eeaca47..39f3f6fe0e 100644 --- a/examples/go/echo/echo.go +++ b/examples/go/echo/echo.go @@ -32,3 +32,16 @@ func Echo(ctx context.Context, req EchoRequest) (EchoResponse, error) { return EchoResponse{Message: fmt.Sprintf("Hello, %s!!! It is %s!", req.Name.Default(defaultName.Get(ctx)), tresp.Time)}, nil } + +/* + +verb cronJob(Unit) Unit + +cron "0 0 * * *" + +*/ + +//ftl:cron +func CronJob(ctx context.Context) error { + _, err := ftl.Call(ctx, Echo, EchoRequest{}) + return err +} diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go index fbec362da1..1c7016e3c6 100644 --- a/go-runtime/compile/schema.go +++ b/go-runtime/compile/schema.go @@ -13,6 +13,7 @@ import ( "sync" "unicode" + "github.com/alecthomas/types/optional" "golang.org/x/exp/maps" "golang.org/x/tools/go/ast/astutil" "golang.org/x/tools/go/packages" @@ -57,9 +58,15 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) { return nil, nil, fmt.Errorf("no packages found in %q, does \"go mod tidy\" need to be run?", dir) } nativeNames := NativeNames{} + // Find module name module := &schema.Module{} merr := []error{} for _, pkg := range pkgs { + moduleName, ok := ftlModuleFromGoModule(pkg.PkgPath).Get() + if !ok { + return nil, nil, fmt.Errorf("package %q is not in the ftl namespace", pkg.PkgPath) + } + module.Name = moduleName if len(pkg.Errors) > 0 { for _, perr := range pkg.Errors { if len(pkg.Syntax) > 0 { @@ -161,9 +168,9 @@ func parseCall(pctx *parseContext, node *ast.CallExpr) error { if pctx.activeVerb == nil { return nil } - moduleName := verbFn.Pkg().Name() - if moduleName == pctx.pkg.Name { - moduleName = "" + moduleName, ok := ftlModuleFromGoModule(verbFn.Pkg().Path()).Get() + if !ok { + return errorf(node.Args[1].Pos(), "call first argument must be a function in an ftl module") } ref := &schema.Ref{ Pos: goPosToSchemaPos(node.Pos()), @@ -494,6 +501,14 @@ func visitComments(doc *ast.CommentGroup) []string { return comments } +func ftlModuleFromGoModule(pkgPath string) optional.Option[string] { + parts := strings.Split(pkgPath, "/") + if parts[0] != "ftl" { + return optional.None[string]() + } + return optional.Some(strings.TrimSuffix(parts[1], "_test")) +} + func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.Ref, error) { named, ok := tnode.(*types.Named) if !ok { @@ -501,8 +516,10 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.R } nodePath := named.Obj().Pkg().Path() if !strings.HasPrefix(nodePath, pctx.pkg.PkgPath) { - base := path.Dir(pctx.pkg.PkgPath) - destModule := path.Base(strings.TrimPrefix(nodePath, base+"/")) + destModule, ok := ftlModuleFromGoModule(nodePath).Get() + if !ok { + return nil, errorf(pos, "struct declared in non-FTL module %s", nodePath) + } dataRef := &schema.Ref{ Pos: goPosToSchemaPos(pos), Module: destModule, @@ -518,7 +535,7 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.R // Fully qualify the Ref if needed if arg, okArg := typeArg.(*schema.Ref); okArg { if arg.Module == "" { - arg.Module = strings.TrimPrefix(pctx.pkg.PkgPath, base+"/") + arg.Module = destModule } typeArg = arg } @@ -533,8 +550,9 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.R } pctx.nativeNames[out] = named.Obj().Name() dataRef := &schema.Ref{ - Pos: goPosToSchemaPos(pos), - Name: out.Name, + Pos: goPosToSchemaPos(pos), + Module: pctx.module.Name, + Name: out.Name, } for i := 0; i < named.TypeParams().Len(); i++ { param := named.TypeParams().At(i) diff --git a/go-runtime/compile/schema_test.go b/go-runtime/compile/schema_test.go index 78edb16888..95bfb7bc29 100644 --- a/go-runtime/compile/schema_test.go +++ b/go-runtime/compile/schema_test.go @@ -37,6 +37,9 @@ func prebuildTestModule(t *testing.T, args ...string) { } func TestExtractModuleSchema(t *testing.T) { + if testing.Short() { + t.SkipNow() + } prebuildTestModule(t, "testdata/one", "testdata/two") _, actual, err := ExtractModuleSchema("testdata/one") @@ -120,6 +123,9 @@ func TestExtractModuleSchema(t *testing.T) { } func TestExtractModuleSchemaTwo(t *testing.T) { + if testing.Short() { + t.SkipNow() + } _, actual, err := ExtractModuleSchema("testdata/two") assert.NoError(t, err) actual = schema.Normalise(actual) @@ -136,16 +142,16 @@ func TestExtractModuleSchemaTwo(t *testing.T) { data Payload { body T } - + data User { name String } - + data UserResponse { user two.User } - - verb callsTwo(two.Payload) two.Payload + + verb callsTwo(two.Payload) two.Payload +calls two.two verb returnsUser(Unit) two.UserResponse @@ -229,6 +235,9 @@ func normaliseString(s string) string { } func TestErrorReporting(t *testing.T) { + if testing.Short() { + t.SkipNow() + } pwd, _ := os.Getwd() _, _, err := ExtractModuleSchema("testdata/failing") assert.EqualError(t, err, filepath.Join(pwd, `testdata/failing/failing.go`)+`:14:2: call must have exactly three arguments`) diff --git a/integration/integration_test.go b/integration/integration_test.go index 2c431eb4ae..8b43f24970 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -23,6 +23,7 @@ import ( "connectrpc.com/connect" "github.com/alecthomas/assert/v2" "github.com/alecthomas/repr" + "github.com/alecthomas/types/optional" _ "github.com/amacneil/dbmate/v2/pkg/driver/postgres" _ "github.com/jackc/pgx/v5/stdlib" // SQL driver @@ -38,9 +39,16 @@ import ( "github.com/TBD54566975/scaffolder" ) -const integrationTestTimeout = time.Second * 60 +var integrationTestTimeout = func() time.Duration { + timeout := optional.Zero(os.Getenv("FTL_INTEGRATION_TEST_TIMEOUT")).Default("5s") + d, err := time.ParseDuration(timeout) + if err != nil { + panic(err) + } + return d +}() -var runtimes = []string{"go", "kotlin"} +var runtimes = []string{"go" /*, "kotlin"*/} func TestLifecycle(t *testing.T) { runForRuntimes(t, func(modulesDir string, runtime string, rd runtimeData) []test { @@ -296,6 +304,7 @@ func runTests(t *testing.T, tmpDir string, tests []test) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Helper() for _, assertion := range tt.assertions { ic.assertWithRetry(t, assertion) } diff --git a/scripts/integration-tests b/scripts/integration-tests deleted file mode 100755 index 2b3b0d2a93..0000000000 --- a/scripts/integration-tests +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -euo pipefail -testName=${1:-} -go test -count 1 -v -tags integration -run "$testName" ./integration