Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ftl call suggests verbs if no match found #1516

Merged
merged 12 commits into from
May 20, 2024
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ This guide is for you.

## Development Prerequisites

We recommend that you use OrbStack instead of Docker desktop when developing on this project:
```
brew install orbstack
```
or [OrbStack Website](https://orbstack.dev/)

The tools used by this project are managed by
[Hermit](https://cashapp.github.io/hermit/), a self-bootstrapping package
installer. To activate the Hermit environment, cd into the source directory and
Expand Down
7 changes: 5 additions & 2 deletions backend/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -737,8 +737,11 @@ func (s *Service) callWithRequest(

verbRef := schema.RefFromProto(req.Msg.Verb)
verb := &schema.Verb{}
err = sch.ResolveRefToType(verbRef, verb)
if err != nil {

if err = sch.ResolveRefToType(verbRef, verb); err != nil {
if errors.Is(err, schema.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, err
}

Expand Down
3 changes: 2 additions & 1 deletion backend/controller/ingress/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ func TestBuildRequestBody(t *testing.T) {
Verb: test.verb,
}, r, sch)
if test.err != "" {
assert.EqualError(t, err, test.err)
assert.Contains(t, err.Error(), test.err)
assert.IsError(t, err, schema.ErrNotFound)
mihai-chiorean marked this conversation as resolved.
Show resolved Hide resolved
return
}
assert.NoError(t, err)
Expand Down
13 changes: 10 additions & 3 deletions backend/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package schema

import (
"crypto/sha256"
"errors"
"fmt"
"reflect"
"strings"
Expand All @@ -12,6 +13,8 @@ import (
schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema"
)

var ErrNotFound = errors.New("not found")

type Schema struct {
Pos Position `parser:"" protobuf:"1,optional"`

Expand Down Expand Up @@ -44,10 +47,13 @@ func (s *Schema) Hash() [sha256.Size]byte {
return sha256.Sum256([]byte(s.String()))
}

// ResolveRefMonomorphised -
// If a Ref is not found, returns ErrNotFound.
func (s *Schema) ResolveRefMonomorphised(ref *Ref) (*Data, error) {
out := &Data{}
err := s.ResolveRefToType(ref, out)
if err != nil {

if err := s.ResolveRefToType(ref, out); err != nil {
// If a ref is not found, returns ErrNotFound
return nil, err
}
return out.Monomorphise(ref)
Expand Down Expand Up @@ -90,7 +96,8 @@ func (s *Schema) ResolveRefToType(ref *Ref, out Decl) error {
}
}
}
return fmt.Errorf("could not resolve reference %v", ref)

return errors.Join(ErrNotFound, fmt.Errorf("could not resolve reference %v", ref))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great, but can you add this to the other Resolve* methods, and add a comment to each, and the error, mentioning that this is returned when the reference cannot be found?

Copy link
Contributor Author

@mihai-chiorean mihai-chiorean May 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveRefMonomorphised is the only one that seems relevant because it calls this. I updated it.

mihai-chiorean marked this conversation as resolved.
Show resolved Hide resolved
}

// Module returns the named module if it exists.
Expand Down
104 changes: 103 additions & 1 deletion cmd/ftl/cmd_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"

"connectrpc.com/connect"
"github.com/jpillora/backoff"
Expand All @@ -23,12 +26,13 @@ type callCmd struct {
Request string `arg:"" optional:"" help:"JSON5 request payload." default:"{}"`
}

func (c *callCmd) Run(ctx context.Context, client ftlv1connect.VerbServiceClient) error {
func (c *callCmd) Run(ctx context.Context, client ftlv1connect.VerbServiceClient, ctlCli ftlv1connect.ControllerServiceClient) error {
ctx, cancel := context.WithTimeout(ctx, c.Wait)
defer cancel()
if err := rpc.Wait(ctx, backoff.Backoff{Max: time.Second * 2}, client); err != nil {
return err
}

logger := log.FromContext(ctx)
request := map[string]any{}
err := json5.Unmarshal([]byte(c.Request), &request)
Expand All @@ -39,10 +43,23 @@ func (c *callCmd) Run(ctx context.Context, client ftlv1connect.VerbServiceClient
if err != nil {
return fmt.Errorf("invalid request: %w", err)
}

logger.Debugf("Calling %s", c.Verb)

// otherwise, we have a match so call the verb
resp, err := client.Call(ctx, connect.NewRequest(&ftlv1.CallRequest{
Verb: c.Verb.ToProto(),
Body: requestJSON,
}))

if cerr := new(connect.Error); errors.As(err, &cerr) && cerr.Code() == connect.CodeNotFound {
suggestions, err := c.findSuggestions(ctx, ctlCli)

// if we have suggestions, return a helpful error message. otherwise continue to the original error
if err == nil {
return fmt.Errorf("verb not found: %s\n\nDid you mean one of these?\n%s", c.Verb, strings.Join(suggestions, "\n"))
}
}
if err != nil {
return err
}
Expand All @@ -58,3 +75,88 @@ func (c *callCmd) Run(ctx context.Context, client ftlv1connect.VerbServiceClient
}
return nil
}

// findSuggestions looks up the schema and finds verbs that are similar to the one that was not found
// it uses the levenshtein distance to determine similarity - if the distance is less than 40% of the length of the verb,
// it returns an error if no closely matching suggestions are found
func (c *callCmd) findSuggestions(ctx context.Context, client ftlv1connect.ControllerServiceClient) ([]string, error) {
logger := log.FromContext(ctx)

// lookup the verbs
res, err := client.GetSchema(ctx, connect.NewRequest(&ftlv1.GetSchemaRequest{}))
if err != nil {
return nil, err
}

modules := res.Msg.GetSchema().GetModules()
mihai-chiorean marked this conversation as resolved.
Show resolved Hide resolved
verbs := []string{}

// build a list of all the verbs
for _, module := range modules {
for _, decl := range module.GetDecls() {
v := decl.GetVerb()
if v == nil {
continue
}

verbName := fmt.Sprintf("%s.%s", module.Name, v.Name)
if verbName == fmt.Sprintf("%s.%s", c.Verb.Module, c.Verb.Name) {
break
}

verbs = append(verbs, module.Name+"."+v.Name)
}
}

suggestions := []string{}

logger.Debugf("Found %d verbs", len(verbs))
needle := fmt.Sprintf("%s.%s", c.Verb.Module, c.Verb.Name)

// only consider suggesting verbs that are within 40% of the length of the needle
distanceThreshold := int(float64(len(needle))*0.4) + 1
for _, verb := range verbs {
d := levenshtein(verb, needle)
logger.Debugf("Verb %s distance %d", verb, d)

if d <= distanceThreshold {
suggestions = append(suggestions, verb)
}
}

if len(suggestions) > 0 {
return suggestions, nil
}

return nil, fmt.Errorf("no suggestions found")
}

// Levenshtein computes the Levenshtein distance between two strings.
//
// credit goes to https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Go
func levenshtein(a, b string) int {
f := make([]int, utf8.RuneCountInString(b)+1)

for j := range f {
f[j] = j
}

for _, ca := range a {
j := 1
fj1 := f[0] // fj1 is the value of f[j - 1] in last iteration
f[0]++
for _, cb := range b {
mn := min(f[j]+1, f[j-1]+1) // delete & insert
if cb != ca {
mn = min(mn, fj1+1) // change
} else {
mn = min(mn, fj1) // matched
}

fj1, f[j] = f[j], mn // save f[j] to fj1(j is about to increase), update f[j] to mn
j++
}
}

return f[len(f)-1]
}
Loading