diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b4f31a6..90df862 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,18 +16,14 @@ jobs: steps: - name: Check out code uses: actions/checkout@v2 - - name: Set up Go ${{ matrix.go-version }} uses: actions/setup-go@v2 with: go-version: '${{ matrix.go-version }}' - - name: Pre-fetch go dependencies and build run: 'go build ./...' - - name: Pre-install conformance test client run: 'go get github.com/GoogleCloudPlatform/functions-framework-conformance/client@v1.2.1 && go install github.com/GoogleCloudPlatform/functions-framework-conformance/client' - - name: Run HTTP conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.2.1 with: @@ -36,7 +32,6 @@ jobs: useBuildpacks: false cmd: "'go run testdata/conformance/cmd/http/main.go'" startDelay: 5 - - name: Run event conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.2.1 with: @@ -46,7 +41,6 @@ jobs: useBuildpacks: false cmd: "'go run testdata/conformance/cmd/legacyevent/main.go'" startDelay: 5 - - name: Run CloudEvent conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.2.1 with: @@ -56,3 +50,23 @@ jobs: useBuildpacks: false cmd: "'go run testdata/conformance/cmd/cloudevent/main.go'" startDelay: 5 + - name: Run HTTP conformance tests using declarative API + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.2.1 + env: + FUNCTION_TARGET: 'declarativeHTTP' + with: + version: 'v1.2.1' + functionType: 'http' + useBuildpacks: false + cmd: "'go run testdata/conformance/cmd/declarative/main.go'" + startDelay: 5 + - name: Run CloudEvent conformance tests using declarative API + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.2.1 + env: + FUNCTION_TARGET: 'declarativeCloudEvent' + with: + version: 'v1.2.1' + functionType: 'cloudevent' + useBuildpacks: false + cmd: "'go run testdata/conformance/cmd/declarative/main.go'" + startDelay: 5 diff --git a/README.md b/README.md index 7b8dac5..7bbcc30 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,37 @@ These functions are registered with the handler via `funcframework.RegisterCloud To learn more about CloudEvents, see the [Go SDK for CloudEvents](https://github.com/cloudevents/sdk-go). +### Declarative Functions + +The Functions Framework also provides a way to declaratively define `HTTP` and `CloudEvent` functions: + +```golang +package function + +import ( + "net/http" + + funcframework "github.com/GoogleCloudPlatform/functions-framework-go/funcframework" +) + +func init() { + funcframework.HTTP("hello", HelloWorld) + funcframework.CloudEvent("ce", CloudEvent) +} + +func HelloWorld(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello, World!")) +} + +func CloudEvent(ctx context.Context, e cloudevents.Event) error { + // Do something with event.Context and event.Data (via event.DataAs(foo)). + return nil +} +``` + +Upon starting, the framework will listen to HTTP requests at `/` and invoke your registered function +specified by the `FUNCTION_TARGET` environment variable (i.e. `FUNCTION_TARGET=hello`). + [ff_go_unit_img]: https://github.com/GoogleCloudPlatform/functions-framework-go/workflows/Go%20Unit%20CI/badge.svg [ff_go_unit_link]: https://github.com/GoogleCloudPlatform/functions-framework-go/actions?query=workflow%3A"Go+Unit+CI" [ff_go_lint_img]: https://github.com/GoogleCloudPlatform/functions-framework-go/workflows/Go%20Lint%20CI/badge.svg diff --git a/funcframework/framework.go b/funcframework/framework.go index 7643ac1..364154e 100644 --- a/funcframework/framework.go +++ b/funcframework/framework.go @@ -21,12 +21,14 @@ import ( "encoding/json" "fmt" "io/ioutil" + "log" "net/http" "os" "reflect" "runtime/debug" "strings" + "github.com/GoogleCloudPlatform/functions-framework-go/internal/registry" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -58,7 +60,6 @@ func RegisterHTTPFunction(path string, fn interface{}) { defer recoverPanic("Registration panic") fnHTTP, ok := fn.(func(http.ResponseWriter, *http.Request)) - if !ok { panic("expected function to have signature func(http.ResponseWriter, *http.Request)") } @@ -96,11 +97,43 @@ func RegisterCloudEventFunctionContext(ctx context.Context, path string, fn func return registerCloudEventFunction(ctx, path, fn, handler) } +// Declaratively registers a HTTP function. +func HTTP(name string, fn func(http.ResponseWriter, *http.Request)) { + if err := registry.RegisterHTTP(name, fn); err != nil { + log.Fatalf("failure to register function: %s", err) + } +} + +// Declaratively registers a CloudEvent function. +func CloudEvent(name string, fn func(context.Context, cloudevents.Event) error) { + if err := registry.RegisterCloudEvent(name, fn); err != nil { + log.Fatalf("failure to register function: %s", err) + } +} + // Start serves an HTTP server with registered function(s). func Start(port string) error { + // If FUNCTION_TARGET, try to start with that registered function + // If not set, assume non-declarative functions. + target := os.Getenv("FUNCTION_TARGET") + // Check if we have a function resource set, and if so, log progress. if os.Getenv("K_SERVICE") == "" { - fmt.Println("Serving function...") + fmt.Printf("Serving function: %s\n", target) + } + + // Check if there's a registered function, and use if possible + if fn, ok := registry.GetRegisteredFunction(target); ok { + ctx := context.Background() + if fn.HTTPFn != nil { + if err := registerHTTPFunction("/", fn.HTTPFn, handler); err != nil { + return fmt.Errorf("unexpected error in registerHTTPFunction: %v", err) + } + } else if fn.CloudEventFn != nil { + if err := registerCloudEventFunction(ctx, "/", fn.CloudEventFn, handler); err != nil { + return fmt.Errorf("unexpected error in registerCloudEventFunction: %v", err) + } + } } return http.ListenAndServe(":"+port, handler) @@ -232,3 +265,8 @@ func writeHTTPErrorResponse(w http.ResponseWriter, statusCode int, status, msg s w.WriteHeader(statusCode) fmt.Fprint(w, msg) } + +func overrideHandlerWithRegisteredFunctions(h *http.ServeMux) { + // override http handler for tests + handler = h +} diff --git a/funcframework/framework_test.go b/funcframework/framework_test.go index fd4cb88..04fae5c 100644 --- a/funcframework/framework_test.go +++ b/funcframework/framework_test.go @@ -22,8 +22,10 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "os" "testing" + "github.com/GoogleCloudPlatform/functions-framework-go/internal/registry" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/google/go-cmp/cmp" ) @@ -441,3 +443,27 @@ func TestCloudEventFunction(t *testing.T) { } } } + +func TestDeclarativeFunction(t *testing.T) { + funcName := "httpfunc" + os.Setenv("FUNCTION_TARGET", funcName) + + h := http.NewServeMux() + overrideHandlerWithRegisteredFunctions(h) + + // register functions + HTTP(funcName, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello World!") + }) + + if _, ok := registry.GetRegisteredFunction(funcName); !ok { + t.Fatalf("could not get registered function: %s", funcName) + } + + srv := httptest.NewServer(h) + defer srv.Close() + + if _, err := http.Get(srv.URL); err != nil { + t.Fatalf("could not make HTTP GET request to function: %s", err) + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..e635be4 --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,52 @@ +package registry + +import ( + "context" + "fmt" + "net/http" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +// A declaratively registered function +type RegisteredFunction struct { + Name string // The name of the function + CloudEventFn func(context.Context, cloudevents.Event) error // Optional: The user's CloudEvent function + HTTPFn func(http.ResponseWriter, *http.Request) // Optional: The user's HTTP function +} + +var ( + function_registry = map[string]RegisteredFunction{} +) + +// Registers a HTTP function with a given name +func RegisterHTTP(name string, fn func(http.ResponseWriter, *http.Request)) error { + if _, ok := function_registry[name]; ok { + return fmt.Errorf("function name already registered: %s", name) + } + function_registry[name] = RegisteredFunction{ + Name: name, + CloudEventFn: nil, + HTTPFn: fn, + } + return nil +} + +// Registers a CloudEvent function with a given name +func RegisterCloudEvent(name string, fn func(context.Context, cloudevents.Event) error) error { + if _, ok := function_registry[name]; ok { + return fmt.Errorf("function name already registered: %s", name) + } + function_registry[name] = RegisteredFunction{ + Name: name, + CloudEventFn: fn, + HTTPFn: nil, + } + return nil +} + +// Gets a registered function by name +func GetRegisteredFunction(name string) (RegisteredFunction, bool) { + fn, ok := function_registry[name] + return fn, ok +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..2b9d705 --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,83 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package registry + +import ( + "context" + "fmt" + "net/http" + "testing" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +func TestRegisterHTTP(t *testing.T) { + RegisterHTTP("httpfn", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello World!") + }) + + fn, ok := GetRegisteredFunction("httpfn") + if !ok { + t.Fatalf("Expected function to be registered") + } + if fn.Name != "httpfn" { + t.Errorf("Expected function name to be 'httpfn', got %s", fn.Name) + } +} + +func TestRegisterCE(t *testing.T) { + RegisterCloudEvent("cefn", func(context.Context, cloudevents.Event) error { + return nil + }) + + fn, ok := GetRegisteredFunction("cefn") + if !ok { + t.Fatalf("Expected function to be registered") + } + if fn.Name != "cefn" { + t.Errorf("Expected function name to be 'cefn', got %s", fn.Name) + } +} + +func TestRegisterMultipleFunctions(t *testing.T) { + if err := RegisterHTTP("multifn1", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello World!") + }); err != nil { + t.Error("Expected \"multifn1\" function to be registered") + } + if err := RegisterHTTP("multifn2", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello World 2!") + }); err != nil { + t.Error("Expected \"multifn2\" function to be registered") + } + if err := RegisterCloudEvent("multifn3", func(context.Context, cloudevents.Event) error { + return nil + }); err != nil { + t.Error("Expected \"multifn3\" function to be registered") + } +} + +func TestRegisterMultipleFunctionsError(t *testing.T) { + if err := RegisterHTTP("samename", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello World!") + }); err != nil { + t.Error("Expected no error registering function") + } + + if err := RegisterHTTP("samename", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello World 2!") + }); err == nil { + t.Error("Expected error registering function with same name") + } +} diff --git a/testdata/conformance/cmd/cloudevent/main.go b/testdata/conformance/cmd/cloudevent/main.go index 4823425..9c2d395 100644 --- a/testdata/conformance/cmd/cloudevent/main.go +++ b/testdata/conformance/cmd/cloudevent/main.go @@ -27,7 +27,7 @@ import ( func main() { ctx := context.Background() if err := funcframework.RegisterCloudEventFunctionContext(ctx, "/", function.CloudEvent); err != nil { - log.Fatalf("Failed to register function:: %v", err) + log.Fatalf("Failed to register function: %v", err) } port := "8080" diff --git a/testdata/conformance/cmd/declarative/main.go b/testdata/conformance/cmd/declarative/main.go new file mode 100644 index 0000000..3afb3b9 --- /dev/null +++ b/testdata/conformance/cmd/declarative/main.go @@ -0,0 +1,37 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Binary that serves the HTTP conformance test function. +package main + +import ( + "log" + "os" + + "github.com/GoogleCloudPlatform/functions-framework-go/funcframework" + _ "github.com/GoogleCloudPlatform/functions-framework-go/testdata/conformance/function" +) + +// Main function used for testing only: +// FUNCTION_TARGET=declarativeHTTP go run testdata/conformance/cmd/declarative/main.go +// FUNCTION_TARGET=declarativeCloudEvent go run testdata/conformance/cmd/declarative/main.go +func main() { + port := "8080" + if envPort := os.Getenv("PORT"); envPort != "" { + port = envPort + } + if err := funcframework.Start(port); err != nil { + log.Fatalf("Failed to start functions framework: %v", err) + } +} diff --git a/testdata/conformance/function/function.go b/testdata/conformance/function/function.go index 9618917..92801c4 100644 --- a/testdata/conformance/function/function.go +++ b/testdata/conformance/function/function.go @@ -24,7 +24,7 @@ import ( "net/http" "cloud.google.com/go/functions/metadata" - _ "github.com/GoogleCloudPlatform/functions-framework-go/funcframework" + "github.com/GoogleCloudPlatform/functions-framework-go/funcframework" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -69,7 +69,6 @@ func Event(ctx context.Context, data interface{}) error { } // CloudEvent is a cloud event function that dumps the event to JSON and calls the validator script -// on the result. func CloudEvent(ctx context.Context, ce cloudevents.Event) error { e, err := json.Marshal(ce) if err != nil { @@ -82,3 +81,9 @@ func CloudEvent(ctx context.Context, ce cloudevents.Event) error { return nil } + +// Register declarative functions +func init() { + funcframework.HTTP("declarativeHTTP", HTTP) + funcframework.CloudEvent("declarativeCloudEvent", CloudEvent) +}