Skip to content

Commit

Permalink
feat: ftl call suggests verbs if no match found (#1516)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
mihai-chiorean and alecthomas authored May 20, 2024
1 parent 5a62849 commit f777c2b
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 8 deletions.
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 @@ -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
}

Expand Down
5 changes: 3 additions & 2 deletions backend/controller/ingress/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ingress
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
"reflect"
Expand Down Expand Up @@ -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",
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 fmt.Errorf("could not resolve reference %v: %w", ref, ErrNotFound)
}

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

"connectrpc.com/connect"
"github.com/jpillora/backoff"
"github.com/titanous/json5"

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"
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -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]
}

0 comments on commit f777c2b

Please sign in to comment.