-
Notifications
You must be signed in to change notification settings - Fork 145
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
Changes from all commits
9d7c939
8db1315
19f7b60
9a50318
0b06a3b
cf57633
3f49a30
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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()) | ||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! :) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.") | ||
} |
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) | ||
} | ||
}) | ||
} | ||
} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 instancedfunction.go
. Or, if a user is creating an HTTP Rest API atapi.example.com
, perhapse they want their code inapi.go
🤷🏻