From f4335d6eda98f552baa0e3453f7ccc065a134939 Mon Sep 17 00:00:00 2001 From: John Fallis <21218504+jfallis@users.noreply.github.com> Date: Mon, 15 May 2023 14:06:50 +0100 Subject: [PATCH] add http adaptor for pact tests (GO-7710) (#13) --- README.md | 22 +++++++ go.mod | 1 + go.sum | 2 + http.go | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++ http_test.go | 77 ++++++++++++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 http.go create mode 100644 http_test.go diff --git a/README.md b/README.md index 41a8622..2837987 100644 --- a/README.md +++ b/README.md @@ -147,5 +147,27 @@ The new version of eris handles errors differently and produce a different JSON eris.Wrapf(err, "failed to send offers to user id: %v", userID) ``` +### Pact HTTP provider testing + +The `NewHTTPHandler` function can be used to create adaptors for `g8.APIGatewayProxyHandler` lambdas and serve HTTP for pact provider testing to aid engineers and verify that an API provider adheres to a number of pacts authored by its clients. + +#### Example +```go + g8.NewHTTPHandler(LambdaHandlerEndpoints{ + g8.LambdaHandler{ + Handler: pact.ExampleGetStub, + Method: http.MethodGet, + Path: "/full/url/path/{var1}/{var2}", + PathParams: []string{"var1", "var2"}, + }, + g8.LambdaHandler{ + Handler: pact.ExamplePostStub, + Method: http.MethodPost, + Path: "/another/full/url/path/{var1}", + PathParams: []string{"var1"}, + }, + }, 8080) +``` + ### Requirements * Go 1.19+ diff --git a/go.mod b/go.mod index a7ec3a2..fe930ab 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/PaesslerAG/jsonpath v0.1.1 github.com/aws/aws-lambda-go v1.34.1 github.com/gaw508/lambda-proxy-http-adapter v0.1.0 + github.com/go-chi/chi/v5 v5.0.8 github.com/google/uuid v1.3.0 github.com/newrelic/go-agent v3.19.0+incompatible github.com/rotisserie/eris v0.5.4 diff --git a/go.sum b/go.sum index 6b7cd64..2a8c1f7 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gaw508/lambda-proxy-http-adapter v0.1.0 h1:0QDBKYl4OJZ5YlNdXXqNuMUjBRVqxWwApP4hbxdTfdA= github.com/gaw508/lambda-proxy-http-adapter v0.1.0/go.mod h1:8lk+YsNGsT8W5tjyznIFP1Y+ZtL/4SxwUPBfQAknucQ= +github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= +github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/http.go b/http.go new file mode 100644 index 0000000..f7ee261 --- /dev/null +++ b/http.go @@ -0,0 +1,166 @@ +package g8 + +import ( + "context" + "encoding/json" + "fmt" + "github.com/aws/aws-lambda-go/events" + "github.com/go-chi/chi/v5" + "io" + "net/http" + "strings" +) + +const ( + WELCOME_MESSAGE = "G8 HTTP server is running on port" + UNHANDLED_ERR_MESSAGE = "unhandled error: " +) + +type LambdaHandlerEndpoints []LambdaHandler + +type LambdaHandler struct { + Handler interface{} + Method string + Path string + PathParams []string +} + +// NewHTTPHandler creates a new HTTP server that listens on the given port. +func NewHTTPHandler(lambdaEndpoints LambdaHandlerEndpoints, portNumber int) { + fmt.Printf("\n%s %d\n\n", WELCOME_MESSAGE, portNumber) + r := chi.NewRouter() + for _, l := range lambdaEndpoints { + r.MethodFunc(l.Method, l.Path, LambdaAdapter(l)) + } + if err := http.ListenAndServe(fmt.Sprintf(":%d", portNumber), r); err != nil { + panic(err) + } +} + +// LambdaAdapter converts a LambdaHandler into a http.HandlerFunc. +func LambdaAdapter(l LambdaHandler) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch eventHandler := l.Handler.(type) { + // APIGatewayProxyHandler + case func(context.Context, events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error): + fmt.Printf("%s %s: %+v \n", r.Method, r.URL.Path, l.PathParams) + request := NewAPIGatewayRequestBuilder(r, l.PathParams) + resp, eErr := eventHandler(context.Background(), request.Request()) + if eErr != nil { + fmt.Printf("%s %s\n", UNHANDLED_ERR_MESSAGE, eErr.Error()) + resp, eErr = unhandledError(eErr) + if eErr != nil { + panic(eErr) + } + } + + w.WriteHeader(resp.StatusCode) + for k, v := range resp.Headers { + w.Header().Set(k, v) + } + for k, v := range resp.MultiValueHeaders { + w.Header().Set(k, strings.Join(v, ",")) + } + + if _, wErr := w.Write([]byte(resp.Body)); wErr != nil { + panic(wErr) + } + default: + panic(fmt.Sprintf("unknown type: %T", l.Handler)) + } + } +} + +// APIGatewayRequestBuilder is a builder for APIGatewayProxyRequest. +type APIGatewayRequestBuilder struct { + pathParams []string + request *http.Request +} + +// Headers returns the headers of the request. +func (b *APIGatewayRequestBuilder) Headers() map[string]string { + headers := make(map[string]string, len(b.request.Header)) + for k, v := range b.request.Header { + headers[k] = strings.Join(v, ",") + } + + return headers +} + +// QueryStrings returns the query strings of the request. +func (b *APIGatewayRequestBuilder) QueryStrings() (map[string]string, map[string][]string) { + query := b.request.URL.Query() + queryParams := make(map[string]string, len(query)) + MultiQueryParams := make(map[string][]string, len(query)) + for k, v := range query { + queryParams[k] = strings.Join(v, ",") + MultiQueryParams[k] = v + } + + return queryParams, MultiQueryParams +} + +// PathParams returns the path parameters of the request. +func (b *APIGatewayRequestBuilder) PathParams() map[string]string { + pathParams := make(map[string]string, len(b.pathParams)) + for _, v := range b.pathParams { + pathParams[v] = chi.URLParam(b.request, v) + } + + return pathParams +} + +// Body returns the body of the request. +func (b *APIGatewayRequestBuilder) Body() string { + if body, err := io.ReadAll(b.request.Body); err == nil { + return string(body) + } + return "" +} + +// Request returns the APIGatewayProxyRequest. +func (b *APIGatewayRequestBuilder) Request() events.APIGatewayProxyRequest { + query, multiQuery := b.QueryStrings() + return events.APIGatewayProxyRequest{ + Path: b.request.URL.Path, + HTTPMethod: b.request.Method, + Headers: b.Headers(), + MultiValueHeaders: b.request.Header, + QueryStringParameters: query, + MultiValueQueryStringParameters: multiQuery, + PathParameters: b.PathParams(), + Body: b.Body(), + } +} + +// NewAPIGatewayRequestBuilder creates a new APIGatewayRequestBuilder. +func NewAPIGatewayRequestBuilder(request *http.Request, pathParams []string) *APIGatewayRequestBuilder { + return &APIGatewayRequestBuilder{ + request: request, + pathParams: pathParams, + } +} + +// unhandledError returns an APIGatewayProxyResponse with the given error. +func unhandledError(err error) (*events.APIGatewayProxyResponse, error) { + var newErr Err + switch err := err.(type) { + case Err: + newErr = err + default: + newErr = ErrInternalServer + } + + b, err := json.Marshal(newErr) + if err != nil { + return nil, err + } + + r := new(events.APIGatewayProxyResponse) + r.Headers = make(map[string]string) + r.Headers["Content-Type"] = "application/json" + r.StatusCode = newErr.Status + r.Body = string(b) + + return r, nil +} diff --git a/http_test.go b/http_test.go new file mode 100644 index 0000000..65e67ae --- /dev/null +++ b/http_test.go @@ -0,0 +1,77 @@ +package g8_test + +import ( + "context" + "fmt" + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" + + "github.com/JSainsburyPLC/g8" +) + +func TestLambdaAdapter(t *testing.T) { + l := g8.LambdaHandler{ + Handler: func(ctx context.Context, r events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) { + return &events.APIGatewayProxyResponse{ + StatusCode: http.StatusOK, + Headers: map[string]string{"Content-Type": "text/plain"}, + MultiValueHeaders: map[string][]string{"Set-Cookie": {"cookie1", "cookie2"}}, + Body: "success", + }, nil + }, + Method: http.MethodGet, + Path: "/test/url/path/{var1}/{var2}", + PathParams: []string{"var1", "var2"}, + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/test/url/path/var1/var2", nil) + r.Header.Set("Content-Type", "application/json") + g8.LambdaAdapter(l)(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/plain", w.Header().Get("Content-Type")) + assert.Equal(t, "cookie1,cookie2", w.Header().Get("Set-Cookie")) + assert.Equal(t, "success", w.Body.String()) +} + +func TestLambdaAdapter_g8_error(t *testing.T) { + l := g8.LambdaHandler{ + Handler: func(ctx context.Context, r events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) { + return nil, g8.ErrInternalServer + }, + Method: http.MethodGet, + Path: "/test/url/path", + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/test/url/path/var1/var2", nil) + r.Header.Set("Content-Type", "application/json") + g8.LambdaAdapter(l)(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + assert.Equal(t, `{"code":"INTERNAL_SERVER_ERROR","detail":"Internal server error"}`, w.Body.String()) +} + +func TestLambdaAdapter_generic_error(t *testing.T) { + l := g8.LambdaHandler{ + Handler: func(ctx context.Context, r events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) { + return nil, fmt.Errorf("generic error") + }, + Method: http.MethodGet, + Path: "/test/url/path", + } + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/test/url/path/var1/var2", nil) + r.Header.Set("Content-Type", "application/json") + g8.LambdaAdapter(l)(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + assert.Equal(t, `{"code":"INTERNAL_SERVER_ERROR","detail":"Internal server error"}`, w.Body.String()) +}