Skip to content

Commit

Permalink
Merge pull request #18 from friendsofgo/config_file
Browse files Browse the repository at this point in the history
Allow to run mock server via config file
  • Loading branch information
aperezg committed May 11, 2019
2 parents c6b1387 + b2c09ad commit 3d728b9
Show file tree
Hide file tree
Showing 12 changed files with 270 additions and 30 deletions.
14 changes: 13 additions & 1 deletion 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
Expand Down Expand Up @@ -37,4 +49,4 @@
* Convert headers into canonical mime type
* Run server with imposter configuration
* Processing and parsing imposters file
* Initial version
* Initial version
71 changes: 64 additions & 7 deletions README.md
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 16 additions & 5 deletions cmd/killgrave/main.go
Expand Up @@ -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)))
}
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -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
)
4 changes: 4 additions & 0 deletions go.sum
Expand Up @@ -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=
41 changes: 41 additions & 0 deletions 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
}
55 changes: 55 additions & 0 deletions 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,
},
}
}
34 changes: 21 additions & 13 deletions internal/route_matchers_test.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
34 changes: 31 additions & 3 deletions internal/server.go
Expand Up @@ -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
Expand All @@ -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
}

Expand Down

0 comments on commit 3d728b9

Please sign in to comment.