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: function signature detection #1699

Merged
merged 7 commits into from
May 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions pkg/functions/signatures.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package functions

import (
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
)

type Signature int

const (
UnknownSignature Signature = iota
InstancedHTTP
InstancedCloudevent
StaticHTTP
StaticCloudevent
)

func (s Signature) String() string {
return []string{
"unknown",
"instanced-http",
"instanced-cloudevent",
"static-http",
"static-cloudevent",
}[s]
}

var signatureMap = map[bool]map[string]Signature{
true: {
"http": InstancedHTTP,
"cloudevent": InstancedCloudevent},
false: {
"http": StaticHTTP,
"cloudevent": StaticCloudevent},
}

func signature(instanced bool, invoke string) Signature {
if invoke == "" {
invoke = "http"
}
s, ok := signatureMap[instanced][invoke]
if !ok {
return UnknownSignature
}
return s
}

// detectors check for the existence of certain method signatures in the
// source code at the given root.
type detector interface {
Detect(dir string) (static, instanced bool, err error)
}

// functionSignature returns the signature implemented by the given function
func functionSignature(f Function) (s Signature, err error) {
d, err := detectorFor(f.Runtime)
if err != nil {
return UnknownSignature, err
}
static, instanced, err := d.Detect(f.Root)
if err != nil {
return
}
// Function must implement either a static handler or the instanced handler
// but not both.
if static && instanced {
return s, fmt.Errorf("function may not implement both the static and instanced method signatures simultaneously")
} else if !static && !instanced {
return s, fmt.Errorf("function does not appear to implement any known method signatures")
} else if instanced {
return signature(true, f.Invoke), nil
} else {
return signature(false, f.Invoke), nil
}
}

// detectorFor runtime returns a signature detector for a given runtime
func detectorFor(runtime string) (detector, error) {
switch runtime {
case "go":
return &goDetector{}, nil
case "python":
return &pythonDetector{}, nil
case "rust":
return nil, errors.New("the Rust signature detector is not yet available")
case "node":
return nil, errors.New("the Node.js signature detector is not yet available")
case "quarkus":
return nil, errors.New("the TypeScript signature detector is not yet available")
default:
return nil, fmt.Errorf("unable to detect the signature of the unrecognized runtime language %q", runtime)
}
}

// GO

type goDetector struct{}

func (d goDetector) Detect(dir string) (static, instanced bool, err error) {
files, err := os.ReadDir(dir)
if err != nil {
err = fmt.Errorf("signature detector encountered an error when scanning the function's source code %w", err)
return
}
for _, file := range files {
filename := filepath.Join(dir, file.Name())
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if it could be a condition that the filename be handle.go

Copy link
Member Author

@lkingland lkingland May 10, 2023

Choose a reason for hiding this comment

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

Since most languages don't have any file name requirements, I didn't want us to impose anything.
Scanning each file in the directory looking for the expected signature should be trivially fast.
Also, I personally think that, now that we support "instanced" function signatures, the filename for those should be "function.go". So this is compatible with both templates: static handle.go and an instanced function.go. Or, if a user is creating an HTTP Rest API at api.example.com, perhapse they want their code in api.go 🤷🏻

if file.IsDir() || !strings.HasSuffix(filename, ".go") {
continue
}
if d.hasFunctionDeclaration(filename, "New") {
instanced = true
}
if d.hasFunctionDeclaration(filename, "Handle") {
static = true
}
}
return
}

func (d goDetector) hasFunctionDeclaration(filename, function string) bool {
astFile, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.SkipObjectResolution)
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if there's a golang parser for all of our runtimes! :)

Copy link
Member Author

Choose a reason for hiding this comment

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

Wouldn't that be helpful! I wouldn't be suprised if there were a parser for all of them already out there.

Fortunately, we don't need a full parser; we just need to be able to split on tokens and have enough of a state machine to know if it's inside of a block comment or not. So even if we need to write a small parser for each language ourselves; it should be fairly straightforward. But let's hope they already exist! 🤞🏻

if err != nil {
return false
}
for _, decl := range astFile.Decls {
if funcDecl, ok := decl.(*ast.FuncDecl); ok {
if funcDecl.Name.Name == function {
return true
}
}
}
return false
}

// PYTHON

