diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfd1d83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Created by .ignore support plugin (hsz.mobi) +### Go template +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ +.idea +vendor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..146bf4b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# build stage +FROM golang:alpine AS build-env +RUN apk --no-cache add build-base git bzr mercurial gcc +ENV D=/go/src/github.com/fnproject/fdk-testkit +RUN go get -u github.com/golang/dep/cmd/dep +ADD Gopkg.* $D/ +RUN cd $D && dep ensure --vendor-only +ADD . $D +RUN cd $D && go test -c -i && cp fdk-testkit.test /tmp/ + +# final stage +FROM fnproject/dind +WORKDIR /app +COPY --from=build-env /tmp/fdk-testkit.test /app/fdk-testkit +CMD ["./fdk-testkit"] diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..2a794d1 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,129 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/PuerkitoBio/purell" + packages = ["."] + revision = "0bcb03f4b4d0a9428594752bd2a3b9aa0a9d4bd4" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/PuerkitoBio/urlesc" + packages = ["."] + revision = "de5bf2ad457846296e2031421a34e2568e304e35" + +[[projects]] + name = "github.com/asaskevich/govalidator" + packages = ["."] + revision = "521b25f4b05fd26bec69d9dedeb8f9c9a83939a8" + version = "v8" + +[[projects]] + name = "github.com/fnproject/fn_go" + packages = ["client","client/apps","client/call","client/operations","client/routes","models"] + revision = "7ce3bb2e624df60cdfbfc1ee5483f6df80bb2b1b" + version = "0.2.1" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/analysis" + packages = ["."] + revision = "2202689560ae7059c9c916c80b2f12e6c3db5b56" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/errors" + packages = ["."] + revision = "03cfca65330da08a5a440053faf994a3c682b5bf" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/jsonpointer" + packages = ["."] + revision = "779f45308c19820f1a69e9a4cd965f496e0da10f" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/jsonreference" + packages = ["."] + revision = "36d33bfe519efae5632669801b180bf1a245da3b" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/loads" + packages = ["."] + revision = "a1b9e29411cdd7cbd557a6eb905911853c684333" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/runtime" + packages = [".","client","logger","middleware","middleware/denco","middleware/header","middleware/untyped","security"] + revision = "94927f8c9742791a9aa46c6c3965f0a37025f41d" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/spec" + packages = ["."] + revision = "4a3d26106361caf1779728cd8301ad6b9534934b" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/strfmt" + packages = ["."] + revision = "610b6cacdcde6852f4de68998bd20ce1dac85b22" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/swag" + packages = ["."] + revision = "cf0bdb963811675a4d7e74901cefc7411a1df939" + +[[projects]] + branch = "master" + name = "github.com/go-openapi/validate" + packages = ["."] + revision = "d509235108fcf6ab4913d2dcb3a2260c0db2108e" + +[[projects]] + branch = "master" + name = "github.com/mailru/easyjson" + packages = ["buffer","jlexer","jwriter"] + revision = "32fa128f234d041f196a9f3e0fea5ac9772c08e1" + +[[projects]] + branch = "master" + name = "github.com/mitchellh/mapstructure" + packages = ["."] + revision = "06020f85339e21b2478f756a78e295255ffa4d6a" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = ["context","context/ctxhttp","idna"] + revision = "a8b9294777976932365dabb6640cf1468d95c70f" + +[[projects]] + branch = "master" + name = "golang.org/x/text" + packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable","width"] + revision = "57961680700a5336d15015c8c50686ca5ba362a4" + +[[projects]] + branch = "v2" + name = "gopkg.in/mgo.v2" + packages = ["bson","internal/json"] + revision = "3f83fa5005286a7fe593b055f0d7771a7dce4655" + +[[projects]] + branch = "v2" + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "287cf08546ab5e7e37d55a84f7ed3fd1db036de5" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "a51d3854deee00e32a6c24cc60618a53394bc05f568f426b00031ac61d7443ca" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..f1fa93a --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,34 @@ + +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + + +[[constraint]] + name = "github.com/fnproject/fn_go" + version = "0.2.1" + +[[constraint]] + branch = "master" + name = "github.com/go-openapi/runtime" + +[[constraint]] + branch = "master" + name = "github.com/go-openapi/strfmt" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e7fe122 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +# Just builds +.PHONY: build test + +build: + go test -c -i + +test: + go test -v ./... diff --git a/README.md b/README.md index 4aee643..b148cd6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,92 @@ -# fdk-testkit -Compatibility validation tests suite for programming language-specific Fn development kits (FDK) +Testing FDK-based functions +=========================== + +Function development kit (FDK) as a piece of software that helps to write hot functions by encapsulating logic of processing incoming requests with respect to defined protocol (HTTP, JSON). +Particular testing framework help developers to identify if any programming language-specific FDK compatible with Fn hot formats. + + +Prerequisites +------------- + +This testing framework allows to run FDK tests against live Fn service, to let tests know of where Fn service is hosted please set following environment variable: +```bash + export FN_API_URL=http://fn.io:8080 +``` + +Test suite requires general purpose programming language-specific FDK-based function image that must be developed specifically for this test suite, following environment variable must be set: +```bash + export FDK_FUNCTION_IMAGE="..." +``` +This environment variable should contain a reference to the particular docker image. + +At this moment Fn server supports 3 formats: + + - default + - http + - json + +To let developers freedom of choice test suite allows to configure list of formats to test against FDK. +By default tests will use two hot formats: `json` and `http`, it's possible to override those formats using following environment format: +```bash + export FDK_FORMATS=json,http +``` +Test suite details +------------------ + +Test suite contains following tests: + +1. `TestFDKFormatSmallBody` + +`TestFDKFormatSmallBody` test +-------------------------- + +FDK should support following formats: + + - HTTP (subtest: `test-fdk-http-small-body`) + - JSON (subtest: `test-fdk-json-small-body`) + +Request input body: +```json +{ + "name": "Jimmy" +} +``` + +Response output body: +```text +Hello Jimmy +``` + +How to run tests? +----------------- + +There are couple options: from source code, from release binary + +From source code: +```bash + go test -v ./... +``` + +How to keep test apps and routes in place? +------------------------------------------ + +By default testkit runs cleanup for all app and routes created during particular execution. +In order to keep them in place to let developers work with results test suite can be configured with following boolean environment variable: +```bash + export DISABLE_TESTKIT_CLEANUP=true +``` +`True` options: "1", "t", "T", "true", "TRUE", "True" +`False` options: "0", "f", "F", "false", "FALSE", "False" + +How to build test binary executable? +------------------------------------ + +Regular `go build` does not work with tests, so following command will create a binary executable for this particular test suite: +```bash + go test -c -i +``` + +Sample FDK-based functions +-------------------------- + +As an example test suite supplied with general purpose test [function written with FDK Python](./functions/python). diff --git a/apps_api.go b/apps_api.go new file mode 100644 index 0000000..75ed07d --- /dev/null +++ b/apps_api.go @@ -0,0 +1,109 @@ +package test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/fnproject/fn_go/client" + "github.com/fnproject/fn_go/client/apps" + "github.com/fnproject/fn_go/models" +) + +func CheckAppResponseError(t *testing.T, e error) { + if e != nil { + switch err := e.(type) { + case *apps.DeleteAppsAppNotFound: + t.Errorf("Unexpected error occurred: %v Original Location: %s", err.Payload.Error.Message, MyCaller()) + t.FailNow() + case *apps.DeleteAppsAppDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v Orig Location: %s", err.Payload.Error.Message, err.Code(), MyCaller()) + t.FailNow() + case *apps.PostAppsDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v Orig Location: %s", err.Payload.Error.Message, err.Code(), MyCaller()) + t.FailNow() + case *apps.GetAppsAppNotFound: + if !strings.Contains("App not found", err.Payload.Error.Message) { + t.Errorf("Unexpected error occurred: %v Original Location: %s", err.Payload.Error.Message, MyCaller()) + t.FailNow() + } + case *apps.GetAppsAppDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v Orig Location: %s", err.Payload.Error.Message, err.Code(), MyCaller()) + t.FailNow() + case *apps.PatchAppsAppDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v Orig Location: %s", err.Payload.Error.Message, err.Code(), MyCaller()) + t.FailNow() + case *apps.PatchAppsAppNotFound: + t.Errorf("Unexpected error occurred: %v. Original Location: %s", err.Payload.Error.Message, MyCaller()) + t.FailNow() + case *apps.PatchAppsAppBadRequest: + t.Errorf("Unexpected error occurred: %v. Original Location: %s", err.Payload.Error.Message, MyCaller()) + t.FailNow() + default: + t.Errorf("Unable to determine type of error: %s Original Location: %s", err, MyCaller()) + t.FailNow() + } + } +} + +func CreateAppNoAssert(ctx context.Context, fnclient *client.Fn, appName string, config map[string]string) (*apps.PostAppsOK, error) { + cfg := &apps.PostAppsParams{ + Body: &models.AppWrapper{ + App: &models.App{ + Config: config, + Name: appName, + }, + }, + Context: ctx, + } + ok, err := fnclient.Apps.PostApps(cfg) + if err == nil { + approutesLock.Lock() + _, got := appsandroutes[appName] + if !got { + appsandroutes[appName] = []string{} + } + approutesLock.Unlock() + } + return ok, err +} + +func CreateApp(t *testing.T, ctx context.Context, fnclient *client.Fn, appName string, config map[string]string) { + appPayload, err := CreateAppNoAssert(ctx, fnclient, appName, config) + CheckAppResponseError(t, err) + if !strings.Contains(appName, appPayload.Payload.App.Name) { + t.Errorf("App name mismatch.\nExpected: %v\nActual: %v", + appName, appPayload.Payload.App.Name) + } +} + +func DeleteApp(t *testing.T, ctx context.Context, fnclient *client.Fn, appName string) { + cfg := &apps.DeleteAppsAppParams{ + App: appName, + Context: ctx, + } + + _, err := fnclient.Apps.DeleteAppsApp(cfg) + CheckAppResponseError(t, err) +} + +func GetApp(t *testing.T, ctx context.Context, fnclient *client.Fn, appName string) *models.App { + cfg := &apps.GetAppsAppParams{ + App: appName, + Context: ctx, + } + + app, err := fnclient.Apps.GetAppsApp(cfg) + CheckAppResponseError(t, err) + return app.Payload.App +} + +func DeleteAppNoT(ctx context.Context, fnclient *client.Fn, appName string) { + cfg := &apps.DeleteAppsAppParams{ + App: appName, + Context: ctx, + } + cfg.WithTimeout(time.Second * 60) + fnclient.Apps.DeleteAppsApp(cfg) +} diff --git a/formats_test.go b/formats_test.go new file mode 100644 index 0000000..cf3e993 --- /dev/null +++ b/formats_test.go @@ -0,0 +1,192 @@ +package test + +import ( + "bytes" + "encoding/json" + "net/url" + "path" + "strconv" + "strings" + "testing" + + "context" + "fmt" + "net/http" + "os" +) + +func doRequest(t *testing.T, ctx context.Context, fnAppName, fnAppRoute string, contentType string, requestBody interface{}) (*bytes.Buffer, *http.Response, error) { + u := url.URL{ + Scheme: "http", + Host: Host(), + } + u.Path = path.Join(u.Path, "r", fnAppName, fnAppRoute) + + b, err := json.Marshal(requestBody) + if err != nil { + return nil, nil, err + } + content := bytes.NewBuffer(b) + output := &bytes.Buffer{} + + response, err := CallFN(ctx, u.String(), contentType, content, output, "POST", []string{}) + + t.Logf("[app: %v] - [route: %v] - [call ID: %v]", fnAppName, fnAppRoute, response.Header.Get("Fn_call_id")) + + if err != nil { + return nil, response, err + } + + return output, response, nil +} + +func callMultiple(times int, t *testing.T, s *SuiteSetup, fnRoute, fnImage, + fnFormat string) { + + timeout := int32(30) + idleTimeout := int32(10) + CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) + CreateRoute(t, s.Context, s.Client, s.AppName, fnRoute, fnImage, "sync", + fnFormat, timeout, idleTimeout, s.RouteConfig, s.RouteHeaders) + + for i := 1; i <= times; i++ { + requestBody := fmt.Sprintf(`{"name":"%v"}`, RandStringBytes(i)) + output, response, err := doRequest(t, s.Context, s.AppName, fnRoute, "text/plain", requestBody) + if err != nil { + t.Errorf("Got unexpected error: %v", err) + } + if response.StatusCode != http.StatusOK { + t.Logf("[app: %v] - [route: %v] - [response data : %v]", s.AppName, fnRoute, output.String()) + t.Errorf("Status code assertion error.\n\tExpected: %v\n\tActual: %v", + 200, response.StatusCode) + } + } + + DeleteApp(t, s.Context, s.Client, s.AppName) +} + +func callOnce(t *testing.T, s *SuiteSetup, fnRoute, fnImage, + fnFormat string, requestBody interface{}) (*bytes.Buffer, *http.Response, error) { + + timeout := int32(30) + idleTimeout := int32(10) + CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) + CreateRoute(t, s.Context, s.Client, s.AppName, fnRoute, fnImage, "sync", + fnFormat, timeout, idleTimeout, s.RouteConfig, s.RouteHeaders) + + output, response, err := doRequest(t, s.Context, s.AppName, fnRoute, "application/json", requestBody) + if err != nil { + return nil, response, err + } + + DeleteApp(t, s.Context, s.Client, s.AppName) + + return output, response, nil +} + +func filterTestedFormats(formats []string) []string { + supportedFormats := os.Getenv("FDK_FORMATS") + + if supportedFormats != "" { + acceptedFormats := strings.Split(supportedFormats, ",") + validFormats := []string{} + + for _, af := range acceptedFormats { + for _, reqF := range formats { + if reqF == af { + validFormats = append(validFormats, reqF) + } + } + } + return validFormats + + } + return formats +} + +func TestFDKFormatSmallBody(t *testing.T) { + + FDKImage := os.Getenv("FDK_FUNCTION_IMAGE") + if FDKImage == "" { + t.Error("Please set FDK-based function image to test") + } + formats := filterTestedFormats([]string{"http", "json"}) + + helloJohnPayload := &struct { + Name string `json:"name"` + }{} + ExpectedOutput := "Hello %v" + for _, format := range formats { + + // echo function: + // payload: + // { + // "name": "%v" + // } + // response: + // "Hello %v" + // if name is empty then: + // "Hello World" + t.Run(fmt.Sprintf("test-fdk-%v-small-body", format), func(t *testing.T) { + + t.Parallel() + s := SetupDefaultSuite() + route := fmt.Sprintf("/test-fdk-%v-format-small-body", format) + + for _, part := range []string{"", "Jimmy", RandStringBytes(10), RandStringBytes(20)} { + helloJohnPayload.Name = part + output, response, err := callOnce(t, s, route, FDKImage, format, helloJohnPayload) + + if err != nil { + t.Errorf("unexpected error: %v", err.Error()) + } + + expected := "" + if part == "" { + expected = fmt.Sprintf(ExpectedOutput, "World") + } + + expected = fmt.Sprintf(ExpectedOutput, part) + if !strings.Contains(output.String(), expected) { + t.Errorf("Output assertion error.\n\tExpected: %v\n\tActual: %v", expected, output.String()) + } + if response.StatusCode != http.StatusOK { + t.Errorf("Status code assertion error.\n\tExpected: %v\n\tActual: %v", 200, response.StatusCode) + } + + expectedHeaderNames := []string{"Content-Type", "Content-Length"} + expectedHeaderValues := []string{"text/plain; charset=utf-8", strconv.Itoa(output.Len())} + for i, name := range expectedHeaderNames { + actual := response.Header.Get(name) + expected := expectedHeaderValues[i] + if !strings.Contains(expected, actual) { + t.Errorf("HTTP header assertion error for %v."+ + "\n\tExpected: %v\n\tActual: %v", name, expected, actual) + } + } + } + }) + } +} + +func TestFDKMultipleEvents(t *testing.T) { + + FDKImage := os.Getenv("FDK_FUNCTION_IMAGE") + if FDKImage == "" { + t.Error("Please set FDK-based function image to test") + } + formats := filterTestedFormats([]string{"http", "json"}) + for _, format := range formats { + // this test attempts to send 50 concurrent requests + // to a function in order to see if it's capable to handle more than 1 event + // the only thing that matters in this test is response code, it should be 200 OK for all requests, + // if one assertion fails means that FDK or Fn failed to dispatch necessary number of calls + t.Run(fmt.Sprintf("test-fdk-%v-multiple-events", format), func(t *testing.T) { + + s := SetupDefaultSuite() + route := fmt.Sprintf("/test-fdk-%v-multiple-events", format) + + callMultiple(50, t, s, route, FDKImage, format) + }) + } +} diff --git a/functions/python/Dockerfile b/functions/python/Dockerfile new file mode 100644 index 0000000..6f70318 --- /dev/null +++ b/functions/python/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.6.2 + + +RUN mkdir /code +ADD . /code/ +WORKDIR /code/ +RUN pip install -r requirements.txt +WORKDIR /code/ + +ENTRYPOINT ["python3", "func.py"] diff --git a/functions/python/func.py b/functions/python/func.py new file mode 100644 index 0000000..6695e5e --- /dev/null +++ b/functions/python/func.py @@ -0,0 +1,47 @@ +# All Rights Reserved. +# +# 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. + +import fdk +import ujson + +from fdk.http import request as http_request + + +def handler(context, data=None, loop=None): + + # specifically for http protocol + if isinstance(data, (http_request.ChunkedStream, + http_request.ContentLengthStream)): + data = data.readall() + + # specifically for json protocol + if isinstance(data, (bytes, str)): + if isinstance(data, bytes): + data = data.decode("utf-8") + try: + data = ujson.loads(data) + except Exception: + data = {} + + if isinstance(data, dict): + name = data.get("name") + if not len(name): + name = "World" + return "Hello {}".format(name) + if isinstance(data, str): + return data + + +if __name__ == "__main__": + fdk.handle(handler) diff --git a/functions/python/func.yaml b/functions/python/func.yaml new file mode 100644 index 0000000..0786faa --- /dev/null +++ b/functions/python/func.yaml @@ -0,0 +1,6 @@ +name: fnproject/test-fdk-python +version: 0.0.1 +runtime: python +format: http +timeout: 100 +path: /test-fdk-python diff --git a/functions/python/requirements.txt b/functions/python/requirements.txt new file mode 100644 index 0000000..3db61ea --- /dev/null +++ b/functions/python/requirements.txt @@ -0,0 +1 @@ +fdk==0.0.4 diff --git a/init_test.go b/init_test.go new file mode 100644 index 0000000..0e14cb0 --- /dev/null +++ b/init_test.go @@ -0,0 +1,19 @@ +package test + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + // call flag.Parse() here if TestMain uses flags + s := SetupDefaultSuite() + result := m.Run() + Cleanup() + s.Cancel() + if result == 0 { + fmt.Fprintln(os.Stdout, "😀 👍 🎗") + } + os.Exit(result) +} diff --git a/routes_api.go b/routes_api.go new file mode 100644 index 0000000..1d1ffbd --- /dev/null +++ b/routes_api.go @@ -0,0 +1,215 @@ +package test + +import ( + "context" + "testing" + + "github.com/fnproject/fn_go/client" + "github.com/fnproject/fn_go/client/routes" + "github.com/fnproject/fn_go/models" +) + +func CheckRouteResponseError(t *testing.T, e error) { + if e != nil { + switch err := e.(type) { + case *routes.PostAppsAppRoutesDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) + t.FailNow() + case *routes.PostAppsAppRoutesBadRequest: + t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) + t.FailNow() + case *routes.PostAppsAppRoutesConflict: + t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) + t.FailNow() + case *routes.GetAppsAppRoutesRouteNotFound: + t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) + t.FailNow() + case *routes.GetAppsAppRoutesRouteDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) + t.FailNow() + case *routes.DeleteAppsAppRoutesRouteNotFound: + t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) + t.FailNow() + case *routes.DeleteAppsAppRoutesRouteDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) + t.FailNow() + case *routes.GetAppsAppRoutesNotFound: + t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) + t.FailNow() + case *routes.GetAppsAppRoutesDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) + t.FailNow() + case *routes.PatchAppsAppRoutesRouteBadRequest: + t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) + t.FailNow() + case *routes.PatchAppsAppRoutesRouteNotFound: + t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) + t.FailNow() + case *routes.PatchAppsAppRoutesRouteDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) + case *routes.PutAppsAppRoutesRouteBadRequest: + t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) + case *routes.PutAppsAppRoutesRouteDefault: + t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) + t.FailNow() + default: + t.Errorf("Unable to determine type of error: %s", err) + t.FailNow() + } + } +} + +func AssertRouteFields(t *testing.T, routeObject *models.Route, path, image, routeType, routeFormat string) { + + rPath := routeObject.Path + rImage := routeObject.Image + rType := routeObject.Type + rTimeout := *routeObject.Timeout + rIdleTimeout := *routeObject.IDLETimeout + rFormat := routeObject.Format + + if rPath != path { + t.Errorf("Route path mismatch. Expected: %v. Actual: %v", path, rPath) + } + if rImage != image { + t.Errorf("Route image mismatch. Expected: %v. Actual: %v", image, rImage) + } + if rType != routeType { + t.Errorf("Route type mismatch. Expected: %v. Actual: %v", routeType, rType) + } + if rTimeout == 0 { + t.Error("Route timeout should have default value of 30 seconds, but got 0 seconds") + } + if rIdleTimeout == 0 { + t.Error("Route idle timeout should have default value of 30 seconds, but got 0 seconds") + } + if rFormat != routeFormat { + t.Errorf("Route format mismatch. Expected: %v. Actual: %v", routeFormat, rFormat) + } + +} + +func CreateRouteNoAssert(ctx context.Context, fnclient *client.Fn, appName, image, routePath, routeType, routeFormat string, timeout, idleTimeout int32, routeConfig map[string]string, headers map[string][]string) (*routes.PostAppsAppRoutesOK, error) { + cfg := &routes.PostAppsAppRoutesParams{ + App: appName, + Body: &models.RouteWrapper{ + Route: &models.Route{ + Config: routeConfig, + Headers: headers, + Image: image, + Path: routePath, + Type: routeType, + Format: routeFormat, + Timeout: &timeout, + IDLETimeout: &idleTimeout, + }, + }, + Context: ctx, + } + ok, err := fnclient.Routes.PostAppsAppRoutes(cfg) + if err == nil { + approutesLock.Lock() + r, got := appsandroutes[appName] + if got { + appsandroutes[appName] = append(r, routePath) + } else { + appsandroutes[appName] = []string{routePath} + } + approutesLock.Unlock() + } + return ok, err + +} + +func CreateRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath, image, routeType, routeFormat string, timeout, idleTimeout int32, routeConfig map[string]string, headers map[string][]string) { + routeResponse, err := CreateRouteNoAssert(ctx, fnclient, appName, image, routePath, routeType, routeFormat, timeout, idleTimeout, routeConfig, headers) + CheckRouteResponseError(t, err) + + AssertRouteFields(t, routeResponse.Payload.Route, routePath, image, routeType, routeFormat) +} + +func DeleteRouteNoAssert(ctx context.Context, fnclient *client.Fn, appName, routePath string) (*routes.DeleteAppsAppRoutesRouteOK, error) { + cfg := &routes.DeleteAppsAppRoutesRouteParams{ + App: appName, + Route: routePath, + Context: ctx, + } + + return fnclient.Routes.DeleteAppsAppRoutesRoute(cfg) +} + +func DeleteRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath string) { + _, err := DeleteRouteNoAssert(ctx, fnclient, appName, routePath) + CheckRouteResponseError(t, err) +} + +func GetRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath string) *models.Route { + cfg := &routes.GetAppsAppRoutesRouteParams{ + App: appName, + Route: routePath, + Context: ctx, + } + + routeResponse, err := fnclient.Routes.GetAppsAppRoutesRoute(cfg) + CheckRouteResponseError(t, err) + return routeResponse.Payload.Route +} + +func UpdateRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath, image, routeType, format string, memory uint64, routeConfig map[string]string, headers map[string][]string, newRoutePath string) (*routes.PatchAppsAppRoutesRouteOK, error) { + + routeObject := GetRoute(t, ctx, fnclient, appName, routePath) + if routeObject.Config == nil { + routeObject.Config = map[string]string{} + } + + if routeObject.Headers == nil { + routeObject.Headers = map[string][]string{} + } + + routeObject.Path = "" + if newRoutePath != "" { + routeObject.Path = newRoutePath + } + + if routeConfig != nil { + for k, v := range routeConfig { + if string(k[0]) == "-" { + delete(routeObject.Config, string(k[1:])) + continue + } + routeObject.Config[k] = v + } + } + if headers != nil { + for k, v := range headers { + if string(k[0]) == "-" { + delete(routeObject.Headers, k) + continue + } + routeObject.Headers[k] = v + } + } + if image != "" { + routeObject.Image = image + } + if format != "" { + routeObject.Format = format + } + if routeType != "" { + routeObject.Type = routeType + } + if memory > 0 { + routeObject.Memory = memory + } + + cfg := &routes.PatchAppsAppRoutesRouteParams{ + App: appName, + Context: ctx, + Body: &models.RouteWrapper{ + Route: routeObject, + }, + Route: routePath, + } + + return fnclient.Routes.PatchAppsAppRoutesRoute(cfg) +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..96d887a --- /dev/null +++ b/utils.go @@ -0,0 +1,223 @@ +package test + +import ( + "context" + "fmt" + "golang.org/x/net/context/ctxhttp" + "io" + "log" + "math/rand" + "net/http" + "net/url" + "os" + "runtime" + "strings" + "sync" + "time" + + "bytes" + "crypto/tls" + "encoding/json" + "github.com/fnproject/fn_go/client" + httptransport "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + "net" + "strconv" + "testing" +) + +const lBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func Host() string { + apiURL := os.Getenv("FN_API_URL") + if apiURL == "" { + apiURL = "http://localhost:8080" + } + + u, err := url.Parse(apiURL) + if err != nil { + log.Fatalln("Couldn't parse API URL:", err) + } + return u.Host +} + +func APIClient() *client.Fn { + transport := httptransport.New(Host(), "/v1", []string{"http"}) + if os.Getenv("FN_TOKEN") != "" { + transport.DefaultAuthentication = httptransport.BearerToken(os.Getenv("FN_TOKEN")) + } + + // create the API client, with the transport + return client.New(transport, strfmt.Default) +} + +var ( + appsandroutes = make(map[string][]string) + approutesLock sync.Mutex +) + +type SuiteSetup struct { + Context context.Context + Client *client.Fn + AppName string + RoutePath string + Image string + RouteType string + Format string + Memory uint64 + RouteConfig map[string]string + RouteHeaders map[string][]string + Cancel context.CancelFunc +} + +func RandStringBytes(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = lBytes[rand.Intn(len(lBytes))] + } + return strings.ToLower(string(b)) +} + +func SetupDefaultSuite() *SuiteSetup { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ss := &SuiteSetup{ + Context: ctx, + Client: APIClient(), + AppName: "fnintegrationtestapp" + RandStringBytes(10), + RoutePath: "/fnintegrationtestroute" + RandStringBytes(10), + Image: "fnproject/hello", + Format: "default", + RouteType: "async", + RouteConfig: map[string]string{}, + RouteHeaders: map[string][]string{}, + Cancel: cancel, + Memory: uint64(256), + } + + if Host() != "localhost:8080" { + _, ok := http.Get(fmt.Sprintf("http://%s/version", Host())) + if ok != nil { + panic("Cannot reach remote api for functions") + } + } + + return ss +} + +func Cleanup() { + keepApps := os.Getenv("DISABLE_TESTKIT_CLEANUP") + b, _ := strconv.ParseBool(keepApps) + if b != true { + ctx := context.Background() + c := APIClient() + approutesLock.Lock() + defer approutesLock.Unlock() + for appName := range appsandroutes { + DeleteAppNoT(ctx, c, appName) + } + appsandroutes = make(map[string][]string) + } +} + +func EnvAsHeader(req *http.Request, selectedEnv []string) { + detectedEnv := os.Environ() + if len(selectedEnv) > 0 { + detectedEnv = selectedEnv + } + + for _, e := range detectedEnv { + kv := strings.Split(e, "=") + name := kv[0] + req.Header.Set(name, os.Getenv(name)) + } +} + +func SetupHTTPClient() *http.Client { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 120 * time.Second, + }).Dial, + MaxIdleConnsPerHost: 512, + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: &tls.Config{ + ClientSessionCache: tls.NewLRUClientSessionCache(4096), + }, + } + return &http.Client{Transport: transport} +} + +func CallFN(ctx context.Context, u string, contentType string, content io.Reader, output io.Writer, method string, env []string) (*http.Response, error) { + httpclient := SetupHTTPClient() + if method == "" { + if content == nil { + method = "GET" + } else { + method = "POST" + } + } + + req, err := http.NewRequest(method, u, content) + if err != nil { + return nil, fmt.Errorf("error running route: %s", err) + } + + req.Header.Set("Content-Type", contentType) + + if len(env) > 0 { + EnvAsHeader(req, env) + } + + resp, err := ctxhttp.Do(ctx, httpclient, req) + if err != nil { + return nil, fmt.Errorf("error running route: %s", err) + } + io.Copy(output, resp.Body) + + return resp, nil +} + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +func MyCaller() string { + fpcs := make([]uintptr, 1) + n := runtime.Callers(3, fpcs) + if n == 0 { + return "n/a" + } + fun := runtime.FuncForPC(fpcs[0] - 1) + if fun == nil { + return "n/a" + } + f, l := fun.FileLine(fpcs[0] - 1) + return fmt.Sprintf("%s:%d", f, l) +} + +func CallAsync(t *testing.T, ctx context.Context, u url.URL, contentType string, content io.Reader) string { + output := &bytes.Buffer{} + _, err := CallFN(ctx, u.String(), contentType, content, output, "POST", []string{}) + if err != nil { + t.Errorf("Got unexpected error: %v", err) + } + + expectedOutput := "call_id" + if !strings.Contains(output.String(), expectedOutput) { + t.Errorf("Assertion error.\n\tExpected: %v\n\tActual: %v", expectedOutput, output.String()) + } + + type CallID struct { + CallID string `json:"call_id"` + } + + callID := &CallID{} + json.NewDecoder(output).Decode(callID) + + if callID.CallID == "" { + t.Errorf("`call_id` not suppose to be empty string") + } + t.Logf("Async execution call ID: %v", callID.CallID) + return callID.CallID +}