diff --git a/README.md b/README.md index aa538fa..de2138c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Killgrave is a simulator for HTTP-based APIs, in simple words a **Mock Server**, * [Create an imposter using JSON Schema](#create-an-imposter-using-json-schema) * [Create an imposter with delay](#create-an-imposter-with-delay) * [Create an imposter with dynamic responses](#create-an-imposter-with-dynamic-responses) + * [Create an imposter with repeated responses](#create-an-imposter-with-repeated-responses) - [Contributing](#contributing) - [License](#license) @@ -266,13 +267,15 @@ We use a rule-based system to match requests to imposters. Therefore, you have t "method": "GET", "endpoint": "/gophers/01D8EMQ185CA8PRGE20DKZTGSR" }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"data\":{\"type\":\"gophers\",\"id\":\"01D8EMQ185CA8PRGE20DKZTGSR\",\"attributes\":{\"name\":\"Zebediah\",\"color\":\"Purples\",\"age\":55}}}" - } + "responses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"data\":{\"type\":\"gophers\",\"id\":\"01D8EMQ185CA8PRGE20DKZTGSR\",\"attributes\":{\"name\":\"Zebediah\",\"color\":\"Purples\",\"age\":55}}}" + } + ] } ] ``` @@ -286,7 +289,7 @@ This a very simple example. Killgrave has more possibilities for configuring imp The imposter object can be divided in two parts: * [Request](#request) -* [Response](#response) +* [Responses](#responses) #### Request @@ -298,10 +301,11 @@ This part defines how Killgrave should determine whether an incoming request mat * `params`: Restrict incoming requests by query parameters. More info can be found [here](#create-an-imposter-with-query-params). Supports regex. * `headers`: Restrict incoming requests by HTTP header. More info can be found [here](#create-an-imposter-with-headers). -#### Response +#### Responses This part defines how Killgrave should respond to the incoming request. The `response` object has the following properties: + * `status` (mandatory): Integer defining the HTTP status to return. * `body` or `bodyFile`: The response body. Either a literal string (`body`) or a path to a file (`bodyFile`). `bodyFile` is especially useful in the case of large outputs. This property is optional: if not response body should be returned it should be removed or left empty. @@ -327,13 +331,15 @@ In the next example, we have configured an endpoint to match with any kind of [U "method": "GET", "endpoint": "/gophers/{_id:[\\w]{26}}" }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"data\":{\"type\":\"gophers\",\"id\":\"01D8EMQ185CA8PRGE20DKZTGSR\",\"attributes\":{\"name\":\"Zebediah\",\"color\":\"Purples\",\"age\":55}}}" - } + "responses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"data\":{\"type\":\"gophers\",\"id\":\"01D8EMQ185CA8PRGE20DKZTGSR\",\"attributes\":{\"name\":\"Zebediah\",\"color\":\"Purples\",\"age\":55}}}" + } + ] } ] ``` @@ -354,13 +360,15 @@ In this example, we have configured an imposter that only matches if we receive "apiKey": "{_apiKey:[\\w]+}" } }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"data\":{\"type\":\"gophers\",\"id\":\"01D8EMQ185CA8PRGE20DKZTGSR\",\"attributes\":{\"name\":\"Zebediah\",\"color\":\"Purples\",\"age\":55}}}" - } + "responses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"data\":{\"type\":\"gophers\",\"id\":\"01D8EMQ185CA8PRGE20DKZTGSR\",\"attributes\":{\"name\":\"Zebediah\",\"color\":\"Purples\",\"age\":55}}}" + } + ] } ] ``` @@ -381,13 +389,15 @@ In the next example, we have configured an imposter that uses regex to match an "Authorization": "\\w+" } }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"data\":{\"type\":\"gophers\",\"id\":\"01D8EMQ185CA8PRGE20DKZTGSR\",\"attributes\":{\"name\":\"Zebediah\",\"color\":\"Purples\",\"age\":55}}}" - } + "responses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"data\":{\"type\":\"gophers\",\"id\":\"01D8EMQ185CA8PRGE20DKZTGSR\",\"attributes\":{\"name\":\"Zebediah\",\"color\":\"Purples\",\"age\":55}}}" + } + ] } ] ``` @@ -474,12 +484,14 @@ Then our imposter will be configured as follows: "Content-Type": "application/json" } }, - "response": { - "status": 201, - "headers": { - "Content-Type": "application/json" + "responses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + } } - } + ] } ] ```` @@ -507,13 +519,15 @@ For example, we can modify our previous POST call to add a `delay` to determine "Content-Type": "application/json" } }, - "response": { - "status": 201, - "headers": { - "Content-Type": "application/json" - }, - "delay": "1s:5s" - } + "responses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "delay": "1s:5s" + } + ] } ] ```` @@ -537,28 +551,115 @@ In the following example, we have defined multiple imposters for the `POST /goph "Content-Type": "application/json" } }, - "response": { - "status": 201, - "headers": { - "Content-Type": "application/json" + "responses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + } } - } + ] }, { "request": { "method": "POST", "endpoint": "/gophers" }, - "response": { - "status": 400, + "responses": [ + { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"errors\":\"bad request\"}" + } + ] + } +] +```` +So as you can see, we have first of all the imposters with the restrictive, `headers` and `json schema`, but +our last `imposter` is a simple `imposter` that it will match with any call via POST to `/gophers`. + +### Create an Imposter with repeated responses + +Killgrave allow repeatable/random responses, with this feature we can use one endpoint and obtain repeatable responses based on the settings provided by the user in imposter config. + +There are totally two new fields which needs to be configured for this feature. +1. `request` -> `responseMode` + - `RANDOM` and `BURST` are valid values + - `RANDOM` is default if field is not provided or any other value is provided. +2. `response` -> `burst` (applicable only in `BURST` mode, ignored in) + - +ve integer are valid values. + - applicable only in `BURST` mode, ignored in `RANDOM` mode. + - default value in `BURST` mode is 1, if the field is missing. + - 0 or -ve values will be converted to 1 + +For example let's consider an imposter config that will give us random response. +````json +[ + { + "request": { + "method": "GET", + "endpoint": "/gophers", + "responseMode": "RANDOM" + }, + "responses": [ + { + "status": 201, "headers": { "Content-Type": "application/json" }, - "body": "{\"errors\":\"bad request\"}" - } + "body": "Response 1" + }, + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": "Response 2" + } + ] } ] ```` +As you can see in the above request `responseMode` is `RANDOM` and a call to /gophers will generate random responses from array of responses. So for some requests you'll get `Response 1` and for others you'll get `Response 2` randomly. In the request if `responseMode` is missing or have any other value than what is expected, then it'll act same as before (randomly). + + +Now, let's consider an imposter example which will give us repeated responses. +````json +[ + { + "request": { + "method": "GET", + "endpoint": "/gophers", + "responseMode": "BURST" + }, + "responses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": "Response 1", + "burst": 1 + }, + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": "Response 2", + "burst": 2 + } + ] + } +] +```` +As you can see in the above request `responseMode` is `BURST` and a call to /gophers will generate repeated responses from array of responses. So for first request you'll get `Response 1` and for next 2 requests you'll get `Response 2`. It'll repeate responses from response 1 afterwards. + +For e.g. a call to /gophers above will give responses in the following order:
+`Response 1` -> `Response 2` -> `Response 2` -> `Response 1` -> `Response 2` -> `Response 2` ... + ## Contributing [Contributions](CONTRIBUTING.md) are more than welcome, if you are interested please follow our guidelines to help you get started. diff --git a/go.sum b/go.sum index c0f305a..6dbedd2 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,7 @@ github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8t github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= @@ -31,7 +30,7 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 83ee85a..d753316 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -11,30 +11,32 @@ import ( // ImposterHandler create specific handler for the received imposter func ImposterHandler(imposter Imposter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if imposter.Delay() > 0 { - time.Sleep(imposter.Delay()) + response := imposter.GetResponse() + + if response.Delay() > 0 { + time.Sleep(response.Delay()) } - writeHeaders(imposter, w) - w.WriteHeader(imposter.Response.Status) - writeBody(imposter, w) + writeHeaders(response, w) + w.WriteHeader(response.Status) + writeBody(imposter, &response, w) } } -func writeHeaders(imposter Imposter, w http.ResponseWriter) { - if imposter.Response.Headers == nil { +func writeHeaders(response Response, w http.ResponseWriter) { + if response.Headers == nil { return } - for key, val := range *imposter.Response.Headers { + for key, val := range *response.Headers { w.Header().Set(key, val) } } -func writeBody(imposter Imposter, w http.ResponseWriter) { - wb := []byte(imposter.Response.Body) +func writeBody(imposter Imposter, response *Response, w http.ResponseWriter) { + wb := []byte(response.Body) - if imposter.Response.BodyFile != nil { - bodyFile := imposter.CalculateFilePath(*imposter.Response.BodyFile) + if response.BodyFile != nil { + bodyFile := imposter.CalculateFilePath(*response.BodyFile) wb = fetchBodyFromFile(bodyFile) } w.Write(wb) diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index 1a35be1..0cf0f56 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -45,9 +45,9 @@ func TestImposterHandler(t *testing.T) { expectedBody string statusCode int }{ - {"valid imposter with body", Imposter{Request: validRequest, Response: Response{Status: http.StatusOK, Headers: &headers, Body: body}}, body, http.StatusOK}, - {"valid imposter with bodyFile", Imposter{Request: validRequest, Response: Response{Status: http.StatusOK, Headers: &headers, BodyFile: &bodyFile}}, string(expectedBodyFileData), http.StatusOK}, - {"valid imposter with not exists bodyFile", Imposter{Request: validRequest, Response: Response{Status: http.StatusOK, Headers: &headers, BodyFile: &bodyFileFake}}, "", http.StatusOK}, + {"valid imposter with body", Imposter{Request: validRequest, Responses: []Response{{Status: http.StatusOK, Headers: &headers, Body: body}}}, body, http.StatusOK}, + {"valid imposter with bodyFile", Imposter{Request: validRequest, Responses: []Response{{Status: http.StatusOK, Headers: &headers, BodyFile: &bodyFile}}}, string(expectedBodyFileData), http.StatusOK}, + {"valid imposter with not exists bodyFile", Imposter{Request: validRequest, Responses: []Response{{Status: http.StatusOK, Headers: &headers, BodyFile: &bodyFileFake}}}, "", http.StatusOK}, } for _, tt := range dataTest { @@ -90,7 +90,7 @@ func TestInvalidRequestWithSchema(t *testing.T) { statusCode int request []byte }{ - {"valid request no schema", Imposter{Request: Request{Method: "POST", Endpoint: "/gophers"}, Response: Response{Status: http.StatusOK, Body: "test ok"}}, http.StatusOK, validRequest}, + {"valid request no schema", Imposter{Request: Request{Method: "POST", Endpoint: "/gophers"}, Responses: []Response{{Status: http.StatusOK, Body: "test ok"}}}, http.StatusOK, validRequest}, } for _, tt := range dataTest { diff --git a/internal/server/http/imposter.go b/internal/server/http/imposter.go index 88c8b1a..4325ff2 100644 --- a/internal/server/http/imposter.go +++ b/internal/server/http/imposter.go @@ -6,6 +6,7 @@ import ( "path" "path/filepath" "strings" + "sync" "time" ) @@ -33,14 +34,13 @@ type ImposterConfig struct { // Imposter define an imposter structure type Imposter struct { - BasePath string - Request Request `json:"request"` - Response Response `json:"response"` -} + BasePath string + Request Request `json:"request"` + Responses []Response `json:"responses"` -// Delay returns delay for response that user can specify in imposter config -func (i *Imposter) Delay() time.Duration { - return i.Response.Delay.Delay() + // Field for handling burst response + responseHandler ResponseHandler + sync.Mutex } // CalculateFilePath calculate file path based on basePath of imposter directory @@ -48,13 +48,34 @@ func (i *Imposter) CalculateFilePath(filePath string) string { return path.Join(i.BasePath, filePath) } +// GetResponse is used to get dynamic/random response. +func (i *Imposter) GetResponse() Response { + i.Lock() + defer i.Unlock() + // If no response provided then returning 404 response + if len(i.Responses) == 0 { + return Response{Status: 404} + } + if i.responseHandler.scheduleMap == nil { + i.fillDefaults() + } + index := i.responseHandler.GetIndex() + return i.Responses[index] +} + +// fillDefaults is used to populate default values for imposters fields. +func (i *Imposter) fillDefaults() { + i.responseHandler.fillDefaults(i) +} + // Request represent the structure of real request type Request struct { - Method string `json:"method"` - Endpoint string `json:"endpoint"` - SchemaFile *string `json:"schemaFile"` - Params *map[string]string `json:"params"` - Headers *map[string]string `json:"headers"` + Method string `json:"method"` + Endpoint string `json:"endpoint"` + SchemaFile *string `json:"schemaFile"` + Params *map[string]string `json:"params"` + Headers *map[string]string `json:"headers"` + ResponseMode string `json:"responseMode"` } // Response represent the structure of real response @@ -63,7 +84,13 @@ type Response struct { Body string `json:"body"` BodyFile *string `json:"bodyFile" yaml:"bodyFile"` Headers *map[string]string `json:"headers"` - Delay ResponseDelay `json:"delay" yaml:"delay"` + RDelay ResponseDelay `json:"delay" yaml:"delay"` + Burst int `json:"burst" yaml:"burst"` +} + +// Delay returns delay for response that user can specify in imposter config +func (r *Response) Delay() time.Duration { + return r.RDelay.Delay() } func findImposters(impostersDirectory string, imposterConfigCh chan ImposterConfig) error { diff --git a/internal/server/http/response_handler.go b/internal/server/http/response_handler.go new file mode 100644 index 0000000..59e96c7 --- /dev/null +++ b/internal/server/http/response_handler.go @@ -0,0 +1,94 @@ +package http + +import ( + "math/rand" +) + +// ResponseMode represents random/burst mode for the response +type ResponseMode int + +// ResponseHandler handles incoming request for random/burst response +type ResponseHandler struct { + Mode ResponseMode // flag for random mode + totalResp int // total Responses available in imposters + + counter int // to keep count of served requests (wrapping after totalResp) + currentInd int // index/key of current response in scheduleMap + scheduleMap []int // prefix array of repeating request +} + +const ( + // RandomMode will generate random responses + RandomMode ResponseMode = iota + // BurstMode will generate repeatable responses + BurstMode +) + +// fillDefaults populates values based on imposter configuration +func (rh *ResponseHandler) fillDefaults(imposter *Imposter) { + // Updating totalResponse length + rh.totalResp = len(imposter.Responses) + + // Updating Response Mode + switch imposter.Request.ResponseMode { + case "BURST": + rh.Mode = BurstMode + default: + rh.Mode = RandomMode + } + + // Populating state for BURST mode + if rh.Mode == BurstMode { + var scheduleMap = make([]int, len(imposter.Responses)) + for ind, resp := range imposter.Responses { + value := resp.Burst + if value <= 0 { + value = 1 + } + if ind != 0 { + scheduleMap[ind] = scheduleMap[ind-1] + value + } else { + scheduleMap[ind] = value + } + } + + rh.scheduleMap = scheduleMap + rh.counter = 1 + rh.currentInd = 0 + } +} + +// GetIndex is responsible for getting index for current request +func (rh *ResponseHandler) GetIndex() int { + if rh.Mode == RandomMode { + return rh.getRandomIndex() + } + ind := rh.getBurstIndex() + + return ind +} + +// getRandomIndex generates random indexes in random mode +func (rh *ResponseHandler) getRandomIndex() int { + return rand.Intn(rh.totalResp) +} + +// getBurstIndex generates repeated index based on the config provided +func (rh *ResponseHandler) getBurstIndex() int { + var index = rh.currentInd + + rh.counter++ // incrementing counter for current request + + // checking if it has to move to next response or not + if rh.scheduleMap[rh.currentInd] < rh.counter { + rh.currentInd++ + } + + // Wrapping logic for counter and index + if rh.currentInd > rh.totalResp-1 { + rh.currentInd = 0 + rh.counter = 1 + } + + return index // returning current request +} diff --git a/internal/server/http/response_handler_test.go b/internal/server/http/response_handler_test.go new file mode 100644 index 0000000..5671845 --- /dev/null +++ b/internal/server/http/response_handler_test.go @@ -0,0 +1,205 @@ +package http + +import ( + "bytes" + "net/http" + "net/http/httptest" + "sync" + "testing" +) + +func TestRepeatingResponse(t *testing.T) { + var serverData = []struct { + name string + imposter Imposter + expectedBodies []string + }{ + { + "repeating response with burst", + Imposter{ + Request: Request{Method: "GET", Endpoint: "/burst", ResponseMode: "BURST"}, + Responses: []Response{{Status: 201, Body: "Response 1", Burst: 1}, {Status: 201, Body: "Response 2", Burst: 2}}, + }, + []string{"Response 1", "Response 2", "Response 2", "Response 1", "Response 2", "Response 2", "Response 1"}, + }, + { + "repeating response without burst", // Default value checking + Imposter{ + Request: Request{Method: "GET", Endpoint: "/repeat", ResponseMode: "BURST"}, + Responses: []Response{{Status: 201, Body: "Response 1"}, {Status: 201, Body: "Response 2"}}, + }, + []string{"Response 1", "Response 2", "Response 1", "Response 2", "Response 1"}, + }, + } + + for _, tt := range serverData { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(tt.imposter.Request.Method, tt.imposter.Request.Endpoint, bytes.NewBuffer(nil)) + if err != nil { + t.Fatalf("could not created request: %v", err) + } + rec := httptest.NewRecorder() + handler := http.HandlerFunc(ImposterHandler(tt.imposter)) + + for i := 0; i < len(tt.expectedBodies); i++ { + handler.ServeHTTP(rec, req) + expectedBody := tt.expectedBodies[i] + actualBody := rec.Body.String() + if expectedBody != actualBody { + t.Fatalf("test-%s expected body is '%s' and got '%s'", tt.name, expectedBody, actualBody) + } + rec.Body.Reset() + } + }) + } +} + +// Checking if valid responses are being generated. +func TestRandomResponse(t *testing.T) { + var serverData = []struct { + name string + imposter Imposter + }{ + { + "random responses", + Imposter{ + Request: Request{Method: "GET", Endpoint: "/random", ResponseMode: "RANDOM"}, + Responses: []Response{{Status: 201, Body: "Response 1"}, {Status: 201, Body: "Response 2"}}, + }, + }, + { + "random responses with burst", + Imposter{ + Request: Request{Method: "GET", Endpoint: "/random", ResponseMode: "RANDOM"}, + Responses: []Response{{Status: 201, Body: "Response 1", Burst: 1}, {Status: 201, Body: "Response 2", Burst: 2}}, + }, + }, + { + "random responses without response mode", // Default value checking + Imposter{ + Request: Request{Method: "GET", Endpoint: "/random"}, + Responses: []Response{{Status: 201, Body: "Response 1"}, {Status: 201, Body: "Response 2"}}, + }, + }, + { + "random responses with more than 2 responses", + Imposter{ + Request: Request{Method: "GET", Endpoint: "/random", ResponseMode: "BURST"}, + Responses: []Response{{Status: 201, Body: "Response 1"}, {Status: 201, Body: "Response 2"}, {Status: 201, Body: "Response 3"}, {Status: 201, Body: "Response 4"}}, + }, + }, + } + + for _, tt := range serverData { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(tt.imposter.Request.Method, tt.imposter.Request.Endpoint, bytes.NewBuffer(nil)) + if err != nil { + t.Fatalf("could not created request: %v", err) + } + rec := httptest.NewRecorder() + handler := http.HandlerFunc(ImposterHandler(tt.imposter)) + expectedRespMap := map[string]bool{} + for i := 0; i < len(tt.imposter.Responses); i++ { + expectedRespMap[tt.imposter.Responses[i].Body] = true + } + + for i := 0; i < 10; i++ { + handler.ServeHTTP(rec, req) + actualBody := rec.Body.String() + + if _, ok := expectedRespMap[actualBody]; !ok { + t.Fatalf("test-%s invalid response body: '%s'", tt.name, actualBody) + } + + rec.Body.Reset() + } + }) + } +} + +func Test404Response(t *testing.T) { + var serverData = []struct { + name string + imposter Imposter + statusCode int + }{ + { + "no response available burst mode", + Imposter{ + Request: Request{Method: "GET", Endpoint: "/burst", ResponseMode: "BURST"}, + }, + http.StatusNotFound, + }, + { + "no response available random mode", + Imposter{ + Request: Request{Method: "GET", Endpoint: "/random", ResponseMode: "RANDOM"}, + }, + http.StatusNotFound, + }, + } + + for _, tt := range serverData { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(tt.imposter.Request.Method, tt.imposter.Request.Endpoint, bytes.NewBuffer(nil)) + if err != nil { + t.Fatalf("could not created request: %v", err) + } + rec := httptest.NewRecorder() + handler := http.HandlerFunc(ImposterHandler(tt.imposter)) + + handler.ServeHTTP(rec, req) + if rec.Code != tt.statusCode { + t.Fatalf("test-%s expected status code is '%d' and got '%d'", tt.name, tt.statusCode, rec.Code) + } + }) + } +} + +func TestConcurrentRequests(t *testing.T) { + var serverData = []struct { + name string + imposter Imposter + loopFor int + expectedBody string + }{ + { + "concurrent requests using go routines", + Imposter{ + Request: Request{Method: "GET", Endpoint: "/burst", ResponseMode: "BURST"}, + Responses: []Response{{Status: 201, Body: "Response 1", Burst: 1}, {Status: 201, Body: "Response 2", Burst: 2}, {Status: 201, Body: "Response 3", Burst: 3}}, + }, + 12, + "Response 1", + }, + } + + for _, tt := range serverData { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(tt.imposter.Request.Method, tt.imposter.Request.Endpoint, bytes.NewBuffer(nil)) + if err != nil { + t.Fatalf("could not created request: %v", err) + } + rec := httptest.NewRecorder() + handler := http.HandlerFunc(ImposterHandler(tt.imposter)) + + var wg sync.WaitGroup + + for i := 0; i < tt.loopFor; i++ { + wg.Add(1) + go func(wg *sync.WaitGroup) { + defer wg.Done() + handler.ServeHTTP(rec, req) + }(&wg) + } + + wg.Wait() + rec.Body.Reset() + handler.ServeHTTP(rec, req) + actualBody := rec.Body.String() + if actualBody != tt.expectedBody { + t.Fatalf("test-%s expected body is '%s' and got '%s'", tt.name, tt.expectedBody, actualBody) + } + }) + } +} diff --git a/internal/server/http/route_matchers_test.go b/internal/server/http/route_matchers_test.go index 1095837..f854129 100644 --- a/internal/server/http/route_matchers_test.go +++ b/internal/server/http/route_matchers_test.go @@ -53,13 +53,13 @@ func TestMatcherBySchema(t *testing.T) { req *http.Request res bool }{ - "correct request schema": {MatcherBySchema(Imposter{Request: requestWithSchema, Response: okResponse}), httpRequestA, true}, - "imposter without request schema": {MatcherBySchema(Imposter{Request: requestWithoutSchema, Response: okResponse}), httpRequestA, true}, - "malformatted schema file": {MatcherBySchema(Imposter{Request: requestWithWrongSchema, Response: okResponse}), httpRequestA, false}, - "incorrect request schema": {MatcherBySchema(Imposter{Request: requestWithSchema, Response: okResponse}), httpRequestB, false}, - "non-existing schema file": {MatcherBySchema(Imposter{Request: requestWithNonExistingSchema, Response: okResponse}), httpRequestB, false}, - "empty body with required schema file": {MatcherBySchema(Imposter{Request: requestWithSchema, Response: okResponse}), &http.Request{Body: emptyBody}, false}, - "invalid request body": {MatcherBySchema(Imposter{Request: requestWithSchema, Response: okResponse}), &http.Request{Body: wrongBody}, false}, + "correct request schema": {MatcherBySchema(Imposter{Request: requestWithSchema, Responses: []Response{okResponse}}), httpRequestA, true}, + "imposter without request schema": {MatcherBySchema(Imposter{Request: requestWithoutSchema, Responses: []Response{okResponse}}), httpRequestA, true}, + "malformatted schema file": {MatcherBySchema(Imposter{Request: requestWithWrongSchema, Responses: []Response{okResponse}}), httpRequestA, false}, + "incorrect request schema": {MatcherBySchema(Imposter{Request: requestWithSchema, Responses: []Response{okResponse}}), httpRequestB, false}, + "non-existing schema file": {MatcherBySchema(Imposter{Request: requestWithNonExistingSchema, Responses: []Response{okResponse}}), httpRequestB, false}, + "empty body with required schema file": {MatcherBySchema(Imposter{Request: requestWithSchema, Responses: []Response{okResponse}}), &http.Request{Body: emptyBody}, false}, + "invalid request body": {MatcherBySchema(Imposter{Request: requestWithSchema, Responses: []Response{okResponse}}), &http.Request{Body: wrongBody}, false}, } for name, tt := range matcherData { diff --git a/internal/server/http/test/testdata/imposters/create_gopher.imp.json b/internal/server/http/test/testdata/imposters/create_gopher.imp.json index 91bd9a5..b850b29 100644 --- a/internal/server/http/test/testdata/imposters/create_gopher.imp.json +++ b/internal/server/http/test/testdata/imposters/create_gopher.imp.json @@ -11,13 +11,13 @@ "gopherColor": "{v:[a-z]+}" } }, - "response": { + "responses": [{ "status": 200, "headers": { "Content-Type": "application/json" }, "bodyFile": "responses/create_gopher_response.json" - } + }] }, { "t": "random_text" diff --git a/internal/server/http/test/testdata/imposters/test_request.imp.json b/internal/server/http/test/testdata/imposters/test_request.imp.json index 09d9403..7f59f20 100644 --- a/internal/server/http/test/testdata/imposters/test_request.imp.json +++ b/internal/server/http/test/testdata/imposters/test_request.imp.json @@ -4,9 +4,9 @@ "method": "GET", "endpoint": "/testRequest" }, - "response": { + "responses": [{ "status": 200, "body": "Handled" - } + }] } ] diff --git a/internal/server/http/test/testdata/imposters/test_request.imp.yaml b/internal/server/http/test/testdata/imposters/test_request.imp.yaml index 0d49b8e..90c038b 100644 --- a/internal/server/http/test/testdata/imposters/test_request.imp.yaml +++ b/internal/server/http/test/testdata/imposters/test_request.imp.yaml @@ -2,6 +2,6 @@ - request: method: GET endpoint: /yamlTestRequest - response: - status: 200 - body: "Yaml Handled" \ No newline at end of file + responses: + - status: 200 + body: "Yaml Handled" \ No newline at end of file diff --git a/internal/server/http/test/testdata/imposters/test_request.imp.yml b/internal/server/http/test/testdata/imposters/test_request.imp.yml index 4f73d62..dc11ae8 100644 --- a/internal/server/http/test/testdata/imposters/test_request.imp.yml +++ b/internal/server/http/test/testdata/imposters/test_request.imp.yml @@ -2,20 +2,20 @@ - request: method: GET endpoint: /ymlTestRequest - response: - status: 200 - body: "Yml Handled" - delay: "1s:5s" + responses: + - status: 200 + body: "Yml Handled" + delay: "1s:5s" - request: method: POST endpoint: /yamlGophers schemaFile: "schemas/create_gopher_request.json" headers: "Content-Type": "application/json" - response: - status: 201 - headers: - "Content-Type": "application/json" - "X-Source": "YAML" - bodyFile: "responses/create_gopher_response.json" + responses: + - status: 201 + headers: + "Content-Type": "application/json" + "X-Source": "YAML" + bodyFile: "responses/create_gopher_response.json" - t: "random_text" diff --git a/internal/server/http/test/testdata/imposters_secure/test_request.imp.json b/internal/server/http/test/testdata/imposters_secure/test_request.imp.json index 0df11d8..70cd706 100644 --- a/internal/server/http/test/testdata/imposters_secure/test_request.imp.json +++ b/internal/server/http/test/testdata/imposters_secure/test_request.imp.json @@ -4,9 +4,9 @@ "method": "GET", "endpoint": "/testHTTPSRequest" }, - "response": { + "responses": [{ "status": 200, "body": "Handled" - } + }] } ]