diff --git a/.gitignore b/.gitignore index 81fbc3a..6b4d7bb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ imposters schemas !test/testdata/imposters/ -!test/testdata/schemas/ \ No newline at end of file +!test/testdata/imposters/schemas/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c068cf6..3913b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,10 @@ * Create an official docker image for the application * Update README.md with how to use the application with docker * Allow write headers for the response + +## v0.2.1 (2019/04/25) + +* Allow imposter's matching by request schema +* Dynamic responses based on regex endpoint or request schema +* Calculate files directory(body and schema) based on imposters path +* Update REAMDE.md with resolved features and new future features diff --git a/README.md b/README.md index a8e7c71..8760fa3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ [![CircleCI](https://circleci.com/gh/friendsofgo/killgrave/tree/master.svg?style=svg)](https://circleci.com/gh/friendsofgo/killgrave/tree/master) +[![Version](https://img.shields.io/github/release/friendsofgo/killgrave.svg?style=flat-square)](https://github.com/friendsofgo/killgrave/releases/latest) [![codecov](https://codecov.io/gh/friendsofgo/killgrave/branch/master/graph/badge.svg)](https://codecov.io/gh/friendsofgo/killgrave) [![Go Report Card](https://goreportcard.com/badge/github.com/friendsofgo/killgrave)](https://goreportcard.com/report/github.com/friendsofgo/killgrave) [![GoDoc](https://godoc.org/graphql.co/graphql?status.svg)](https://godoc.org/github.com/friendsofgo/killgrave) -[![FriendsOfGo](https://img.shields.io/badge/powered%20by-Friends%20of%20Go-73D7E2.svg)](https://img.shields.io/badge/powered%20by-Friends%20of%20Go-73D7E2.svg) +[![FriendsOfGo](https://img.shields.io/badge/powered%20by-Friends%20of%20Go-73D7E2.svg)](https://friendsofgo.tech)