type pythonDetector struct{}

func (d pythonDetector) Detect(dir string) (bool, bool, error) {
return false, false, errors.New("the Python method signature detector is not yet available.")
}
172 changes: 172 additions & 0 deletions pkg/functions/signatures_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package functions

import (
"errors"
"os"
"path/filepath"
"testing"

. "knative.dev/func/pkg/testing"
)

// TestSignature_Map ensures via spot-checking that the mappings for the
// different method signature constants are correctly associated to their
// string representation, the boolean indicator of instanced, and the
// invocation hint as defined on the function; and this association is
// traversable via the `signature` method.
func TestSignature_Map(t *testing.T) {
instanced := false
invocation := "http"
expectedName := "static-http"
expectedSig := StaticHTTP

s := signature(instanced, invocation)
if s != expectedSig {
t.Fatal("signature flags incorrectly mapped")
}
if s.String() != expectedName {
t.Fatalf("signature string representation incorrectly mapped. Expected %q got %q", expectedName, s)
}

// ensure that the default for invocation is http
if signature(true, "") != InstancedHTTP {
t.Fatalf("expected %v, got %v", InstancedHTTP, signature(true, ""))
}
}

// TestDetector_Go ensures that the go language detector will correctly
// identify the signature to expect of a function's source.
func TestDetector_Go(t *testing.T) {
// NOTE:
// The detector need only check the function's name; not the entire signature
// because invocation hint (http vs cloudevent) is available in the
// function's metadata, and the detector needs to be as simple as possible
// while fulfilling its purpose of detecting which signature is _expected_
// of the source code, not whether or not it actually does: that's the job
// of the compiler later. This detection is used to determine which
// scaffolding code needs to be written to get the user to a proper
// complile attempt.
tests := []struct {
Name string // Name of the test
Sig Signature // Signature Expected
Err error // Error Expected
Src string // Source code to check
Cfg func(Function) Function // Configure the default function for the test.
}{
{
Name: "Instanced HTTP",
Sig: InstancedHTTP,
Err: nil,
Src: `
package f

func New() { }
`},
{
Name: "Static HTTP",
Sig: StaticHTTP,
Err: nil,
Src: `
package f

func Handle() { }
`},
{
Name: "Instanced Cloudevent",
Sig: InstancedCloudevent,
Err: nil,
Cfg: func(f Function) Function {
f.Invoke = "cloudevent" // see NOTE above
return f
},
Src: `
package f
func New() { }
`},
{
Name: "Static Cloudevent",
Sig: StaticCloudevent,
Err: nil,
Cfg: func(f Function) Function {
f.Invoke = "cloudevent" // see NOTE above
return f
},
Src: `
package f
func Handle() { }
`},
{
Name: "Static and Instanced - error",
Sig: UnknownSignature,
Err: errors.New("error expected"), // TODO: typed error and err.Is/As
Src: `
package f
func Handle() { }
func New() { }
`},
{
Name: "No Signatures Found - error",
Sig: UnknownSignature,
Err: errors.New("error expected"), // TODO: typed error and err.Is/As
Src: `
package f
// Intentionally Blank
`},
{
Name: "Comments Ignored",
Sig: StaticHTTP,
Err: nil,
Src: `
package f
/*
This comment block would cause the function to be detected as instanced
without the use of the language parser.

func New()

*/
func Handle() { }
`},
}

for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {

root, cleanup := Mktemp(t)
defer cleanup()

f := Function{Runtime: "go", Root: root}
if test.Cfg != nil {
f = test.Cfg(f)
}

f, err := New().Init(f)
if err != nil {
t.Fatal(err)
}

// NOTE: if/when the default filename changes from handle.go to
// function.go, this will also have to change
if err := os.WriteFile(filepath.Join(root, "handle.go"), []byte(test.Src), os.ModePerm); err != nil {
t.Fatal(err)
}

s, err := functionSignature(f)
if err != nil && test.Err == nil {
t.Fatalf("unexpected error. %v", err)
}

if test.Err != nil {
if err == nil {
t.Fatal("expected error not received")
} else {
t.Logf("received expected error: %v", err)
}
}

if s != test.Sig {
t.Fatalf("Expected signature '%v', got '%v'", test.Sig, s)
}
})
}
}