diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 83ee85a..5108d6c 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -9,32 +9,33 @@ import ( ) // ImposterHandler create specific handler for the received imposter -func ImposterHandler(imposter Imposter) http.HandlerFunc { +func ImposterHandler(i Imposter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if imposter.Delay() > 0 { - time.Sleep(imposter.Delay()) + res := i.NextResponse() + if res.Delay.Delay() > 0 { + time.Sleep(res.Delay.Delay()) } - writeHeaders(imposter, w) - w.WriteHeader(imposter.Response.Status) - writeBody(imposter, w) + writeHeaders(res, w) + w.WriteHeader(res.Status) + writeBody(i, res, w) } } -func writeHeaders(imposter Imposter, w http.ResponseWriter) { - if imposter.Response.Headers == nil { +func writeHeaders(r Response, w http.ResponseWriter) { + if r.Headers == nil { return } - for key, val := range *imposter.Response.Headers { + for key, val := range *r.Headers { w.Header().Set(key, val) } } -func writeBody(imposter Imposter, w http.ResponseWriter) { - wb := []byte(imposter.Response.Body) +func writeBody(i Imposter, r Response, w http.ResponseWriter) { + wb := []byte(r.Body) - if imposter.Response.BodyFile != nil { - bodyFile := imposter.CalculateFilePath(*imposter.Response.BodyFile) + if r.BodyFile != nil { + bodyFile := i.CalculateFilePath(*r.BodyFile) wb = fetchBodyFromFile(bodyFile) } w.Write(wb) diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index c9be466..395748e 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestImposterHandler(t *testing.T) { @@ -47,9 +48,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, Response: Responses{{Status: http.StatusOK, Headers: &headers, Body: body}}}, body, http.StatusOK}, + {"valid imposter with bodyFile", Imposter{Request: validRequest, Response: Responses{{Status: http.StatusOK, Headers: &headers, BodyFile: &bodyFile}}}, string(expectedBodyFileData), http.StatusOK}, + {"valid imposter with not exists bodyFile", Imposter{Request: validRequest, Response: Responses{{Status: http.StatusOK, Headers: &headers, BodyFile: &bodyFileFake}}}, "", http.StatusOK}, } for _, tt := range dataTest { @@ -58,7 +59,7 @@ func TestImposterHandler(t *testing.T) { assert.NoError(t, err) rec := httptest.NewRecorder() - handler := http.HandlerFunc(ImposterHandler(tt.imposter)) + handler := ImposterHandler(tt.imposter) handler.ServeHTTP(rec, req) assert.Equal(t, rec.Code, tt.statusCode) @@ -85,7 +86,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"}, Response: Responses{{Status: http.StatusOK, Body: "test ok"}}}, http.StatusOK, validRequest}, } for _, tt := range dataTest { @@ -94,7 +95,7 @@ func TestInvalidRequestWithSchema(t *testing.T) { req, err := http.NewRequest("POST", "/gophers", bytes.NewBuffer(tt.request)) assert.Nil(t, err) rec := httptest.NewRecorder() - handler := http.HandlerFunc(ImposterHandler(tt.imposter)) + handler := ImposterHandler(tt.imposter) handler.ServeHTTP(rec, req) @@ -102,3 +103,61 @@ func TestInvalidRequestWithSchema(t *testing.T) { }) } } + +func TestImposterHandler_MultipleRequests(t *testing.T) { + req, err := http.NewRequest("POST", "/gophers", bytes.NewBuffer([]byte(`{ + "data": { + "type": "gophers", + "attributes": { + "name": "Zebediah", + "color": "Purple" + } + } + }`))) + require.NoError(t, err) + + t.Run("created then conflict", func(t *testing.T) { + imp := Imposter{ + Request: Request{Method: "POST", Endpoint: "/gophers"}, + Response: Responses{ + {Status: http.StatusCreated, Body: "Created"}, + {Status: http.StatusConflict, Body: "Conflict"}, + }, + } + + handler := ImposterHandler(imp) + + // First request + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Equal(t, "Created", rec.Body.String()) + + // Second request + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Equal(t, "Conflict", rec.Body.String()) + }) + + t.Run("idempotent", func(t *testing.T) { + handler := ImposterHandler(Imposter{ + Request: Request{Method: "POST", Endpoint: "/gophers"}, + Response: Responses{ + {Status: http.StatusAccepted, Body: "Accepted"}, + }, + }) + + // First request + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusAccepted, rec.Code) + assert.Equal(t, "Accepted", rec.Body.String()) + + // Second request + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusAccepted, rec.Code) + assert.Equal(t, "Accepted", rec.Body.String()) + }) +} diff --git a/internal/server/http/imposter.go b/internal/server/http/imposter.go index 0d4e831..82fc41f 100644 --- a/internal/server/http/imposter.go +++ b/internal/server/http/imposter.go @@ -3,12 +3,11 @@ package http import ( "encoding/json" "fmt" - "io/ioutil" + "io" "os" "path" "path/filepath" "strings" - "time" "github.com/spf13/afero" "gopkg.in/yaml.v2" @@ -38,18 +37,22 @@ type ImposterConfig struct { // Imposter define an imposter structure type Imposter struct { - BasePath string `json:"-" yaml:"-"` - Path string `json:"-" yaml:"-"` - Request Request `json:"request"` - Response Response `json:"response"` + BasePath string `json:"-" yaml:"-"` + Path string `json:"-" yaml:"-"` + Request Request `json:"request"` + Response Responses `json:"response"` + resIdx int } -// Delay returns delay for response that user can specify in imposter config -func (i *Imposter) Delay() time.Duration { - return i.Response.Delay.Delay() +// NextResponse returns the imposter's response. +// If there are multiple responses, it will return them sequentially. +func (i *Imposter) NextResponse() Response { + r := i.Response[i.resIdx] + i.resIdx = (i.resIdx + 1) % len(i.Response) + return r } -// CalculateFilePath calculate file path based on basePath of imposter directory +// CalculateFilePath calculate file path based on basePath of imposter's directory func (i *Imposter) CalculateFilePath(filePath string) string { return path.Join(i.BasePath, filePath) } @@ -72,6 +75,58 @@ type Response struct { Delay ResponseDelay `json:"delay" yaml:"delay"` } +// Responses is a wrapper for Response, to allow the use of either a single +// response or an array of responses, while keeping backwards compatibility. +type Responses []Response + +func (rr *Responses) MarshalJSON() ([]byte, error) { + if len(*rr) == 1 { + return json.Marshal((*rr)[0]) + } + return json.Marshal(*rr) +} + +func (rr *Responses) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *rr = nil + return nil + } + + if data[0] == '[' { + return json.Unmarshal(data, (*[]Response)(rr)) + } + + var r Response + if err := json.Unmarshal(data, &r); err != nil { + return err + } + + *rr = Responses{r} + return nil +} + +func (rr *Responses) MarshalYAML() (interface{}, error) { + if len(*rr) == 1 { + return (*rr)[0], nil + } + return *rr, nil +} + +func (rr *Responses) UnmarshalYAML(unmarshal func(interface{}) error) error { + var r Response + if err := unmarshal(&r); err == nil { + *rr = Responses{r} + return nil + } + + var tmp []Response + if err := unmarshal(&tmp); err != nil { + return err + } + *rr = tmp + return nil +} + type ImposterFs struct { fs afero.Fs } @@ -114,7 +169,7 @@ func (i ImposterFs) unmarshalImposters(imposterConfig ImposterConfig) ([]Imposte imposterFile, _ := i.fs.Open(imposterConfig.FilePath) defer imposterFile.Close() - bytes, _ := ioutil.ReadAll(imposterFile) + bytes, _ := io.ReadAll(imposterFile) var parseError error var imposters []Imposter diff --git a/internal/server/http/imposter_test.go b/internal/server/http/imposter_test.go new file mode 100644 index 0000000..e855669 --- /dev/null +++ b/internal/server/http/imposter_test.go @@ -0,0 +1,146 @@ +package http + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResponses_MarshalJSON(t *testing.T) { + tcs := map[string]struct { + rr *Responses + exp string + }{ + "single response": { + rr: &Responses{{Status: 200, Body: "OK"}}, + exp: `{"status":200,"body":"OK","bodyFile":null,"headers":null,"delay":{}}`, + }, + "multiple response": { + rr: &Responses{{Status: 200, Body: "OK"}, {Status: 404, Body: "Not Found"}}, + exp: `[{"status":200,"body":"OK","bodyFile":null,"headers":null,"delay":{}},{"status":404,"body":"Not Found","bodyFile":null,"headers":null,"delay":{}}]`, + }, + "empty array": { + rr: &Responses{}, + exp: `[]`, + }, + "null array": { + rr: nil, + exp: `null`, + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + got, err := json.Marshal(tc.rr) + assert.NoError(t, err) + assert.Equal(t, tc.exp, string(got)) + }) + } +} + +func TestResponses_UnmarshalJSON(t *testing.T) { + tcs := map[string]struct { + data string + exp Imposter + }{ + "single response": { + data: `{"response": {"status":200,"body":"OK"}}`, + exp: Imposter{Response: Responses{{Status: 200, Body: "OK"}}}, + }, + "single array response": { + data: `{"response": [{"status":200,"body":"OK"}]}`, + exp: Imposter{Response: Responses{{Status: 200, Body: "OK"}}}, + }, + "multiple array response": { + data: `{"response": [{"status":200,"body":"OK"}, {"status":404,"body":"Not Found"}]}`, + exp: Imposter{Response: Responses{{Status: 200, Body: "OK"}, {Status: 404, Body: "Not Found"}}}, + }, + "empty array": { + data: `{"response": []}`, + exp: Imposter{Response: Responses{}}, + }, + "null array": { + data: `{"response": null}`, + exp: Imposter{Response: nil}, + }} + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + var got Imposter + err := json.Unmarshal([]byte(tc.data), &got) + require.NoError(t, err) + assert.Equal(t, tc.exp, got) + }) + } +} + +func TestResponses_MarshalYAML(t *testing.T) { + tcs := map[string]struct { + rr *Responses + exp string + }{ + "single response": { + rr: &Responses{{Status: 200, Body: "OK"}}, + exp: "status: 200\nbody: OK\nbodyFile: null\nheaders: null\ndelay: {}\n", + }, + "multiple response": { + rr: &Responses{{Status: 200, Body: "OK"}, {Status: 404, Body: "Not Found"}}, + exp: "- status: 200\n body: OK\n bodyFile: null\n headers: null\n delay: {}\n- status: 404\n body: Not Found\n bodyFile: null\n headers: null\n delay: {}\n", + }, + "empty array": { + rr: &Responses{}, + exp: "[]\n", + }, + "null array": { + rr: nil, + exp: "null\n", + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + got, err := yaml.Marshal(tc.rr) + assert.NoError(t, err) + assert.Equal(t, tc.exp, string(got)) + }) + } +} + +func TestResponses_UnmarshalYAML(t *testing.T) { + tcs := map[string]struct { + data string + exp Imposter + }{ + "single response": { + data: "response:\n status: 200\n body: OK\n", + exp: Imposter{Response: Responses{{Status: 200, Body: "OK"}}}, + }, + "single array response": { + data: "response:\n- status: 200\n body: OK\n", + exp: Imposter{Response: Responses{{Status: 200, Body: "OK"}}}, + }, + "multiple array response": { + data: "response:\n- status: 200\n body: OK\n- status: 404\n body: Not Found\n", + exp: Imposter{Response: Responses{{Status: 200, Body: "OK"}, {Status: 404, Body: "Not Found"}}}, + }, + "empty array": { + data: "response: []\n", + exp: Imposter{Response: Responses{}}, + }, + "null array": { + data: "response: \n", + exp: Imposter{Response: nil}, + }} + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + var got Imposter + err := yaml.Unmarshal([]byte(tc.data), &got) + require.NoError(t, err) + assert.Equal(t, tc.exp, got) + }) + } +} diff --git a/internal/server/http/route_matchers_test.go b/internal/server/http/route_matchers_test.go index 664394b..8a03253 100644 --- a/internal/server/http/route_matchers_test.go +++ b/internal/server/http/route_matchers_test.go @@ -47,7 +47,7 @@ func TestMatcherBySchema(t *testing.T) { httpRequestA := &http.Request{Body: bodyA} httpRequestB := &http.Request{Body: bodyB} - okResponse := Response{Status: http.StatusOK} + okResponse := Responses{{Status: http.StatusOK}} var matcherData = map[string]struct { fn mux.MatcherFunc @@ -56,7 +56,7 @@ func TestMatcherBySchema(t *testing.T) { }{ "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}, + "malformed 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}, diff --git a/internal/server/http/server_test.go b/internal/server/http/server_test.go index e083af5..ba16c0c 100644 --- a/internal/server/http/server_test.go +++ b/internal/server/http/server_test.go @@ -32,7 +32,7 @@ func TestServer_Build(t *testing.T) { err error }{ {"imposter directory not found", NewServer("failImposterPath", nil, &http.Server{}, &Proxy{}, false, imposterFs), errors.New("hello")}, - {"malformatted json", NewServer("test/testdata/malformatted_imposters", nil, &http.Server{}, &Proxy{}, false, imposterFs), nil}, + {"malformed json", NewServer("test/testdata/malformatted_imposters", nil, &http.Server{}, &Proxy{}, false, imposterFs), nil}, {"valid imposter", NewServer("test/testdata/imposters", mux.NewRouter(), &http.Server{}, &Proxy{}, false, imposterFs), nil}, }