From f777c2bd5f60fa36058e91aea2e93874cee61b83 Mon Sep 17 00:00:00 2001 From: Mihai Chiorean Date: Mon, 20 May 2024 15:57:06 -0700 Subject: [PATCH] feat: ftl call suggests verbs if no match found (#1516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://hackmd.io/@ftl/By2EEAXmR ``` ~/D/pfi ❯❯❯ ftl call "idv.GetCustomerID" ✘ 1 mihai/create_recipient_api ✭ ✱ ftl: error: verb not found, did you mean one of these: [idv.getCustomerId] ~/D/pfi ❯❯❯ ftl call "idv.getCustomerId" '{"did": "did:web:1234567"}' ✘ 1 mihai/create_recipient_api ✭ ✱ {"customer":"customer_01hy1bf79pf71b4ff5kmfmnerp"} ``` --------- Co-authored-by: Alec Thomas --- CONTRIBUTING.md | 6 ++ backend/controller/controller.go | 7 +- backend/controller/ingress/request_test.go | 5 +- backend/schema/schema.go | 13 ++- cmd/ftl/cmd_call.go | 108 ++++++++++++++++++++- 5 files changed, 131 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fb769d8c5..54bd52527f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/backend/controller/controller.go b/backend/controller/controller.go index c9db0696d3..d9748f9fb0 100644 --- a/backend/controller/controller.go +++ b/backend/controller/controller.go @@ -732,8 +732,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 } diff --git a/backend/controller/ingress/request_test.go b/backend/controller/ingress/request_test.go index b4ceb6af05..82cf9b9b6c 100644 --- a/backend/controller/ingress/request_test.go +++ b/backend/controller/ingress/request_test.go @@ -3,6 +3,7 @@ package ingress import ( "bytes" "context" + "fmt" "net/http" "net/url" "reflect" @@ -96,10 +97,10 @@ func TestBuildRequestBody(t *testing.T) { }{ {name: "UnknownVerb", verb: "unknown", - err: `could not resolve reference test.unknown`}, + err: fmt.Errorf("could not resolve reference test.unknown: %w", schema.ErrNotFound).Error()}, {name: "UnknownModule", verb: "unknown", - err: `could not resolve reference test.unknown`}, + err: fmt.Errorf("could not resolve reference test.unknown: %w", schema.ErrNotFound).Error()}, {name: "QueryParameterDecoding", verb: "getAlias", method: "GET", diff --git a/backend/schema/schema.go b/backend/schema/schema.go index d28aae2994..1ed3521be5 100644 --- a/backend/schema/schema.go +++ b/backend/schema/schema.go @@ -2,6 +2,7 @@ package schema import ( "crypto/sha256" + "errors" "fmt" "reflect" "strings" @@ -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"` @@ -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) @@ -90,7 +96,8 @@ func (s *Schema) ResolveRefToType(ref *Ref, out Decl) error { } } } - return fmt.Errorf("could not resolve reference %v", ref) + + return fmt.Errorf("could not resolve reference %v: %w", ref, ErrNotFound) } // Module returns the named module if it exists. diff --git a/cmd/ftl/cmd_call.go b/cmd/ftl/cmd_call.go index c80adc2396..904c0b92ab 100644 --- a/cmd/ftl/cmd_call.go +++ b/cmd/ftl/cmd_call.go @@ -3,8 +3,11 @@ package main import ( "context" "encoding/json" + "errors" "fmt" + "strings" "time" + "unicode/utf8" "connectrpc.com/connect" "github.com/jpillora/backoff" @@ -12,6 +15,7 @@ import ( ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" + "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/go-runtime/ftl/reflection" "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/rpc" @@ -23,12 +27,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) @@ -39,10 +44,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 } @@ -58,3 +76,91 @@ 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 := make([]*schema.Module, 0, len(res.Msg.GetSchema().GetModules())) + for _, pbmodule := range res.Msg.GetSchema().GetModules() { + module, err := schema.ModuleFromProto(pbmodule) + if err != nil { + logger.Errorf(err, "failed to convert module from protobuf") + continue + } + modules = append(modules, module) + } + verbs := []string{} + + // build a list of all the verbs + for _, module := range modules { + for _, v := range module.Verbs() { + 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] +}