diff --git a/router/api.go b/router/api.go index cf3c61405def..2c878cafaf4f 100644 --- a/router/api.go +++ b/router/api.go @@ -16,6 +16,7 @@ import ( const apiGatewayIntegrationExtension = "x-amazon-apigateway-integration" const apiGatewayAnyMethodExtension = "x-amazon-apigateway-any-method" +const apiGatewayBinaryMediaTypesExtension = "x-amazon-apigateway-binary-media-types" // temporary object. This is just used to marshal and unmarshal the any method // API Gateway swagger extension @@ -49,6 +50,11 @@ func (api *AWSServerlessApi) Mounts() ([]*ServerlessRouterMount, error) { mounts := []*ServerlessRouterMount{} + binaryMediaTypes, ok := swagger.VendorExtensible.Extensions.GetStringSlice(apiGatewayBinaryMediaTypesExtension) + if !ok { + binaryMediaTypes = []string{} + } + for path, pathItem := range swagger.Paths.Paths { // temporary tracking of mounted methods for the current path. Used to // mount all non-existing methods for the any extension. This is because @@ -75,7 +81,8 @@ func (api *AWSServerlessApi) Mounts() ([]*ServerlessRouterMount, error) { mounts = append(mounts, api.createMount( path, strings.ToLower(method), - api.parseIntegrationSettings(integration))) + api.parseIntegrationSettings(integration), + binaryMediaTypes)) mappedMethods[method] = true } } @@ -100,7 +107,8 @@ func (api *AWSServerlessApi) Mounts() ([]*ServerlessRouterMount, error) { mounts = append(mounts, api.createMount( path, strings.ToLower(method), - api.parseIntegrationSettings(anyMethodObject.IntegrationSettings))) + api.parseIntegrationSettings(anyMethodObject.IntegrationSettings), + binaryMediaTypes)) } } } @@ -129,11 +137,12 @@ func (api *AWSServerlessApi) parseIntegrationSettings(integrationData interface{ return &integration } -func (api *AWSServerlessApi) createMount(path string, verb string, integration *ApiGatewayIntegration) *(ServerlessRouterMount) { +func (api *AWSServerlessApi) createMount(path string, verb string, integration *ApiGatewayIntegration, binaryMediaTypes []string) *(ServerlessRouterMount) { newMount := &ServerlessRouterMount{ - Name: path, - Path: path, - Method: verb, + Name: path, + Path: path, + Method: verb, + BinaryMediaTypes: binaryMediaTypes, } if integration == nil { diff --git a/event.go b/router/event.go similarity index 89% rename from event.go rename to router/event.go index 88e4b78ab133..1d357d763bf2 100644 --- a/event.go +++ b/router/event.go @@ -1,4 +1,4 @@ -package main +package router import ( "encoding/json" @@ -19,6 +19,7 @@ type Event struct { PathParameters map[string]string `json:"pathParameters"` StageVariables map[string]string `json:"stageVariables"` Path string `json:"path"` + IsBase64Encoded bool `json:"isBase64Encoded"` } // RequestContext represents the context object that gets passed to an AWS Lambda function @@ -49,12 +50,16 @@ type ContextIdentity struct { } // NewEvent initalises and populates a new ApiEvent with -// event details from a http.Request -func NewEvent(req *http.Request) (*Event, error) { - - body, err := ioutil.ReadAll(req.Body) - if err != nil { - return nil, err +// event details from a http.Request and isBase64Encoded value +func NewEvent(req *http.Request, isBase64Encoded bool) (*Event, error) { + + var body []byte + if req.Body != nil { + var err error + body, err = ioutil.ReadAll(req.Body) + if err != nil { + return nil, err + } } headers := map[string]string{} @@ -84,6 +89,7 @@ func NewEvent(req *http.Request) (*Event, error) { Path: req.URL.Path, Resource: req.URL.Path, PathParameters: pathParams, + IsBase64Encoded: isBase64Encoded, } event.RequestContext.Identity.SourceIP = req.RemoteAddr diff --git a/event_test.go b/router/event_test.go similarity index 84% rename from event_test.go rename to router/event_test.go index 9a32b3836279..17322d7e14e5 100644 --- a/event_test.go +++ b/router/event_test.go @@ -1,11 +1,10 @@ -package main +package router import ( "bytes" "net/http" "net/http/httptest" - "github.com/awslabs/aws-sam-local/router" "github.com/awslabs/goformation/cloudformation" . "github.com/onsi/ginkgo" @@ -14,9 +13,9 @@ import ( var _ = Describe("Event", func() { Describe("PathParameters", func() { - var r *router.ServerlessRouter + var r *ServerlessRouter BeforeEach(func() { - r = router.NewServerlessRouter(false) + r = NewServerlessRouter(false) }) Context("with path parameters on the route", func() { @@ -48,8 +47,7 @@ var _ = Describe("Event", func() { req, _ := http.NewRequest("GET", "/get/1", new(bytes.Buffer)) It("returns the parameters on the event", func() { - r.AddFunction(function, func(w http.ResponseWriter, r *http.Request) { - e, _ := NewEvent(r) + r.AddFunction(function, func(w http.ResponseWriter, e *Event) { Expect(e.PathParameters).To(HaveKeyWithValue("parameter", "1")) }) @@ -57,13 +55,12 @@ var _ = Describe("Event", func() { r.Router().ServeHTTP(rec, req) }) }) - + Context("and path parameters on the request", func() { req, _ := http.NewRequest("GET", "/get/1", new(bytes.Buffer)) It("returns stage property with value \"prod\"", func() { - r.AddFunction(function, func(w http.ResponseWriter, r *http.Request) { - e, _ := NewEvent(r) + r.AddFunction(function, func(w http.ResponseWriter, e *Event) { Expect(e.RequestContext.Stage).To(BeIdenticalTo("prod")) }) @@ -76,8 +73,7 @@ var _ = Describe("Event", func() { req, _ := http.NewRequest("GET", "/get", new(bytes.Buffer)) It("returns nil for PathParameters on the event", func() { - r.AddFunction(function, func(w http.ResponseWriter, r *http.Request) { - e, _ := NewEvent(r) + r.AddFunction(function, func(w http.ResponseWriter, e *Event) { Expect(e.PathParameters).To(BeNil()) }) @@ -106,8 +102,7 @@ var _ = Describe("Event", func() { req, _ := http.NewRequest("GET", "/get", new(bytes.Buffer)) It("returns nil for PathParameters on the event", func() { - r.AddFunction(function, func(w http.ResponseWriter, r *http.Request) { - e, _ := NewEvent(r) + r.AddFunction(function, func(w http.ResponseWriter, e *Event) { Expect(e.PathParameters).To(BeNil()) }) diff --git a/router/function.go b/router/function.go index 2314e4e513be..5602fa081bc6 100644 --- a/router/function.go +++ b/router/function.go @@ -1,8 +1,6 @@ package router import ( - "net/http" - "github.com/awslabs/goformation/cloudformation" ) @@ -11,7 +9,7 @@ import ( // from the event sources. type AWSServerlessFunction struct { *cloudformation.AWSServerlessFunction - handler http.HandlerFunc + handler EventHandlerFunc } // Mounts fetches an array of the ServerlessRouterMount's for this API. diff --git a/router/function_test.go b/router/function_test.go index 337b5576a07c..930fddb5870d 100644 --- a/router/function_test.go +++ b/router/function_test.go @@ -60,7 +60,7 @@ var _ = Describe("Function", func() { }, } - err := r.AddFunction(function, func(w http.ResponseWriter, r *http.Request) { + err := r.AddFunction(function, func(w http.ResponseWriter, e *router.Event) { w.WriteHeader(200) w.Write([]byte("ok")) }) @@ -190,7 +190,7 @@ var _ = Describe("Function", func() { }, } - err := r.AddFunction(function, func(w http.ResponseWriter, r *http.Request) { + err := r.AddFunction(function, func(w http.ResponseWriter, e *router.Event) { w.WriteHeader(200) w.Write([]byte("ok")) }) @@ -246,7 +246,7 @@ var _ = Describe("Function", func() { Runtime: "nodejs6.10", } - err := r.AddFunction(function, func(w http.ResponseWriter, r *http.Request) { + err := r.AddFunction(function, func(w http.ResponseWriter, e *router.Event) { w.WriteHeader(200) w.Write([]byte("ok")) }) diff --git a/router/mount.go b/router/mount.go index dfd752180513..7878ed1e05f8 100644 --- a/router/mount.go +++ b/router/mount.go @@ -1,21 +1,32 @@ package router import ( + "encoding/base64" + "io/ioutil" + "mime" "net/http" "strings" + "unicode/utf8" + "log" + "fmt" ) const MuxPathRegex = ".+" var HttpMethods = []string{"OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"} +// EventHandlerFunc is similar to Go http.Handler but it receives an event from API Gateway +// instead of http.Request +type EventHandlerFunc func(http.ResponseWriter, *Event) + // ServerlessRouterMount represents a single mount point on the API // Such as '/path', the HTTP method, and the function to resolve it type ServerlessRouterMount struct { - Name string - Function *AWSServerlessFunction - Handler http.HandlerFunc - Path string - Method string + Name string + Function *AWSServerlessFunction + Handler EventHandlerFunc + Path string + Method string + BinaryMediaTypes []string // authorization settings AuthType string @@ -23,6 +34,43 @@ type ServerlessRouterMount struct { IntegrationArn *LambdaFunctionArn } +// Returns the wrapped handler to encode the body as base64 when binary +// media types contains Content-Type +func (m *ServerlessRouterMount) WrappedHandler() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + contentType := req.Header.Get("Content-Type") + mediaType, _, err := mime.ParseMediaType(contentType) + binaryContent := false + + if err == nil { + for _, value := range m.BinaryMediaTypes { + if value != "" && value == mediaType { + binaryContent = true + break + } + } + } + + if binaryContent { + if body, err := ioutil.ReadAll(req.Body); err == nil && !utf8.Valid(body) { + req.Body = ioutil.NopCloser(strings.NewReader(base64.StdEncoding.EncodeToString(body))) + } else { + req.Body = ioutil.NopCloser(strings.NewReader(string(body))) + } + } + + event, err := NewEvent(req, binaryContent) + if err != nil { + msg := fmt.Sprintf("Error creating a new event: %s", err) + log.Println(msg) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{ "message": "Internal server error" }`)) + } else { + m.Handler(w, event) + } + }) +} + // Methods gets an array of HTTP methods from a AWS::Serverless::Function // API event source method declaration (which could include 'any') func (m *ServerlessRouterMount) Methods() []string { diff --git a/router/router.go b/router/router.go index e2577f194834..fa5279d18ad1 100644 --- a/router/router.go +++ b/router/router.go @@ -34,7 +34,7 @@ func NewServerlessRouter(usePrefix bool) *ServerlessRouter { // AddFunction adds a AWS::Serverless::Function to the router and mounts all of it's // event sources that have type 'Api' -func (r *ServerlessRouter) AddFunction(f *cloudformation.AWSServerlessFunction, handler http.HandlerFunc) error { +func (r *ServerlessRouter) AddFunction(f *cloudformation.AWSServerlessFunction, handler EventHandlerFunc) error { // Wrap GoFormation's AWS::Serverless::Function definition in our own, which provides // convenience methods for extracting the ServerlessRouterMount(s) from it. @@ -115,7 +115,7 @@ func (r *ServerlessRouter) Router() http.Handler { // Mount all of the things! for _, mount := range r.Mounts() { - r.mux.Handle(mount.GetMuxPath(), mount.Handler).Methods(mount.Methods()...) + r.mux.Handle(mount.GetMuxPath(), mount.WrappedHandler()).Methods(mount.Methods()...) } return r.mux @@ -127,8 +127,8 @@ func (r *ServerlessRouter) Mounts() []*ServerlessRouterMount { return r.mounts } -func (r *ServerlessRouter) missingFunctionHandler() func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, req *http.Request) { +func (r *ServerlessRouter) missingFunctionHandler() func(http.ResponseWriter, *Event) { + return func(w http.ResponseWriter, event *Event) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadGateway) w.Write([]byte(`{ "message": "No function defined for resource method" }`)) diff --git a/router/router_test.go b/router/router_test.go index ffc6dfc178e6..be9667d63a90 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -1,10 +1,14 @@ package router import ( + "bytes" + "encoding/base64" "net/http" "net/http/httptest" + "strings" "github.com/awslabs/goformation" + "github.com/awslabs/goformation/cloudformation" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -38,4 +42,89 @@ var _ = Describe("ServerlessRouter", func() { Expect(rr.Code).To(Equal(http.StatusBadGateway)) }) }) + + Context("with SAM template and x-amazon-apigateway-binary-media-types defined in it", func() { + const input = `{ + "Resources": { + "MyApi": { + "Type": "AWS::Serverless::Api", + "Properties": { + "DefinitionBody": { + "swagger": "2.0", + "paths": { + "/post": { + "post": { + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/dummy/invocations" + } + }, + "responses": {} + } + } + }, + "x-amazon-apigateway-binary-media-types": ["multipart/form-data"] + } + } + } + } + }` + template, _ := goformation.ParseJSON([]byte(input)) + + function := &cloudformation.AWSServerlessFunction{ + Runtime: "nodejs6.10", + Events: map[string]cloudformation.AWSServerlessFunction_EventSource{ + "PostRequest": { + Type: "Api", + Properties: &cloudformation.AWSServerlessFunction_Properties{ + ApiEvent: &cloudformation.AWSServerlessFunction_ApiEvent{ + Path: "/post", + Method: "post", + }, + }, + }, + }, + } + templateApis := template.GetAllAWSServerlessApiResources() + + It("returns the base64 encoded body on a binary request", func() { + mux := NewServerlessRouter(false) + + for _, api := range templateApis { + err := mux.AddAPI(&api) + Expect(err).To(BeNil()) + } + data := []byte{'\xe3'} + req, _ := http.NewRequest("POST", "/post", bytes.NewReader(data)) + req.Header.Add("Content-Type", "multipart/form-data; boundary=something") + + mux.AddFunction(function, func(w http.ResponseWriter, e *Event) { + Expect(string(e.Body)).To(Equal(base64.StdEncoding.EncodeToString(data))) + }) + + rec := httptest.NewRecorder() + mux.Router().ServeHTTP(rec, req) + }) + + It("returns the text body on a text request", func() { + mux := NewServerlessRouter(false) + + for _, api := range templateApis { + err := mux.AddAPI(&api) + Expect(err).To(BeNil()) + } + text := "foo" + req, _ := http.NewRequest("POST", "/post", strings.NewReader(text)) + req.Header.Add("Content-Type", "multipart/form-data; boundary=something") + + mux.AddFunction(function, func(w http.ResponseWriter, e *Event) { + Expect(string(e.Body)).To(Equal(text)) + }) + + rec := httptest.NewRecorder() + mux.Router().ServeHTTP(rec, req) + }) + }) }) diff --git a/runtime.go b/runtime.go index 60c714c1c6cc..91cb37c7c4d8 100644 --- a/runtime.go +++ b/runtime.go @@ -6,6 +6,7 @@ import ( "io" "io/ioutil" "log" + "mime" "net/http" "os" "path/filepath" @@ -18,6 +19,7 @@ import ( "strings" + "encoding/base64" "encoding/json" "fmt" "path" @@ -25,6 +27,7 @@ import ( "os/signal" "syscall" + "github.com/awslabs/aws-sam-local/router" "github.com/awslabs/goformation/cloudformation" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -43,7 +46,7 @@ import ( // Invoker is a simple interface to help with testing runtimes type Invoker interface { Invoke(string, string) (io.Reader, io.Reader, error) - InvokeHTTP(string) func(http.ResponseWriter, *http.Request) + InvokeHTTP(string) func(http.ResponseWriter, *router.Event) CleanUp() } @@ -502,21 +505,15 @@ func (r *Runtime) CleanUp() { } -// InvokeHTTP invokes a Lambda function, and implements the Go http.HandlerFunc interface -// so it can be connected straight into most HTTP packages/frameworks etc. -func (r *Runtime) InvokeHTTP(profile string) func(http.ResponseWriter, *http.Request) { +// InvokeHTTP invokes a Lambda function. +func (r *Runtime) InvokeHTTP(profile string) func(http.ResponseWriter, *router.Event) { - return func(w http.ResponseWriter, req *http.Request) { + return func(w http.ResponseWriter, event *router.Event) { var wg sync.WaitGroup w.Header().Set("Content-Type", "application/json") - - event, err := NewEvent(req) - if err != nil { - msg := fmt.Sprintf("Error invoking %s runtime: %s", r.Function.Runtime, err) - log.Println(msg) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{ "message": "Internal server error" }`)) - return + acceptHeader, ok := event.Headers["Accept"] + if !ok { + acceptHeader = "" } eventJSON, err := event.JSON() @@ -540,7 +537,7 @@ func (r *Runtime) InvokeHTTP(profile string) func(http.ResponseWriter, *http.Req wg.Add(1) var output []byte go func() { - output = parseOutput(w, stdoutTxt, r.Function.Runtime, &wg) + output = parseOutput(w, stdoutTxt, r.Function.Runtime, &wg, acceptHeader) }() wg.Add(1) @@ -560,7 +557,7 @@ func (r *Runtime) InvokeHTTP(profile string) func(http.ResponseWriter, *http.Req // parseOutput decodes the proxy response from the output of the function and returns // the rest -func parseOutput(w http.ResponseWriter, stdoutTxt io.Reader, runtime string, wg *sync.WaitGroup) (output []byte) { +func parseOutput(w http.ResponseWriter, stdoutTxt io.Reader, runtime string, wg *sync.WaitGroup, acceptHeader string) (output []byte) { defer wg.Done() result, err := ioutil.ReadAll(stdoutTxt) @@ -576,9 +573,10 @@ func parseOutput(w http.ResponseWriter, stdoutTxt io.Reader, runtime string, wg // of a Lambda proxy response (inc statusCode / body), and if so, handle it // otherwise just copy the whole output back to the http.ResponseWriter proxy := &struct { - StatusCode json.Number `json:"statusCode"` - Headers map[string]string `json:"headers"` - Body json.Number `json:"body"` + StatusCode json.Number `json:"statusCode"` + Headers map[string]string `json:"headers"` + Body json.Number `json:"body"` + IsBase64Encoded bool `json:"isBase64Encoded"` }{} // We only want the last line of stdout, because it's possible that @@ -613,7 +611,26 @@ func parseOutput(w http.ResponseWriter, stdoutTxt io.Reader, runtime string, wg w.WriteHeader(int(statusCode)) } - w.Write([]byte(proxy.Body)) + acceptMediaTypeMatched := false + if acceptHeader != "" { + //API Gateway only honors the first Accept media type. + acceptMediaType := strings.Split(acceptHeader, ",")[0] + contentType := proxy.Headers["Content-Type"] + contentMediaType, _, err := mime.ParseMediaType(contentType) + acceptMediaTypeMatched = err == nil && acceptMediaType == contentMediaType + } + + if proxy.IsBase64Encoded && acceptMediaTypeMatched { + if decodedBytes, err := base64.StdEncoding.DecodeString(string(proxy.Body)); err != nil { + log.Printf(color.RedString("Function returned an invalid base64 body: %s\n"), err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{ "message": "Internal server error" }`)) + } else { + w.Write(decodedBytes) + } + } else { + w.Write([]byte(proxy.Body)) + } return } diff --git a/runtime_test.go b/runtime_test.go index 440172dcaeab..29f378b1af15 100644 --- a/runtime_test.go +++ b/runtime_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "fmt" "io" "net/http" @@ -67,16 +68,17 @@ var _ = Describe("sam", func() { var r *fakeResponse inputs := []struct { - name string - output io.Reader - body []byte - status int - headers http.Header - trailing string + name string + output io.Reader + body []byte + status int + headers http.Header + trailing string + acceptHeader string }{ { name: "only proxy response", - output: strings.NewReader(`{"statusCode":202,"headers":{"foo":"bar"},"body":"{\"nextToken\":null,\"beers\":[]}","base64Encoded":false}`), + output: strings.NewReader(`{"statusCode":202,"headers":{"foo":"bar"},"body":"{\"nextToken\":null,\"beers\":[]}","isBase64Encoded":false}`), body: []byte(`{"nextToken":null,"beers":[]}`), status: 202, headers: http.Header(map[string][]string{"foo": []string{"bar"}}), @@ -85,7 +87,7 @@ var _ = Describe("sam", func() { name: "proxy response with extra output", output: strings.NewReader(`Foo Bar - {"statusCode":200,"headers":null,"body":"{\"nextToken\":null,\"beers\":[]}","base64Encoded":false}`), + {"statusCode":200,"headers":null,"body":"{\"nextToken\":null,\"beers\":[]}","isBase64Encoded":false}`), body: []byte(`{"nextToken":null,"beers":[]}`), status: 200, headers: make(http.Header), @@ -101,7 +103,7 @@ var _ = Describe("sam", func() { }, { name: "bad status code", - output: strings.NewReader(`{"statusCode":"xxx","headers":null,"body":"{\"nextToken\":null,\"beers\":[]}","base64Encoded":false}`), + output: strings.NewReader(`{"statusCode":"xxx","headers":null,"body":"{\"nextToken\":null,\"beers\":[]}","isBase64Encoded":false}`), body: []byte(`{"nextToken":null,"beers":[]}`), status: 502, headers: make(http.Header), @@ -113,6 +115,38 @@ var _ = Describe("sam", func() { status: 500, headers: make(http.Header), }, + { + name: "base64 encoded response and Accept and Content-Type media types are matched", + output: strings.NewReader(fmt.Sprintf(`{"statusCode":200,"headers":{"Content-Type": "multipart/form-data; boundary=something"},"body": "%v","isBase64Encoded":true}`, base64.StdEncoding.EncodeToString([]byte("abc")))), + body: []byte("abc"), + status: 200, + headers: http.Header(map[string][]string{"Content-Type": []string{"multipart/form-data; boundary=something"}}), + acceptHeader: "multipart/form-data, application/foo", + }, + { + name: "invalid base64 encoded response and Accept and Content-Type media types are matched", + output: strings.NewReader(fmt.Sprintf(`{"statusCode":200,"headers":{"Content-Type":"multipart/form-data; boundary=something"},"body": "a","isBase64Encoded":true}`)), + body: []byte(`{ "message": "Internal server error" }`), + status: 500, + headers: http.Header(map[string][]string{"Content-Type": []string{"multipart/form-data; boundary=something"}}), + acceptHeader: "multipart/form-data, application/foo", + }, + { + name: "base64 encoded response but Accept and Content-Type media types are not matched", + output: strings.NewReader(fmt.Sprintf(`{"statusCode":200,"headers":{"Content-Type": "multipart/form-data; boundary=something"},"body": "%v","isBase64Encoded":true}`, base64.StdEncoding.EncodeToString([]byte("abc")))), + body: []byte(base64.StdEncoding.EncodeToString([]byte("abc"))), + status: 200, + headers: http.Header(map[string][]string{"Content-Type": []string{"multipart/form-data; boundary=something"}}), + acceptHeader: "application/foo", + }, + { + name: "base64 encoded response and empty Accept and Content-Type headers", + output: strings.NewReader(fmt.Sprintf(`{"statusCode":200,"headers":{"Content-Type": ""},"body": "%v","isBase64Encoded":true}`, base64.StdEncoding.EncodeToString([]byte("abc")))), + body: []byte(base64.StdEncoding.EncodeToString([]byte("abc"))), + status: 200, + headers: http.Header(map[string][]string{"Content-Type": []string{""}}), + acceptHeader: "", + }, } for _, input := range inputs { @@ -120,7 +154,7 @@ var _ = Describe("sam", func() { Context(input.name, func() { wg.Add(1) r = newResponse() - out = parseOutput(r, input.output, "foo", &wg) + out = parseOutput(r, input.output, "foo", &wg, input.acceptHeader) It("should have the expected output", func() { Expect(r.status).To(Equal(input.status))