diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fef0a6..c461827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.3.3 (2019/05/11) + +* Improve default CORS options +* Allow up mock server via config file +* Allow configure CORS options + * Access-Control-Request-Method + * Access-Control-Request-Headers + * Access-Control-Allow-Origin + * Access-Control-Expose-Headers + * Access-Control-Allow-Credentials +* Improve route_mateches unit tests + ## v0.3.2 (2019/05/08) * Fix CORS add AccessControl allowing methods and headers @@ -37,4 +49,4 @@ * Convert headers into canonical mime type * Run server with imposter configuration * Processing and parsing imposters file -* Initial version \ No newline at end of file +* Initial version diff --git a/README.md b/README.md index 52b1275..846fd6c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,17 @@ Or you can download the binary for your arch on: [https://github.com/friendsofgo/killgrave/releases](https://github.com/friendsofgo/killgrave/releases) +### Docker + +The application is also available through [Docker](https://hub.docker.com/r/friendsofgo/killgrave), just run: + +```bash +docker run -it --rm -p 3000:3000 -v $PWD/:/home -w /home friendsofgo/killgrave +``` +Remember to use the [-p](https://docs.docker.com/engine/reference/run/) flag to expose the container port where the application is listening (3000 by default). + +NOTE: If you want to use `killgrave` through Docker at the same time you use your own dockerised HTTP-based API, be careful with networking issues. + ## Using Killgrave Use `killgrave` with default flags: @@ -39,6 +50,8 @@ $ killgrave ``` Or custome your server with this flags: ```sh + -config string + path with configuration file -host string if you run your server on a different host (default "localhost") -imposters string @@ -49,6 +62,28 @@ Or custome your server with this flags: show the version of the application ``` +Use `killgrave` with config file: + +First of all you need create a file with a valid config, i.e: + +```yaml +#config.yml + +imposters_path: "imposters" +port: 3000 +host: "localhost" +cors: + methods: ["GET"] + headers: ["Content-Type"] + exposed_headers: ["Cache-Control"] + origins: ["*"] + allow_credentials: true +``` + +The parameter `cors` is optional and his options can be empty array, the other options `imposters_path`, `port`, `host` are mandatory. + +If you want more information about the CORS options, visit the [CORS section](#CORS). + ## How to use ### Create an imposter @@ -178,17 +213,37 @@ curl --header "Content-Type: application/json" \ http://localhost:3000/gophers ``` -### Docker +## CORS -The application is also available through [Docker](https://hub.docker.com/r/friendsofgo/killgrave), just run: +If you want to use `killgrave` on your client application you must consider to configure correctly all about CORS, thus we offer the possibility to configure as you need through a config file. -```bash -docker run -it --rm -p 3000:3000 friendsofgo/killgrave -``` +In the CORS section of the file you can find the next options: -Remember to use the [-p](https://docs.docker.com/engine/reference/run/) flag to expose the container port where the application is listening (3000 by default). +- **methods** (string array) + + Represent the **Access-Control-Request-Method header**, if you don't specify it or if you do leave it as any empty array, the default values will be: -NOTE: If you want to use `killgrave` through Docker at the same time you use your own dockerised HTTP-based API, be careful with networking issues. + `"GET", "HEAD", "POST", "PUT", "OPTIONS", "DELETE", "PATCH", "TRACE", "CONNECT"` + +- **headers** (string array) + + Represent the **Access-Control-Request-Headers header**, if you don't specify it or if you do leave it as any empty array, the default values will be: + + `"X-Requested-With", "Content-Type", "Authorization"` + +- **exposed_headers** (string array) + + Represent the **Access-Control-Expose-Headers header**, if you don't specify it or if you do leave it as any empty array, the default values will be: + + `"Cache-Control", "Content-Language", "Content-Type", "Expires", "Last-Modified", "Pragma"` + +- **origins** (string array) + + Represent the **Access-Control-Allow-Origin header**, if you don't specify or leave as empty array this options has not default value + +- **allow_credentials** (boolean) + + Represent the **Access-Control-Allow-Credentials header** you must indicate if true or false ## Features * Imposters created in json @@ -206,6 +261,8 @@ NOTE: If you want to use `killgrave` through Docker at the same time you use you * Dynamic responses based on query params * Allow organize your imposters with structured folders * Allow write multiple imposters by file +* Run mock server with predefined configuration with config yaml file +* Configure your CORS server options ## Next Features - [ ] Proxy server diff --git a/cmd/killgrave/main.go b/cmd/killgrave/main.go index fda1b09..1f4ddd1 100644 --- a/cmd/killgrave/main.go +++ b/cmd/killgrave/main.go @@ -21,21 +21,32 @@ func main() { port := flag.Int("port", 3000, "por to run the server") imposters := flag.String("imposters", "imposters", "directory where your imposters are saved") v := flag.Bool("version", false, "show the version of the application") + c := flag.String("config", "", "path with configuration file") + flag.Parse() if *v { fmt.Printf("%s version %s\n", name, version) return } - + var config killgrave.Config + if *c != "" { + killgrave.ReadConfigFile(*c, &config) + } else { + config = killgrave.Config{ + ImpostersPath: *imposters, + Port: *port, + Host: *host, + } + } r := mux.NewRouter() - s := killgrave.NewServer(*imposters, r) + s := killgrave.NewServer(config.ImpostersPath, r) if err := s.Build(); err != nil { log.Fatal(err) } - httpAddr := fmt.Sprintf("%s:%d", *host, *port) - log.Printf("The fake server is on tap now: http://%s:%d\n", *host, *port) - log.Fatal(http.ListenAndServe(httpAddr, handlers.CORS(s.AccessControl()...)(r))) + httpAddr := fmt.Sprintf("%s:%d", config.Host, config.Port) + log.Printf("The fake server is on tap now: http://%s:%d\n", config.Host, config.Port) + log.Fatal(http.ListenAndServe(httpAddr, handlers.CORS(s.AccessControl(config.CORS)...)(r))) } diff --git a/go.mod b/go.mod index f90153f..a2665b8 100644 --- a/go.mod +++ b/go.mod @@ -10,4 +10,5 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.1.0 + gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index af7a1c5..cf2c144 100644 --- a/go.sum +++ b/go.sum @@ -17,3 +17,7 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.1.0 h1:ngVtJC9TY/lg0AA/1k48FYhBrhRoFlEmWzsehpNAaZg= github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..2b262bb --- /dev/null +++ b/internal/config.go @@ -0,0 +1,41 @@ +package killgrave + +import ( + "io/ioutil" + "os" + + "github.com/pkg/errors" + "gopkg.in/yaml.v2" +) + +// Config representation of config file yaml +type Config struct { + ImpostersPath string `yaml:"imposters_path"` + Port int `yaml:"port"` + Host string `yaml:"host"` + CORS ConfigCORS `yaml:"cors"` +} + +// ConfigCORS representation of section CORS of the yaml +type ConfigCORS struct { + Methods []string `yaml:"methods"` + Headers []string `yaml:"headers"` + Origins []string `yaml:"origins"` + ExposedHeaders []string `yaml:"exposed_headers"` + AllowCredentials bool `yaml:"allow_credentials"` +} + +// ReadConfigFile unmarshal content of config file to Config struct +func ReadConfigFile(path string, config *Config) error { + configFile, err := os.Open(path) + if err != nil { + return errors.Wrapf(err, "error trying to read config file: %s", path) + } + defer configFile.Close() + + bytes, _ := ioutil.ReadAll(configFile) + if err := yaml.Unmarshal(bytes, config); err != nil { + return errors.Wrapf(err, "error while unmarshall configFile file %s", path) + } + return nil +} diff --git a/internal/config_test.go b/internal/config_test.go new file mode 100644 index 0000000..2ee0328 --- /dev/null +++ b/internal/config_test.go @@ -0,0 +1,55 @@ +package killgrave + +import ( + "reflect" + "testing" + + "github.com/pkg/errors" +) + +func TestReadConfigFile(t *testing.T) { + tests := map[string]struct { + input string + expected Config + err error + }{ + "valid config file": {"test/testdata/config.yml", validConfig(), nil}, + "file not found": {"test/testdata/file.yml", Config{}, errors.New("error")}, + "wrong yaml file": {"test/testdata/wrong_config.yml", Config{}, errors.New("error")}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var got Config + err := ReadConfigFile(tc.input, &got) + + if err != nil && tc.err == nil { + t.Fatalf("not expected any erros and got %v", err) + } + + if err == nil && tc.err != nil { + t.Fatalf("expected an error and got nil") + } + + if !reflect.DeepEqual(tc.expected, got) { + t.Fatalf("expected: %v, got: %v", tc.expected, got) + } + + }) + } +} + +func validConfig() Config { + return Config{ + ImpostersPath: "imposters", + Port: 3000, + Host: "localhost", + CORS: ConfigCORS{ + Methods: []string{"GET"}, + Origins: []string{"*"}, + Headers: []string{"Content-Type"}, + ExposedHeaders: []string{"Cache-Control"}, + AllowCredentials: true, + }, + } +} diff --git a/internal/route_matchers_test.go b/internal/route_matchers_test.go index 21081f4..b883cfe 100644 --- a/internal/route_matchers_test.go +++ b/internal/route_matchers_test.go @@ -7,12 +7,14 @@ import ( "testing" "github.com/gorilla/mux" + "github.com/pkg/errors" ) func TestMatcherBySchema(t *testing.T) { bodyA := ioutil.NopCloser(bytes.NewReader([]byte("{\"type\": \"gopher\"}"))) bodyB := ioutil.NopCloser(bytes.NewReader([]byte("{\"type\": \"cat\"}"))) emptyBody := ioutil.NopCloser(bytes.NewReader([]byte(""))) + wrongBody := ioutil.NopCloser(errReader(0)) schemaGopherFile := "test/testdata/imposters/schemas/type_gopher.json" schemaCatFile := "test/testdata/imposters/schemas/type_cat.json" @@ -46,22 +48,22 @@ func TestMatcherBySchema(t *testing.T) { httpRequestB := &http.Request{Body: bodyB} okResponse := Response{Status: http.StatusOK} - var matcherData = []struct { - name string - fn mux.MatcherFunc - req *http.Request - res bool + var matcherData = map[string]struct { + fn mux.MatcherFunc + 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}, + "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}, } - for _, tt := range matcherData { - t.Run(tt.name, func(t *testing.T) { + for name, tt := range matcherData { + t.Run(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) @@ -70,3 +72,9 @@ func TestMatcherBySchema(t *testing.T) { } } + +type errReader int + +func (errReader) Read(p []byte) (n int, err error) { + return 0, errors.New("test error") +} diff --git a/internal/server.go b/internal/server.go index 50c7089..e3a5eac 100644 --- a/internal/server.go +++ b/internal/server.go @@ -12,6 +12,12 @@ import ( "github.com/pkg/errors" ) +var ( + defaultCORSMethods = []string{"GET", "HEAD", "POST", "PUT", "OPTIONS", "DELETE", "PATCH", "TRACE", "CONNECT"} + defaultCORSHeaders = []string{"X-Requested-With", "Content-Type", "Authorization"} + defaultCORSExposedHeaders = []string{"Cache-Control", "Content-Language", "Content-Type", "Expires", "Last-Modified", "Pragma"} +) + // Server definition of mock server type Server struct { impostersPath string @@ -27,9 +33,31 @@ func NewServer(p string, r *mux.Router) *Server { } // AccessControl Return options to initialize the mock server with default access control -func (s *Server) AccessControl() (h []handlers.CORSOption) { - h = append(h, handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "OPTIONS", "DELETE", "PATCH", "TRACE", "CONNECT"})) - h = append(h, handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "*"})) +func (s *Server) AccessControl(config ConfigCORS) (h []handlers.CORSOption) { + h = append(h, handlers.AllowedMethods(defaultCORSMethods)) + h = append(h, handlers.AllowedHeaders(defaultCORSHeaders)) + h = append(h, handlers.ExposedHeaders(defaultCORSExposedHeaders)) + + if len(config.Methods) > 0 { + h = append(h, handlers.AllowedMethods(config.Methods)) + } + + if len(config.Origins) > 0 { + h = append(h, handlers.AllowedOrigins(config.Origins)) + } + + if len(config.Headers) > 0 { + h = append(h, handlers.AllowedHeaders(config.Headers)) + } + + if len(config.ExposedHeaders) > 0 { + h = append(h, handlers.ExposedHeaders(config.ExposedHeaders)) + } + + if config.AllowCredentials { + h = append(h, handlers.AllowCredentials()) + } + return } diff --git a/internal/server_test.go b/internal/server_test.go index 2c8a84a..c870954 100644 --- a/internal/server_test.go +++ b/internal/server_test.go @@ -39,7 +39,20 @@ func TestRunServer(t *testing.T) { func TestAccessControl(t *testing.T) { s := NewServer("test/testdata/imposters", mux.NewRouter()) - h := s.AccessControl() + config := Config{ + ImpostersPath: "imposters", + Port: 3000, + Host: "localhost", + CORS: ConfigCORS{ + Methods: []string{"GET"}, + Origins: []string{"*"}, + Headers: []string{"Content-Type"}, + ExposedHeaders: []string{"Cache-Control"}, + AllowCredentials: true, + }, + } + + h := s.AccessControl(config.CORS) if len(h) <= 0 { t.Fatal("Expected any CORS options and got empty") diff --git a/internal/test/testdata/config.yml b/internal/test/testdata/config.yml new file mode 100644 index 0000000..b3bc62d --- /dev/null +++ b/internal/test/testdata/config.yml @@ -0,0 +1,9 @@ +imposters_path: "imposters" +port: 3000 +host: "localhost" +cors: + methods: ["GET"] + headers: ["Content-Type"] + exposed_headers: ["Cache-Control"] + origins: ["*"] + allow_credentials: true diff --git a/internal/test/testdata/wrong_config.yml b/internal/test/testdata/wrong_config.yml new file mode 100644 index 0000000..8276753 --- /dev/null +++ b/internal/test/testdata/wrong_config.yml @@ -0,0 +1 @@ +error