Golang Killgrave @@ -181,10 +182,14 @@ NOTE: If you want to use `killgrave` through Docker at the same time you use you * Write bodies in line * Regex for using on endpoint urls * Allow write headers on response +* Allow imposter's matching by request schema +* Dynamic responses based on regex endpoint or request schema ## Next Features +- [ ] Dynamic responses based on headers +- [ ] Dynamic responses based on query params +- [ ] Allow write multiples imposters by file - [ ] Proxy server -- [ ] Dynamic responses and error responses - [ ] Record proxy server - [ ] Better documentation with examples of each feature diff --git a/handler.go b/handler.go index cc48667..94bbbca 100644 --- a/handler.go +++ b/handler.go @@ -2,74 +2,29 @@ package killgrave import ( "fmt" - "io" "io/ioutil" "log" "net/http" "net/textproto" "os" "strings" - - "github.com/pkg/errors" - "github.com/xeipuuv/gojsonschema" ) // ImposterHandler create specific handler for the received imposter func ImposterHandler(imposter Imposter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if err := validateSchema(imposter, r.Body); err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(err.Error())) - return - } - if err := validateHeaders(imposter, r.Header); err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(err.Error())) return } - writeHeaders(imposter, w) w.WriteHeader(imposter.Response.Status) writeBody(imposter, w) } } -func validateSchema(imposter Imposter, bodyRequest io.ReadCloser) error { - if imposter.Request.SchemaFile == nil { - return nil - } - - schemaFile := *imposter.Request.SchemaFile - if _, err := os.Stat(schemaFile); os.IsNotExist(err) { - return errors.Wrapf(err, "the schema file %s not found", schemaFile) - } - - b, err := ioutil.ReadAll(bodyRequest) - if err != nil { - return errors.Wrapf(err, "impossible read the request body") - } - - dir, _ := os.Getwd() - schemaFilePath := "file://" + dir + "/" + schemaFile - schema := gojsonschema.NewReferenceLoader(schemaFilePath) - document := gojsonschema.NewStringLoader(string(b)) - - res, err := gojsonschema.Validate(schema, document) - if err != nil { - return errors.Wrap(err, "error validating the json schema") - } - - if !res.Valid() { - for _, desc := range res.Errors() { - return errors.New(desc.String()) - } - } - - return nil -} - func validateHeaders(imposter Imposter, header http.Header) error { if imposter.Request.Headers == nil { return nil @@ -117,7 +72,8 @@ func writeBody(imposter Imposter, w http.ResponseWriter) { wb := []byte(imposter.Response.Body) if imposter.Response.BodyFile != nil { - wb = fetchBodyFromFile(*imposter.Response.BodyFile) + bodyFile := imposter.CalculateFilePath(*imposter.Response.BodyFile) + wb = fetchBodyFromFile(bodyFile) } w.Write(wb) } diff --git a/handler_test.go b/handler_test.go index fd92ab5..1e7372b 100644 --- a/handler_test.go +++ b/handler_test.go @@ -23,9 +23,9 @@ func TestImposterHandler(t *testing.T) { var headers = make(http.Header) headers.Add("Content-Type", "application/json") - schemaFile := "test/testdata/schemas/create_gopher_request.json" - bodyFile := "test/testdata/responses/create_gopher_response.json" - bodyFileFake := "test/testdata/responses/create_gopher_response_fail.json" + schemaFile := "test/testdata/imposters/schemas/create_gopher_request.json" + bodyFile := "test/testdata/imposters/responses/create_gopher_response.json" + bodyFileFake := "test/testdata/imposters/responses/create_gopher_response_fail.json" body := `{"test":true}` validRequest := Request{ @@ -75,15 +75,6 @@ func TestImposterHandler(t *testing.T) { } func TestInvalidRequestWithSchema(t *testing.T) { - wrongRequest := []byte(`{ - "data": { - "type": "gophers", - "attributes": { - "name": "Zebediah", - "color": "Purple" - } - } - }`) validRequest := []byte(`{ "data": { "type": "gophers", @@ -93,9 +84,6 @@ func TestInvalidRequestWithSchema(t *testing.T) { } } }`) - notExistFile := "failSchema" - wrongSchema := "test/testdata/schemas/create_gopher_request_fail.json" - validSchema := "test/testdata/schemas/create_gopher_request.json" var dataTest = []struct { name string @@ -103,13 +91,11 @@ func TestInvalidRequestWithSchema(t *testing.T) { statusCode int request []byte }{ - {"schema file not found", Imposter{Request: Request{Method: "POST", Endpoint: "/gophers", SchemaFile: ¬ExistFile}}, http.StatusBadRequest, validRequest}, - {"wrong schema", Imposter{Request: Request{Method: "POST", Endpoint: "/gophers", SchemaFile: &wrongSchema}}, http.StatusBadRequest, validRequest}, - {"request invalid", Imposter{Request: Request{Method: "POST", Endpoint: "/gophers", SchemaFile: &validSchema}}, http.StatusBadRequest, wrongRequest}, {"valid request no schema", Imposter{Request: Request{Method: "POST", Endpoint: "/gophers"}, Response: Response{Status: http.StatusOK, Body: "test ok"}}, http.StatusOK, validRequest}, } for _, tt := range dataTest { + t.Run(tt.name, func(t *testing.T) { req, err := http.NewRequest("POST", "/gophers", bytes.NewBuffer(tt.request)) if err != nil { @@ -137,7 +123,7 @@ func TestInvalidHeaders(t *testing.T) { } } }`) - schemaFile := "test/testdata/schemas/create_gopher_request.json" + schemaFile := "test/testdata/imposters/schemas/create_gopher_request.json" var expectedHeaders = make(http.Header) expectedHeaders.Add("Content-Type", "application/json") expectedHeaders.Add("Authorization", "Bearer gopher") diff --git a/imposter.go b/imposter.go index 5ade3c1..dbdd2cb 100644 --- a/imposter.go +++ b/imposter.go @@ -1,13 +1,22 @@ package killgrave -import "net/http" +import ( + "net/http" + "path" +) // Imposter define an imposter structure type Imposter struct { + BasePath string Request Request `json:"request"` Response Response `json:"response"` } +// CalculateFilePath calculate file path based on basePath of imposter directory +func (i *Imposter) CalculateFilePath(filePath string) string { + return path.Join(i.BasePath, filePath) +} + // Request represent the structure of real request type Request struct { Method string `json:"method"` @@ -18,8 +27,8 @@ type Request struct { // Response represent the structure of real response type Response struct { - Status int `json:"status"` - Body string `json:"body"` - BodyFile *string `json:"bodyFile"` - Headers *http.Header `json:"headers"` + Status int `json:"status"` + Body string `json:"body"` + BodyFile *string `json:"bodyFile"` + Headers *http.Header `json:"headers"` } diff --git a/route_matchers.go b/route_matchers.go new file mode 100644 index 0000000..058f917 --- /dev/null +++ b/route_matchers.go @@ -0,0 +1,67 @@ +package killgrave + +import ( + "bytes" + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/xeipuuv/gojsonschema" + "io/ioutil" + "log" + "net/http" + "os" +) + +// MatcherBySchema check if the request matching with the schema file +func MatcherBySchema(imposter Imposter) mux.MatcherFunc { + return func(req *http.Request, rm *mux.RouteMatch) bool { + err := validateSchema(imposter, req) + + // TODO: inject the logger + if err != nil { + log.Println(err) + return false + } + return true + } +} + +func validateSchema(imposter Imposter, req *http.Request) error { + if imposter.Request.SchemaFile == nil { + return nil + } + + var b []byte + + defer func() { + req.Body.Close() + req.Body = ioutil.NopCloser(bytes.NewBuffer(b)) + }() + + schemaFile := imposter.CalculateFilePath(*imposter.Request.SchemaFile) + if _, err := os.Stat(schemaFile); os.IsNotExist(err) { + return errors.Wrapf(err, "the schema file %s not found", schemaFile) + } + + b, err := ioutil.ReadAll(req.Body) + if err != nil { + return errors.Wrapf(err, "impossible read the request body") + } + + dir, _ := os.Getwd() + schemaFilePath := "file://" + dir + "/" + schemaFile + schema := gojsonschema.NewReferenceLoader(schemaFilePath) + document := gojsonschema.NewStringLoader(string(b)) + + res, err := gojsonschema.Validate(schema, document) + if err != nil { + return errors.Wrap(err, "error validating the json schema") + } + + if !res.Valid() { + for _, desc := range res.Errors() { + return errors.New(desc.String()) + } + } + + return nil +} diff --git a/route_matchers_test.go b/route_matchers_test.go new file mode 100644 index 0000000..67ae4d1 --- /dev/null +++ b/route_matchers_test.go @@ -0,0 +1,67 @@ +package killgrave + +import ( + "bytes" + "github.com/gorilla/mux" + "io/ioutil" + "net/http" + "testing" +) + +func TestMatcherBySchema(t *testing.T) { + bodyA := ioutil.NopCloser(bytes.NewReader([]byte("{\"type\": \"gopher\"}"))) + bodyB := ioutil.NopCloser(bytes.NewReader([]byte("{\"type\": \"cat\"}"))) + + schemaGopherFile := "test/testdata/imposters/schemas/type_gopher.json" + schemaCatFile := "test/testdata/imposters/schemas/type_cat.json" + schemeFailFile := "test/testdata/imposters/schemas/type_gopher_fail.json" + + requestWithoutSchema := Request{ + Method: "POST", + Endpoint: "/login", + SchemaFile: nil, + } + + requestWithSchema := Request{ + Method: "POST", + Endpoint: "/login", + SchemaFile: &schemaGopherFile, + } + + requestWithNonExistingSchema := Request{ + Method: "POST", + Endpoint: "/login", + SchemaFile: &schemaCatFile, + } + + requestWithWrongSchema := Request{ + Method: "POST", + Endpoint: "/login", + SchemaFile: &schemeFailFile, + } + + okResponse := Response{Status: http.StatusOK} + + var matcherData = []struct { + name string + fn mux.MatcherFunc + req *http.Request + res bool + }{ + {"imposter without request schema", MatcherBySchema(Imposter{Request: requestWithoutSchema, Response: okResponse}), &http.Request{Body: bodyA}, true}, + {"correct request schema", MatcherBySchema(Imposter{Request: requestWithSchema, Response: okResponse}), &http.Request{Body: bodyA}, true}, + {"incorrect request schema", MatcherBySchema(Imposter{Request: requestWithSchema, Response: okResponse}), &http.Request{Body: bodyB}, false}, + {"non-existing schema file", MatcherBySchema(Imposter{Request: requestWithNonExistingSchema, Response: okResponse}), &http.Request{Body: bodyB}, false}, + {"malformatted schema file", MatcherBySchema(Imposter{Request: requestWithWrongSchema, Response: okResponse}), &http.Request{Body: bodyB}, false}, + } + + for _, tt := range matcherData { + t.Run(tt.name, func(t *testing.T) { + res := tt.fn(tt.req, nil) + if res != tt.res { + t.Fatalf("error while matching by request schema - expected: %t, given: %t", tt.res, res) + } + }) + + } +} diff --git a/server.go b/server.go index 7eafb36..45e11d6 100644 --- a/server.go +++ b/server.go @@ -40,6 +40,10 @@ func (s *Server) buildImposters() error { files, _ := ioutil.ReadDir(s.impostersPath) for _, f := range files { + if f.IsDir() { + continue + } + var imposter Imposter if err := s.buildImposter(f.Name(), &imposter); err != nil { return err @@ -48,7 +52,9 @@ func (s *Server) buildImposters() error { if imposter.Request.Endpoint == "" { continue } - s.router.HandleFunc(imposter.Request.Endpoint, ImposterHandler(imposter)).Methods(imposter.Request.Method) + s.router.HandleFunc(imposter.Request.Endpoint, ImposterHandler(imposter)). + Methods(imposter.Request.Method). + MatcherFunc(MatcherBySchema(imposter)) } return nil @@ -63,5 +69,7 @@ func (s *Server) buildImposter(imposterFileName string, imposter *Imposter) erro if err := json.Unmarshal(bytes, imposter); err != nil { return malformattedImposterError(fmt.Sprintf("error while unmarshall imposter file %s", f)) } + imposter.BasePath = s.impostersPath + return nil } diff --git a/server_test.go b/server_test.go index 0a35ae7..35e063f 100644 --- a/server_test.go +++ b/server_test.go @@ -13,7 +13,7 @@ func TestRunServer(t *testing.T) { err error }{ {"imposter directory not found", NewServer("failImposterPath", nil), invalidDirectoryError("error")}, - {"malformatted json", NewServer("test/testdata/malformatted", nil), malformattedImposterError("error")}, + {"malformatted json", NewServer("test/testdata/malformatted_imposters", nil), malformattedImposterError("error")}, {"valid imposter", NewServer("test/testdata/imposters", mux.NewRouter()), nil}, } diff --git a/test/testdata/responses/create_gopher_response.json b/test/testdata/imposters/responses/create_gopher_response.json similarity index 100% rename from test/testdata/responses/create_gopher_response.json rename to test/testdata/imposters/responses/create_gopher_response.json diff --git a/test/testdata/schemas/create_gopher_request.json b/test/testdata/imposters/schemas/create_gopher_request.json similarity index 100% rename from test/testdata/schemas/create_gopher_request.json rename to test/testdata/imposters/schemas/create_gopher_request.json diff --git a/test/testdata/imposters/schemas/type_gopher.json b/test/testdata/imposters/schemas/type_gopher.json new file mode 100644 index 0000000..8467b21 --- /dev/null +++ b/test/testdata/imposters/schemas/type_gopher.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "pattern": "^(gopher)$" + } + } +} diff --git a/test/testdata/imposters/schemas/type_gopher_fail.json b/test/testdata/imposters/schemas/type_gopher_fail.json new file mode 100644 index 0000000..9cfb314 --- /dev/null +++ b/test/testdata/imposters/schemas/type_gopher_fail.json @@ -0,0 +1,12 @@ +{ + "type": "error", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "pattern": "^(gopher)$" + } + } +} diff --git a/test/testdata/malformatted/create_gopher.json b/test/testdata/malformatted_imposters/create_gopher.json similarity index 100% rename from test/testdata/malformatted/create_gopher.json rename to test/testdata/malformatted_imposters/create_gopher.json diff --git a/test/testdata/schemas/create_gopher_request_fail.json b/test/testdata/schemas/create_gopher_request_fail.json deleted file mode 100644 index 49ed8f3..0000000 --- a/test/testdata/schemas/create_gopher_request_fail.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "type": "error", - "properties": { - "nul": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "gophers" - ] - }, - "attributes": { - "properties": { - "name": { - "type": "string" - }, - "color": { - "type": "string" - }, - "age": { - "type": "integer" - } - }, - "required": [ - "name", - "color", - "age" - ] - } - }, - "required": [ - "type", - "attributes" - ] - } - }, - "required": [ - "data" - ] -} \ No newline at end of file