Skip to content

Commit

Permalink
feat: add FSM API and schema extraction for Go (#1412)
Browse files Browse the repository at this point in the history
  • Loading branch information
alecthomas authored May 6, 2024
1 parent 319798a commit 20bd79e
Show file tree
Hide file tree
Showing 14 changed files with 520 additions and 97 deletions.
6 changes: 3 additions & 3 deletions backend/schema/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ func encodeMetadata(metadata []Metadata) string {
}
w := &strings.Builder{}
fmt.Fprintln(w)
for _, c := range metadata {
fmt.Fprint(w, indent(c.String()))
for _, m := range metadata {
fmt.Fprintln(w, m.String())
}
return w.String()
return strings.TrimSuffix(w.String(), "\n")
}

func encodeComments(comments []string) string {
Expand Down
2 changes: 1 addition & 1 deletion backend/schema/fsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ type FSMTransition struct {
Pos Position `parser:"" protobuf:"1,optional"`

Comments []string `parser:"@Comment*" protobuf:"2"`
From *Ref `parser:"@@" protobuf:"3"`
From *Ref `parser:"@@" protobuf:"3,optional"`
To *Ref `parser:"'to' @@" protobuf:"4"`
}

Expand Down
19 changes: 10 additions & 9 deletions backend/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ module todo {
}
export verb create(todo.CreateRequest) todo.CreateResponse
+calls todo.destroy +database calls todo.testdb
+calls todo.destroy
+database calls todo.testdb
export verb destroy(builtin.HttpRequest<todo.DestroyRequest>) builtin.HttpResponse<todo.DestroyResponse, String>
+ingress http GET /todo/destroy/{name}
Expand Down Expand Up @@ -102,13 +103,13 @@ module payments {
data OnlinePaymentPaid {
}
verb completed(payments.OnlinePaymentCompleted) builtin.Empty
verb completed(payments.OnlinePaymentCompleted) Unit
verb created(payments.OnlinePaymentCreated) builtin.Empty
verb created(payments.OnlinePaymentCreated) Unit
verb failed(payments.OnlinePaymentFailed) builtin.Empty
verb failed(payments.OnlinePaymentFailed) Unit
verb paid(payments.OnlinePaymentPaid) builtin.Empty
verb paid(payments.OnlinePaymentPaid) Unit
}
`
assert.Equal(t, normaliseString(expected), normaliseString(testSchema.String()))
Expand Down Expand Up @@ -624,19 +625,19 @@ var testSchema = MustValidate(&Schema{
&Data{Name: "OnlinePaymentCompleted"},
&Verb{Name: "created",
Request: &Ref{Module: "payments", Name: "OnlinePaymentCreated"},
Response: &Ref{Module: "builtin", Name: "Empty"},
Response: &Unit{},
},
&Verb{Name: "paid",
Request: &Ref{Module: "payments", Name: "OnlinePaymentPaid"},
Response: &Ref{Module: "builtin", Name: "Empty"},
Response: &Unit{},
},
&Verb{Name: "failed",
Request: &Ref{Module: "payments", Name: "OnlinePaymentFailed"},
Response: &Ref{Module: "builtin", Name: "Empty"},
Response: &Unit{},
},
&Verb{Name: "completed",
Request: &Ref{Module: "payments", Name: "OnlinePaymentCompleted"},
Response: &Ref{Module: "builtin", Name: "Empty"},
Response: &Unit{},
},
&FSM{
Name: "payment",
Expand Down
14 changes: 9 additions & 5 deletions backend/schema/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,20 +184,24 @@ func ValidateModuleInSchema(schema *Schema, m optional.Option[*Module]) (*Schema
if sym, decl := ResolveAs[*Verb](scopes, *start); decl == nil {
merr = append(merr, errorf(start, "unknown start verb %q", start))
} else if sym == nil {
merr = append(merr, errorf(start, "start state %q must be a verb", start))
merr = append(merr, errorf(start, "start state %q must be a sink", start))
} else if sym.Kind() != VerbKindSink {
merr = append(merr, errorf(start, "start state %q must be a sink but is %s", start, sym.Kind()))
}
}

case *FSMTransition:
if sym, decl := ResolveAs[*Verb](scopes, *n.From); decl == nil {
merr = append(merr, errorf(n, "unknown source verb %q", n.From))
merr = append(merr, errorf(n.From, "unknown source verb %q", n.From))
} else if sym == nil {
merr = append(merr, errorf(n, "source state %q is not a verb", n.From))
merr = append(merr, errorf(n.From, "source state %q is not a verb", n.From))
}
if sym, decl := ResolveAs[*Verb](scopes, *n.To); decl == nil {
merr = append(merr, errorf(n, "unknown destination verb %q", n.To))
merr = append(merr, errorf(n.To, "unknown destination verb %q", n.To))
} else if sym == nil {
merr = append(merr, errorf(n, "destination state %q is not a verb", n.To))
merr = append(merr, errorf(n.To, "destination state %q is not a sink", n.To))
} else if sym.Kind() != VerbKindSink {
merr = append(merr, errorf(n.To, "destination state %q must be a sink but is %s", n.To, sym.Kind()))
}

case *Array, *Bool, *Bytes, *Data, *Database, Decl, *Field, *Float,
Expand Down
9 changes: 5 additions & 4 deletions backend/schema/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ func TestValidate(t *testing.T) {
{name: "InvalidFSM",
schema: `
module one {
verb A(Empty) Empty
verb A(Empty) Unit
verb B(one.C) Empty
fsm FSM {
Expand All @@ -269,14 +269,15 @@ func TestValidate(t *testing.T) {
`4:13-13: unknown reference "one.C"`,
`6:6-6: "FSM" has no start states`,
`7:18-18: unknown source verb "one.C"`,
`7:27-27: destination state "one.B" must be a sink but is verb`,
},
},
{name: "DuplicateFSM",
schema: `
module one {
verb A(Empty) Empty
verb B(Empty) Empty
verb C(Empty) Empty
verb A(Empty) Unit
verb B(Empty) Unit
verb C(Empty) Unit
fsm FSM {
start one.A
Expand Down
33 changes: 33 additions & 0 deletions backend/schema/verb.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,39 @@ type Verb struct {
var _ Decl = (*Verb)(nil)
var _ Symbol = (*Verb)(nil)

// VerbKind is the kind of Verb: verb, sink, source or empty.
type VerbKind string

const (
// VerbKindVerb is a normal verb taking an input and an output of any non-unit type.
VerbKindVerb VerbKind = "verb"
// VerbKindSink is a verb that takes an input and returns unit.
VerbKindSink VerbKind = "sink"
// VerbKindSource is a verb that returns an output and takes unit.
VerbKindSource VerbKind = "source"
// VerbKindEmpty is a verb that takes unit and returns unit.
VerbKindEmpty VerbKind = "empty"
)

// Kind returns the kind of Verb this is.
func (v *Verb) Kind() VerbKind {
_, inIsUnit := v.Request.(*Unit)
_, outIsUnit := v.Response.(*Unit)
switch {
case inIsUnit && outIsUnit:
return VerbKindEmpty

case inIsUnit:
return VerbKindSource

case outIsUnit:
return VerbKindSink

default:
return VerbKindVerb
}
}

func (v *Verb) Position() Position { return v.Pos }
func (v *Verb) schemaDecl() {}
func (v *Verb) schemaSymbol() {}
Expand Down
134 changes: 112 additions & 22 deletions go-runtime/compile/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ var (
})

ftlCallFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Call"
ftlFSMFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.FSM"
ftlTransitionFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Transition"
ftlStartFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Start"
ftlConfigFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Config"
ftlSecretFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Secret" //nolint:gosec
ftlPostgresDBFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.PostgresDatabase"
Expand Down Expand Up @@ -201,56 +204,143 @@ func visitCallExpr(pctx *parseContext, node *ast.CallExpr) {
case ftlConfigFuncPath, ftlSecretFuncPath:
// Secret/config declaration: ftl.Config[<type>](<name>)
parseConfigDecl(pctx, node, fn)

case ftlFSMFuncPath:
parseFSMDecl(pctx, node)

case ftlPostgresDBFuncPath:
parseDatabaseDecl(pctx, node, schema.PostgresDatabaseType)
}
}

func parseCall(pctx *parseContext, node *ast.CallExpr) {
if pctx.activeVerb == nil {
return
}
if len(node.Args) != 3 {
pctx.errors.add(errorf(node, "call must have exactly three arguments"))
return
}
_, verbFn := deref[*types.Func](pctx.pkg, node.Args[1])
if verbFn == nil {
ref := parseVerbRef(pctx, node.Args[1])
if ref == nil {
if sel, ok := node.Args[1].(*ast.SelectorExpr); ok {
pctx.errors.add(errorf(node.Args[1], "call first argument must be a function but is an unresolved reference to %s.%s", sel.X, sel.Sel))
}
pctx.errors.add(errorf(node.Args[1], "call first argument must be a function"))
pctx.errors.add(errorf(node.Args[1], "call first argument must be a function in an ftl module"))
return
}
if pctx.activeVerb == nil {
return
pctx.activeVerb.AddCall(ref)
}

func parseVerbRef(pctx *parseContext, node ast.Expr) *schema.Ref {
_, verbFn := deref[*types.Func](pctx.pkg, node)
if verbFn == nil {
return nil
}
moduleName, ok := ftlModuleFromGoModule(verbFn.Pkg().Path()).Get()
if !ok {
pctx.errors.add(errorf(node.Args[1], "call first argument must be a function in an ftl module"))
return
return nil
}
ref := &schema.Ref{
return &schema.Ref{
Pos: goPosToSchemaPos(node.Pos()),
Module: moduleName,
Name: strcase.ToLowerCamel(verbFn.Name()),
}
pctx.activeVerb.AddCall(ref)
}

func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) {
var name string
if len(node.Args) == 1 {
if literal, ok := node.Args[0].(*ast.BasicLit); ok && literal.Kind == token.STRING {
var err error
name, err = strconv.Unquote(literal.Value)
if err != nil {
pctx.errors.add(wrapf(node, err, ""))
return
}
func parseFSMDecl(pctx *parseContext, node *ast.CallExpr) {
var literal *ast.BasicLit
if len(node.Args) > 0 {
literal, _ = node.Args[0].(*ast.BasicLit)
}
if literal == nil || literal.Kind != token.STRING {
pctx.errors.add(errorf(node, "first argument to an FSM declaration must be the name as a string literal"))
return
}

name, err := strconv.Unquote(literal.Value)
if err != nil {
panic(err) // Should never happen
}

if !schema.ValidateName(name) {
pctx.errors.add(errorf(node, "FSM names must be valid identifiers"))
}

fsm := &schema.FSM{
Pos: goPosToSchemaPos(node.Pos()),
Name: name,
}
pctx.module.Decls = append(pctx.module.Decls, fsm)

for _, arg := range node.Args[1:] {
call, ok := arg.(*ast.CallExpr)
if !ok {
pctx.errors.add(errorf(arg, "expected call to Start or Transition"))
continue
}
_, fn := deref[*types.Func](pctx.pkg, call.Fun)
if fn == nil {
pctx.errors.add(errorf(call, "expected call to Start or Transition"))
continue
}
parseFSMTransition(pctx, call, fn, fsm)
}
if name == "" {
pctx.errors.add(errorf(node, "config and secret declarations must have a single string literal argument"))
}

// Parse a Start or Transition call in an FSM declaration and add it to the FSM.
func parseFSMTransition(pctx *parseContext, node *ast.CallExpr, fn *types.Func, fsm *schema.FSM) {
refs := make([]*schema.Ref, len(node.Args))
for i, arg := range node.Args {
ref := parseVerbRef(pctx, arg)
if ref == nil {
pctx.errors.add(errorf(arg, "expected a reference to a sink"))
return
}
refs[i] = ref
}
switch fn.FullName() {
case ftlStartFuncPath:
if len(refs) != 1 {
pctx.errors.add(errorf(node, "expected one reference to a sink"))
return
}
fsm.Start = append(fsm.Start, refs...)

case ftlTransitionFuncPath:
if len(refs) != 2 {
pctx.errors.add(errorf(node, "expected two references to sinks"))
return
}
fsm.Transitions = append(fsm.Transitions, &schema.FSMTransition{
Pos: goPosToSchemaPos(node.Pos()),
From: refs[0],
To: refs[1],
})

default:
pctx.errors.add(errorf(node, "expected call to Start or Transition"))
}
}

func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) {
var literal *ast.BasicLit
if len(node.Args) > 0 {
literal, _ = node.Args[0].(*ast.BasicLit)
}
if literal == nil || literal.Kind != token.STRING {
pctx.errors.add(errorf(node, "first argument to config and secret declarations must be the name as a string literal"))
return
}
name, err := strconv.Unquote(literal.Value)
if err != nil {
panic(err) // Should never happen
}

if !schema.ValidateName(name) {
pctx.errors.add(errorf(node, "config and secret names must be valid identifiers"))
}

index := node.Fun.(*ast.IndexExpr) //nolint:forcetypeassert

// Type parameter
Expand Down Expand Up @@ -530,7 +620,7 @@ func maybeVisitTypeEnumVariant(pctx *parseContext, node *ast.GenDecl, directives
for _, dir := range directives {
if exportableDir, ok := dir.(exportable); ok {
if pctx.enums[enumName].Export && !exportableDir.IsExported() {
pctx.errors.add(errorf(node, "parent enum %q is exported, but directive %q on %q is not. All variants of exported enums that have a directive must be explicitly exported as well", enumName, exportableDir, t.Name.Name))
pctx.errors.add(errorf(node, "parent enum %q is exported, but directive %q on %q is not: all variants of exported enums that have a directive must be explicitly exported as well", enumName, exportableDir, t.Name.Name))
}
isExported = exportableDir.IsExported()
}
Expand Down
Loading

0 comments on commit 20bd79e

Please sign in to comment.