diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7d30ef7..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -sudo: required - -services: - - docker - -language: go - -go: - - "1.13" - - "1.14" - - "1.15" - - "1.16" - -script: make unit && make integration - -jobs: - include: - - stage: check - install: go install golang.org/x/lint/golint@latest - script: make lint - go: "1.16" # only run source code analysis tools on latest version of Go diff --git a/README.md b/README.md index 5d790cd..a57aa3c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A mountebank API client for the Go programming language. ## Installation ```sh -$ go get -u github.com/senseyeio/mbgo +go get -u github.com/senseyeio/mbgo@latest ``` ## Testing @@ -15,8 +15,8 @@ $ go get -u github.com/senseyeio/mbgo This package includes both unit and integration tests. Use the `unit` and `integration` targets in the Makefile to run them, respectively: ```sh -$ make unit -$ make integration +make unit +make integration ``` The integration tests expect Docker to be available on the host, using it to run a local mountebank container at diff --git a/client.go b/client.go index a6072e8..1c06702 100644 --- a/client.go +++ b/client.go @@ -1,7 +1,6 @@ // Copyright (c) 2018 Senseye Ltd. All rights reserved. // Use of this source code is governed by the MIT License that can be found in the LICENSE file. -// Package mbgo implements a mountebank API client. package mbgo import ( diff --git a/client_integration_test.go b/client_integration_test.go index 6d3d478..a359493 100644 --- a/client_integration_test.go +++ b/client_integration_test.go @@ -1,6 +1,7 @@ // Copyright (c) 2018 Senseye Ltd. All rights reserved. // Use of this source code is governed by the MIT License that can be found in the LICENSE file. +//go:build integration // +build integration package mbgo_test @@ -138,7 +139,7 @@ func TestClient_Create_Integration(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "equals", - Request: mbgo.HTTPRequest{ + Request: &mbgo.HTTPRequest{ Method: http.MethodGet, Path: "/foo", Query: map[string][]string{ @@ -153,7 +154,7 @@ func TestClient_Create_Integration(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.HTTPResponse{ + Value: &mbgo.HTTPResponse{ StatusCode: http.StatusOK, Headers: map[string][]string{ "Content-Type": {"application/json"}, @@ -214,7 +215,7 @@ func TestClient_Create_Integration(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.TCPResponse{ + Value: &mbgo.TCPResponse{ Data: "c2Vjb25kIHJlc3BvbnNl", }, }, @@ -223,6 +224,129 @@ func TestClient_Create_Integration(t *testing.T) { }, }, }, + { + Description: "should support nested logical predicates", + Input: mbgo.Imposter{ + Proto: "http", + Port: 8080, + Name: "create_test_predicate_nested_logical", + Stubs: []mbgo.Stub{ + { + Predicates: []mbgo.Predicate{ + { + Operator: "or", + Request: []mbgo.Predicate{ + { + Operator: "equals", + Request: mbgo.HTTPRequest{ + Method: http.MethodPost, + Path: "/foo", + }, + }, + { + Operator: "equals", + Request: mbgo.HTTPRequest{ + Method: http.MethodPost, + Path: "/bar", + }, + }, + { + Operator: "and", + Request: []mbgo.Predicate{ + { + Operator: "equals", + Request: mbgo.HTTPRequest{ + Method: http.MethodPost, + Path: "/baz", + }, + }, + { + Operator: "equals", + Request: mbgo.HTTPRequest{ + Body: "foo", + }, + }, + }, + }, + }, + }, + }, + Responses: []mbgo.Response{ + { + Type: "is", + Value: mbgo.HTTPResponse{ + StatusCode: http.StatusOK, + }, + }, + }, + }, + }, + }, + Before: func(t *testing.T, mb *mbgo.Client) { + _, err := mb.Delete(newContext(time.Second), 8080, false) + assert.MustOk(t, err) + }, + After: func(t *testing.T, mb *mbgo.Client) { + _, err := mb.Delete(newContext(time.Second), 8080, false) + assert.MustOk(t, err) + }, + Expected: &mbgo.Imposter{ + Proto: "http", + Port: 8080, + Name: "create_test_predicate_nested_logical", + Stubs: []mbgo.Stub{ + { + Predicates: []mbgo.Predicate{ + { + Operator: "or", + Request: []mbgo.Predicate{ + { + Operator: "equals", + Request: &mbgo.HTTPRequest{ + Method: http.MethodPost, + Path: "/foo", + }, + }, + { + Operator: "equals", + Request: &mbgo.HTTPRequest{ + Method: http.MethodPost, + Path: "/bar", + }, + }, + { + Operator: "and", + Request: []mbgo.Predicate{ + { + Operator: "equals", + Request: &mbgo.HTTPRequest{ + Method: http.MethodPost, + Path: "/baz", + }, + }, + { + Operator: "equals", + Request: &mbgo.HTTPRequest{ + Body: "foo", + }, + }, + }, + }, + }, + }, + }, + Responses: []mbgo.Response{ + { + Type: "is", + Value: &mbgo.HTTPResponse{ + StatusCode: http.StatusOK, + }, + }, + }, + }, + }, + }, + }, } for _, c := range cases { @@ -325,7 +449,7 @@ func TestClient_Imposter_Integration(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "endsWith", - Request: mbgo.TCPRequest{ + Request: &mbgo.TCPRequest{ Data: "SGVsbG8sIHdvcmxkIQ==", }, }, @@ -333,7 +457,7 @@ func TestClient_Imposter_Integration(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.TCPResponse{ + Value: &mbgo.TCPResponse{ Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", }, }, @@ -449,7 +573,7 @@ func TestClient_AddStub_Integration(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "endsWith", - Request: mbgo.TCPRequest{ + Request: &mbgo.TCPRequest{ Data: "foo", }, }, @@ -457,7 +581,7 @@ func TestClient_AddStub_Integration(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.TCPResponse{ + Value: &mbgo.TCPResponse{ Data: "bar", }, }, @@ -467,7 +591,7 @@ func TestClient_AddStub_Integration(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "endsWith", - Request: mbgo.TCPRequest{ + Request: &mbgo.TCPRequest{ Data: "SGVsbG8sIHdvcmxkIQ==", }, }, @@ -475,7 +599,7 @@ func TestClient_AddStub_Integration(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.TCPResponse{ + Value: &mbgo.TCPResponse{ Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", }, }, @@ -593,7 +717,7 @@ func TestClient_OverwriteStub_Integration(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "endsWith", - Request: mbgo.TCPRequest{ + Request: &mbgo.TCPRequest{ Data: "foo", }, }, @@ -601,7 +725,7 @@ func TestClient_OverwriteStub_Integration(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.TCPResponse{ + Value: &mbgo.TCPResponse{ Data: "bar", }, }, @@ -737,7 +861,7 @@ func TestClient_OverwriteAllStubs_Integration(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "endsWith", - Request: mbgo.TCPRequest{ + Request: &mbgo.TCPRequest{ Data: "foo", }, }, @@ -745,7 +869,7 @@ func TestClient_OverwriteAllStubs_Integration(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.TCPResponse{ + Value: &mbgo.TCPResponse{ Data: "bar", }, }, @@ -755,7 +879,7 @@ func TestClient_OverwriteAllStubs_Integration(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "endsWith", - Request: mbgo.TCPRequest{ + Request: &mbgo.TCPRequest{ Data: "bar", }, }, @@ -763,7 +887,7 @@ func TestClient_OverwriteAllStubs_Integration(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.TCPResponse{ + Value: &mbgo.TCPResponse{ Data: "baz", }, }, diff --git a/doc.go b/doc.go index f584988..6894305 100644 --- a/doc.go +++ b/doc.go @@ -1,2 +1,5 @@ +// Copyright (c) 2018 Senseye Ltd. All rights reserved. +// Use of this source code is governed by the MIT License that can be found in the LICENSE file. + // Package mbgo implements a mountebank API client with support for the HTTP and TCP protocols. package mbgo diff --git a/dto.go b/dto.go new file mode 100644 index 0000000..cd88504 --- /dev/null +++ b/dto.go @@ -0,0 +1,617 @@ +// Copyright (c) 2018 Senseye Ltd. All rights reserved. +// Use of this source code is governed by the MIT License that can be found in the LICENSE file. + +package mbgo + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "reflect" + "strings" +) + +func parseClientSocket(s string) (ip net.IP, err error) { + parts := strings.Split(s, ":") + ipStr := strings.Join(parts[0:len(parts)-1], ":") + + ip = net.ParseIP(ipStr) + if ip == nil { + err = fmt.Errorf("invalid IP address: %s", ipStr) + } + return +} + +func toMapValues(q map[string][]string) map[string]interface{} { + if q == nil { + return nil + } + + out := make(map[string]interface{}, len(q)) + + for k, ss := range q { + if len(ss) == 0 { + continue + } else if len(ss) == 1 { + out[k] = ss[0] + } else { + out[k] = ss + } + } + + return out +} + +func fromMapValues(q map[string]interface{}) (map[string][]string, error) { + if q == nil { + return nil, nil + } + + out := make(map[string][]string, len(q)) + + for k, v := range q { + switch typ := v.(type) { + case string: + out[k] = []string{typ} + case []interface{}: + ss := make([]string, len(typ)) + for i, elem := range typ { + s, ok := elem.(string) + if !ok { + return nil, errors.New("invalid query key array subtype") + } + ss[i] = s + } + out[k] = ss + default: + return nil, fmt.Errorf("invalid query key type: %#v", typ) + } + } + + return out, nil +} + +type httpRequestDTO struct { + RequestFrom string `json:"requestFrom,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Query map[string]interface{} `json:"query,omitempty"` + Headers map[string]interface{} `json:"headers,omitempty"` + Body interface{} `json:"body,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +// MarshalJSON satisfies the json.Marshaler interface. +func (r HTTPRequest) MarshalJSON() ([]byte, error) { + dto := httpRequestDTO{ + RequestFrom: "", + Method: r.Method, + Path: r.Path, + Query: toMapValues(r.Query), + Headers: toMapValues(r.Headers), + Body: r.Body, + Timestamp: r.Timestamp, + } + if r.RequestFrom != nil { + dto.RequestFrom = r.RequestFrom.String() + } + return json.Marshal(dto) +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (r *HTTPRequest) UnmarshalJSON(b []byte) error { + var v httpRequestDTO + err := json.Unmarshal(b, &v) + if err != nil { + return err + } + + if v.RequestFrom != "" { + r.RequestFrom, err = parseClientSocket(v.RequestFrom) + if err != nil { + return nil + } + } + r.Method = v.Method + r.Path = v.Path + r.Query, err = fromMapValues(v.Query) + if err != nil { + return nil + } + r.Headers, err = fromMapValues(v.Headers) + if err != nil { + return nil + } + r.Body = v.Body + r.Timestamp = v.Timestamp + + return nil +} + +type httpResponseDTO struct { + StatusCode int `json:"statusCode,omitempty"` + Headers map[string]interface{} `json:"headers,omitempty"` + Body interface{} `json:"body,omitempty"` + Mode string `json:"_mode,omitempty"` +} + +// MarshalJSON satisfies the json.Marshaler interface. +func (r HTTPResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(httpResponseDTO{ + StatusCode: r.StatusCode, + Headers: toMapValues(r.Headers), + Body: r.Body, + Mode: r.Mode, + }) +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (r *HTTPResponse) UnmarshalJSON(b []byte) error { + var v httpResponseDTO + err := json.Unmarshal(b, &v) + if err != nil { + return err + } + + r.StatusCode = v.StatusCode + r.Headers, err = fromMapValues(v.Headers) + if err != nil { + return err + } + r.Body = v.Body + r.Mode = v.Mode + + return nil +} + +type tcpRequestDTO struct { + RequestFrom string `json:"requestFrom,omitempty"` + Data string `json:"data,omitempty"` +} + +// MarshalJSON satisfies the json.Marshaler interface. +func (r TCPRequest) MarshalJSON() ([]byte, error) { + dto := tcpRequestDTO{ + RequestFrom: "", + Data: r.Data, + } + if r.RequestFrom != nil { + dto.RequestFrom = r.RequestFrom.String() + } + return json.Marshal(dto) +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (r *TCPRequest) UnmarshalJSON(b []byte) error { + var v tcpRequestDTO + err := json.Unmarshal(b, &v) + if err != nil { + return err + } + + if v.RequestFrom != "" { + r.RequestFrom, err = parseClientSocket(v.RequestFrom) + if err != nil { + return err + } + } + r.Data = v.Data + + return err +} + +type tcpResponseDTO struct { + Data string `json:"data"` +} + +// MarshalJSON satisfies the json.Marshaler interface. +func (r TCPResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(tcpResponseDTO(r)) +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (r *TCPResponse) UnmarshalJSON(b []byte) error { + var v tcpResponseDTO + err := json.Unmarshal(b, &v) + if err != nil { + return err + } + + r.Data = v.Data + + return nil +} + +const ( + // Predicate parameter keys for internal use. + paramCaseSensitive = "caseSensitive" + paramExcept = "except" + paramJSONPath = "jsonpath" + paramXPath = "xpath" +) + +type predicateDTO map[string]json.RawMessage + +// MarshalJSON satisfies the json.Marshaler interface. +func (p Predicate) MarshalJSON() ([]byte, error) { + dto := predicateDTO{} + + // marshal request based on type + switch t := p.Request.(type) { + case json.Marshaler: + b, err := t.MarshalJSON() + if err != nil { + return nil, err + } + dto[p.Operator] = b + + case []Predicate: + preds := make([]json.RawMessage, len(t)) + for i, sub := range t { + b, err := sub.MarshalJSON() + if err != nil { + return nil, err + } + preds[i] = b + } + b, err := json.Marshal(preds) + if err != nil { + return nil, err + } + dto[p.Operator] = b + + case string: + b, err := json.Marshal(t) + if err != nil { + return nil, err + } + dto[p.Operator] = b + + default: + return nil, fmt.Errorf("unsupported predicate request type: %v", + reflect.TypeOf(t).String()) + } + + if p.JSONPath != nil { + b, err := json.Marshal(p.JSONPath) + if err != nil { + return nil, err + } + dto[paramJSONPath] = b + } + + if p.CaseSensitive { + b, err := json.Marshal(p.CaseSensitive) + if err != nil { + return nil, err + } + dto[paramCaseSensitive] = b + } + + return json.Marshal(dto) +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (p *Predicate) UnmarshalJSON(b []byte) error { + var dto predicateDTO + err := json.Unmarshal(b, &dto) + if err != nil { + return err + } + + // Handle and delete parameters from the DTO map before we check the + // operator so that we can enforce only one operator exists in the map. + if b, ok := dto[paramCaseSensitive]; ok { + err = json.Unmarshal(b, &p.CaseSensitive) + if err != nil { + return err + } + delete(dto, paramCaseSensitive) + } + if b, ok := dto[paramJSONPath]; ok { + err = json.Unmarshal(b, &p.JSONPath) + if err != nil { + return err + } + delete(dto, paramJSONPath) + } + // Ignore 'except' and 'xpath' parameters for now. + delete(dto, paramExcept) + delete(dto, paramXPath) + + if len(dto) < 1 { + return errors.New("predicate should only have a single operator") + } + for key, b := range dto { + p.Operator = key + + switch key { + // Interpret the request as a string containing JavaScript if the + // inject operator is used. + case "inject": + var js string + err = json.Unmarshal(b, &js) + if err != nil { + return err + } + p.Request = js + + // Slice of predicates + case "and", "or": + var ps []Predicate + err = json.Unmarshal(b, &ps) + if err != nil { + return err + } + p.Request = ps + + // Single predicate + case "not": + var v Predicate + err = json.Unmarshal(b, &v) + if err != nil { + return err + } + p.Request = v + + // Otherwise we have a request object. + default: + p.Request = b // defer unmarshaling until protocol is known + } + } + + return nil +} + +const ( + keyBehaviors = "_behaviors" +) + +// MarshalJSON satisfies the json.Marshaler interface. +func (r Response) MarshalJSON() ([]byte, error) { + dto := make(map[string]json.RawMessage) + + m, ok := r.Value.(json.Marshaler) + if !ok { + return nil, errors.New("response value must implement json.Marshaler") + } + + b, err := m.MarshalJSON() + if err != nil { + return nil, err + } + + dto[r.Type] = b + + if r.Behaviors != nil { + behaviors, err := json.Marshal(r.Behaviors) + if err != nil { + return nil, err + } + dto[keyBehaviors] = behaviors + } + + return json.Marshal(dto) +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (r *Response) UnmarshalJSON(b []byte) error { + var dto map[string]json.RawMessage + err := json.Unmarshal(b, &dto) + if err != nil { + return err + } + + // Handle and delete behaviors from the DTO map before we check the + // type so that we can enforce only one type exists in the map. + if b, ok := dto[keyBehaviors]; ok { + err = json.Unmarshal(b, r.Behaviors) + if err != nil { + return err + } + delete(dto, keyBehaviors) + } + + for key, b := range dto { + r.Type = key + r.Value = b // defer unmarshaling until protocol is known + } + + return nil +} + +type stubDTO struct { + Predicates []Predicate `json:"predicates,omitempty"` + Responses []Response `json:"responses"` +} + +// MarshalJSON satisfies the json.Marshaler interface. +func (s Stub) MarshalJSON() ([]byte, error) { + return json.Marshal(stubDTO(s)) +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (s *Stub) UnmarshalJSON(b []byte) error { + var dto stubDTO + err := json.Unmarshal(b, &dto) + if err != nil { + return err + } + + s.Predicates = dto.Predicates + s.Responses = dto.Responses + + return nil +} + +type imposterRequestDTO struct { + Proto string `json:"protocol"` + Port int `json:"port,omitempty"` + Name string `json:"name,omitempty"` + RecordRequests bool `json:"recordRequests,omitempty"` + AllowCORS bool `json:"allowCORS,omitempty"` + DefaultResponse json.RawMessage `json:"defaultResponse,omitempty"` + Stubs []json.RawMessage `json:"stubs,omitempty"` +} + +// MarshalJSON satisfies the json.Marshaler interface. +func (imp Imposter) MarshalJSON() ([]byte, error) { + dto := imposterRequestDTO{ + Proto: imp.Proto, + Port: imp.Port, + Name: imp.Name, + RecordRequests: imp.RecordRequests, + AllowCORS: imp.AllowCORS, + DefaultResponse: nil, + Stubs: nil, + } + if imp.DefaultResponse != nil { + jm, ok := imp.DefaultResponse.(json.Marshaler) + if !ok { + return nil, errors.New("default response must implemented json.Marshaler") + } + b, err := jm.MarshalJSON() + if err != nil { + return nil, err + } + dto.DefaultResponse = b + } + if n := len(imp.Stubs); n > 0 { + dto.Stubs = make([]json.RawMessage, n) + for i, stub := range imp.Stubs { + b, err := stub.MarshalJSON() + if err != nil { + return nil, err + } + dto.Stubs[i] = b + } + } + return json.Marshal(dto) +} + +type imposterResponseDTO struct { + Port int `json:"port"` + Proto string `json:"protocol"` + Name string `json:"name,omitempty"` + RequestCount int `json:"numberOfRequests,omitempty"` + Stubs []json.RawMessage `json:"stubs,omitempty"` + Requests []json.RawMessage `json:"requests,omitempty"` +} + +func getRequestUnmarshaler(proto string) (json.Unmarshaler, error) { + var um json.Unmarshaler + switch proto { + case "http": + um = &HTTPRequest{} + case "tcp": + um = &TCPRequest{} + default: + return nil, fmt.Errorf("unsupported protocol: %s", proto) + } + return um, nil +} + +func unmarshalPredicateRecurse(proto string, p *Predicate) error { + switch v := p.Request.(type) { + case json.RawMessage: + um, err := getRequestUnmarshaler(proto) + if err != nil { + return err + } + if err = um.UnmarshalJSON(v); err != nil { + return err + } + p.Request = um + + case Predicate: + if err := unmarshalPredicateRecurse(proto, &v); err != nil { + return err + } + case []Predicate: + for i := range v { + if err := unmarshalPredicateRecurse(proto, &v[i]); err != nil { + return err + } + } + } + return nil +} + +func getResponseUnmarshaler(proto string) (json.Unmarshaler, error) { + var um json.Unmarshaler + switch proto { + case "http": + um = &HTTPResponse{} + case "tcp": + um = &TCPResponse{} + default: + return nil, fmt.Errorf("unsupported protocol: %s", proto) + } + return um, nil +} + +// UnmarshalJSON satisfies the json.Unmarshaler interface. +func (imp *Imposter) UnmarshalJSON(b []byte) error { + var dto imposterResponseDTO + err := json.Unmarshal(b, &dto) + if err != nil { + return err + } + + imp.Port = dto.Port + imp.Proto = dto.Proto + imp.Name = dto.Name + imp.RequestCount = dto.RequestCount + + if n := len(dto.Stubs); n > 0 { + imp.Stubs = make([]Stub, n) + for i, b := range dto.Stubs { + var s Stub + err = json.Unmarshal(b, &s) + if err != nil { + return err + } + + for i := range s.Predicates { + err = unmarshalPredicateRecurse(imp.Proto, &s.Predicates[i]) + if err != nil { + return err + } + } + + for i, r := range s.Responses { + if raw, ok := r.Value.(json.RawMessage); ok { + um, err := getResponseUnmarshaler(imp.Proto) + if err != nil { + return err + } + err = um.UnmarshalJSON(raw) + if err != nil { + return err + } + s.Responses[i].Value = um + } + } + + imp.Stubs[i] = s + } + } + + if n := len(dto.Requests); n > 0 { + imp.Requests = make([]interface{}, n) + for i, b := range dto.Requests { + um, err := getRequestUnmarshaler(imp.Proto) + if err != nil { + return err + } + err = um.UnmarshalJSON(b) + if err != nil { + return err + } + imp.Requests[i] = um + } + } + + return nil +} diff --git a/imposter_test.go b/dto_test.go similarity index 78% rename from imposter_test.go rename to dto_test.go index b4164e9..08463fc 100644 --- a/imposter_test.go +++ b/dto_test.go @@ -13,6 +13,200 @@ import ( "github.com/senseyeio/mbgo/internal/assert" ) +type duplex interface { + json.Marshaler + json.Unmarshaler +} + +var ( + _ duplex = &mbgo.HTTPRequest{} + _ duplex = &mbgo.HTTPResponse{} + _ duplex = &mbgo.TCPRequest{} + _ duplex = &mbgo.TCPResponse{} + _ duplex = &mbgo.Predicate{} + _ duplex = &mbgo.Response{} + _ duplex = &mbgo.Stub{} + _ duplex = &mbgo.Imposter{} +) + +func TestPredicate_MarshalJSON(t *testing.T) { + cases := map[string]struct { + predicate mbgo.Predicate + want map[string]interface{} + }{ + "contains request": { + predicate: mbgo.Predicate{ + Operator: "is", + Request: mbgo.HTTPRequest{ + Method: http.MethodGet, + }, + }, + want: map[string]interface{}{ + "is": map[string]interface{}{ + "method": http.MethodGet, + }, + }, + }, + "contains nested predicate": { + predicate: mbgo.Predicate{ + Operator: "not", + Request: mbgo.Predicate{ + Operator: "is", + Request: mbgo.HTTPRequest{ + Method: http.MethodGet, + }, + }, + }, + want: map[string]interface{}{ + "not": map[string]interface{}{ + "is": map[string]interface{}{ + "method": http.MethodGet, + }, + }, + }, + }, + "contains nested predicate collection": { + predicate: mbgo.Predicate{ + Operator: "or", + Request: []mbgo.Predicate{ + { + Operator: "is", + Request: mbgo.HTTPRequest{ + Method: http.MethodGet, + }, + }, + { + Operator: "is", + Request: mbgo.HTTPRequest{ + Body: "foo", + }, + }, + }, + }, + want: map[string]interface{}{ + "or": []map[string]interface{}{ + { + "is": map[string]interface{}{ + "method": http.MethodGet, + }, + }, + { + "is": map[string]interface{}{ + "body": "foo", + }, + }, + }, + }, + }, + } + + for name, c := range cases { + c := c + + t.Run(name, func(t *testing.T) { + t.Parallel() + + // verify JSON structure of expected value versus actual + actualBytes, err := json.Marshal(c.predicate) + assert.MustOk(t, err) + + expectedBytes, err := json.Marshal(c.want) + assert.MustOk(t, err) + + var actual, expected map[string]interface{} + err = json.Unmarshal(actualBytes, &actual) + assert.MustOk(t, err) + + err = json.Unmarshal(expectedBytes, &expected) + assert.MustOk(t, err) + + assert.Equals(t, expected, actual) + }) + } +} + +func TestPredicate_UnmarshalJSON(t *testing.T) { + cases := map[string]struct { + want mbgo.Predicate + json map[string]interface{} + }{ + "contains request": { + json: map[string]interface{}{ + "is": map[string]interface{}{ + "method": http.MethodGet, + }, + }, + want: mbgo.Predicate{ + Operator: "is", + Request: json.RawMessage(`{"method":"GET"}`), + }, + }, + "contains nested predicate": { + json: map[string]interface{}{ + "not": map[string]interface{}{ + "is": map[string]interface{}{ + "method": http.MethodGet, + }, + }, + }, + want: mbgo.Predicate{ + Operator: "not", + Request: mbgo.Predicate{ + Operator: "is", + Request: json.RawMessage(`{"method":"GET"}`), + }, + }, + }, + "contains nested predicate collection": { + json: map[string]interface{}{ + "or": []map[string]interface{}{ + { + "is": map[string]interface{}{ + "method": http.MethodGet, + }, + }, + { + "is": map[string]interface{}{ + "body": "foo", + }, + }, + }, + }, + want: mbgo.Predicate{ + Operator: "or", + Request: []mbgo.Predicate{ + { + Operator: "is", + Request: json.RawMessage(`{"method":"GET"}`), + }, + { + Operator: "is", + Request: json.RawMessage(`{"body":"foo"}`), + }, + }, + }, + }, + } + + for name, c := range cases { + c := c + + t.Run(name, func(t *testing.T) { + t.Parallel() + + b, err := json.Marshal(c.json) + assert.MustOk(t, err) + + var got mbgo.Predicate + err = json.Unmarshal(b, &got) + assert.MustOk(t, err) + + // verify unmarshaled predicate versus expected + assert.Equals(t, c.want, got) + }) + } +} + func TestImposter_MarshalJSON(t *testing.T) { cases := []struct { Description string @@ -482,7 +676,7 @@ func TestImposter_UnmarshalJSON(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "equals", - Request: mbgo.HTTPRequest{ + Request: &mbgo.HTTPRequest{ RequestFrom: net.IPv4(172, 17, 0, 1), Method: "POST", Path: "/foo", @@ -499,7 +693,7 @@ func TestImposter_UnmarshalJSON(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.HTTPResponse{ + Value: &mbgo.HTTPResponse{ StatusCode: http.StatusOK, Mode: "text", Headers: map[string][]string{ @@ -551,7 +745,7 @@ func TestImposter_UnmarshalJSON(t *testing.T) { Predicates: []mbgo.Predicate{ { Operator: "equals", - Request: mbgo.TCPRequest{ + Request: &mbgo.TCPRequest{ RequestFrom: net.IPv4(172, 17, 0, 1), Data: "SGVsbG8sIHdvcmxkIQ==", }, @@ -560,7 +754,7 @@ func TestImposter_UnmarshalJSON(t *testing.T) { Responses: []mbgo.Response{ { Type: "is", - Value: mbgo.TCPResponse{ + Value: &mbgo.TCPResponse{ Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", }, }, diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/imposter.go b/imposter.go index ac88030..b722524 100644 --- a/imposter.go +++ b/imposter.go @@ -4,28 +4,11 @@ package mbgo import ( - "encoding/json" - "errors" - "fmt" "net" "net/http" "net/url" - "strings" ) -const behaviorsKey = "_behaviors" - -func parseHybridAddress(s string) (ip net.IP, err error) { - parts := strings.Split(s, ":") - ipStr := strings.Join(parts[0:len(parts)-1], ":") - - ip = net.ParseIP(ipStr) - if ip == nil { - err = fmt.Errorf("invalid IP address: %s", ipStr) - } - return -} - // HTTPRequest describes an incoming HTTP request received by an // Imposter of the "http" protocol. // @@ -54,50 +37,6 @@ type HTTPRequest struct { Timestamp string } -// httpRequestDTO is a data transfer object used as an intermediary value -// for marshalling and un-marshalling the JSON structure of an HTTPRequest. -type httpRequestDTO struct { - RequestFrom string `json:"requestFrom,omitempty"` - Method string `json:"method,omitempty"` - Path string `json:"path,omitempty"` - Query map[string]interface{} `json:"query,omitempty"` - Headers map[string]interface{} `json:"headers,omitempty"` - Body interface{} `json:"body,omitempty"` - Timestamp string `json:"timestamp,omitempty"` -} - -// toMapValues maps an HTTP query or header value to its mountebank JSON representation. -func toMapValues(q map[string][]string) map[string]interface{} { - out := make(map[string]interface{}, len(q)) - - for k, ss := range q { - if len(ss) == 0 { - continue - } else if len(ss) == 1 { - out[k] = ss[0] - } else { - out[k] = ss - } - } - - return out -} - -// toDTO maps an HTTPRequest value to a httpRequestDTO value. -func (r HTTPRequest) toDTO() httpRequestDTO { - dto := httpRequestDTO{} - if r.RequestFrom != nil { - dto.RequestFrom = r.RequestFrom.String() - } - dto.Method = r.Method - dto.Path = r.Path - dto.Query = toMapValues(r.Query) - dto.Headers = toMapValues(r.Headers) - dto.Body = r.Body - dto.Timestamp = r.Timestamp - return dto -} - // TCPRequest describes incoming TCP data received by an Imposter of // the "tcp" protocol. // @@ -111,113 +50,6 @@ type TCPRequest struct { Data string } -// tcpRequestDTO is a data transfer object used as an intermediary value -// for marshalling and un-marshalling the JSON structure of a TCPRequest. -type tcpRequestDTO struct { - RequestFrom string `json:"requestFrom,omitempty"` - Data string `json:"data,omitempty"` -} - -// toDTO maps a TCPRequest value to a tcpRequestDTO value. -func (r TCPRequest) toDTO() tcpRequestDTO { - dto := tcpRequestDTO{} - if r.RequestFrom != nil { - dto.RequestFrom = r.RequestFrom.String() - } - dto.Data = r.Data - return dto -} - -// fromMapValues parses a mountebank JSON representation of an HTTP header or query -// into its respective stdlib map representation. -func fromMapValues(q map[string]interface{}) (map[string][]string, error) { - out := make(map[string][]string, len(q)) - - for k, v := range q { - switch typ := v.(type) { - case string: - out[k] = []string{typ} - case []interface{}: - ss := make([]string, len(typ)) - for i, elem := range typ { - s, ok := elem.(string) - if !ok { - return nil, errors.New("invalid query key array subtype") - } - ss[i] = s - } - out[k] = ss - default: - return nil, fmt.Errorf("invalid query key type: %#v", typ) - } - } - - return out, nil -} - -// unmarshalRequest unmarshals a network request given its protocol proto and the JSON data b. -func unmarshalRequest(proto string, b json.RawMessage) (v interface{}, err error) { - switch proto { - case "http": - var dto httpRequestDTO - if err = json.Unmarshal(b, &dto); err != nil { - return - } - - var ( - ip net.IP - q, h map[string][]string - ) - - if dto.RequestFrom != "" { - ip, err = parseHybridAddress(dto.RequestFrom) - if err != nil { - return - } - } - q, err = fromMapValues(dto.Query) - if err != nil { - return - } - h, err = fromMapValues(dto.Headers) - if err != nil { - return - } - - v = HTTPRequest{ - RequestFrom: ip, - Method: dto.Method, - Path: dto.Path, - Query: q, - Headers: h, - Body: dto.Body, - Timestamp: dto.Timestamp, - } - return - - case "tcp": - var dto tcpRequestDTO - if err = json.Unmarshal(b, &dto); err != nil { - return - } - var ip net.IP - if dto.RequestFrom != "" { - ip, err = parseHybridAddress(dto.RequestFrom) - if err != nil { - return - } - } - v = TCPRequest{ - RequestFrom: ip, - Data: dto.Data, - } - return - - default: - return nil, fmt.Errorf("unsupported protocol: %s", proto) - } -} - // JSONPath is a predicate parameter used to narrow the scope of a tested value // to one found at the specified path in the response JSON. // @@ -249,80 +81,6 @@ type Predicate struct { CaseSensitive bool } -// toDTO maps a Predicate value to a predicateDTO value. -func (p Predicate) toDTO() (predicateDTO, error) { - dto := predicateDTO{} - - var v interface{} - switch typ := p.Request.(type) { - case string: - v = typ - case HTTPRequest: - v = typ.toDTO() - case *HTTPRequest: - v = typ.toDTO() - case TCPRequest: - v = typ.toDTO() - case *TCPRequest: - v = typ.toDTO() - } - - b, err := json.Marshal(v) - if err != nil { - return dto, err - } - dto[p.Operator] = b - - if p.JSONPath != nil { - b, err = json.Marshal(p.JSONPath) - if err != nil { - return dto, err - } - dto["jsonpath"] = b - } - - if p.CaseSensitive { - dto["caseSensitive"] = []byte("true") - } - - return dto, nil -} - -// predicateDTO is an data-transfer object used as an intermediary value -// for delaying the marshalling and un-marshalling of its inner request -// value until the protocol is known at runtime. See the unmarshalProto -// method for more details. -type predicateDTO map[string]json.RawMessage - -// unmarshalProto unmarshals the predicateDTO dto into a Predicate value -// with its Request field set to the appropriate type based on the specified -// network protocol proto - currently either HTTPRequest or TCPRequest. -func (dto predicateDTO) unmarshalProto(proto string) (p Predicate, err error) { - if len(dto) < 1 { - err = errors.New("unexpected Predicate JSON structure") - return - } - - for key, b := range dto { - p.Operator = key - - switch key { - // Interpret the request as a string containing JavaScript if the - // inject operator is used. - case "inject": - var js string - err = json.Unmarshal(b, &js) - if err != nil { - return - } - p.Request = js - default: - p.Request, err = unmarshalRequest(proto, b) - } - } - return -} - // HTTPResponse is a Response.Value used to respond to a matched HTTPRequest. // // See more information about HTTP responses in mountebank at: @@ -342,25 +100,6 @@ type HTTPResponse struct { Mode string } -// httpResponseDTO is the data-transfer object used to describe the -// JSON structure of an HTTPResponse value. -type httpResponseDTO struct { - StatusCode int `json:"statusCode,omitempty"` - Headers map[string]interface{} `json:"headers,omitempty"` - Body interface{} `json:"body,omitempty"` - Mode string `json:"_mode,omitempty"` -} - -// toDTO maps a HTTPResponse value to an httpResponseDTO value. -func (r HTTPResponse) toDTO() httpResponseDTO { - return httpResponseDTO{ - StatusCode: r.StatusCode, - Headers: toMapValues(r.Headers), - Body: r.Body, - Mode: r.Mode, - } -} - // TCPResponse is a Response.Value to a matched incoming TCPRequest. // // See more information about TCP responses in mountebank at: @@ -372,17 +111,13 @@ type TCPResponse struct { Data string } -// tcpResponseDTO is the data-transfer object used to describe the -// JSON structure of an TCPResponse value. -type tcpResponseDTO struct { - Data string `json:"data"` -} - -// toDTO maps a TCPResponse value to a tcpResponseDTO value. -func (r TCPResponse) toDTO() tcpResponseDTO { - return tcpResponseDTO{ - Data: r.Data, - } +// Behaviors defines the possible response behaviors for a stub. +// +// See more information on stub behaviours in mountebank at: +// http://www.mbtest.org/docs/api/behaviors. +type Behaviors struct { + // Wait adds latency to a response by waiting a specified number of milliseconds before sending the response. + Wait int `json:"wait,omitempty"` } // Response defines a networked response sent by a Stub whenever an @@ -406,110 +141,6 @@ type Response struct { Behaviors *Behaviors } -// Behaviors defines the possible response behaviors for a stub. -// -// See more information on stub behaviours in mountebank at: -// http://www.mbtest.org/docs/api/behaviors. -type Behaviors struct { - // Wait adds latency to a response by waiting a specified number of milliseconds before sending the response. - Wait int `json:"wait,omitempty"` -} - -func getResponseSubTypeDTO(v interface{}) (interface{}, error) { - switch typ := v.(type) { - case HTTPResponse: - v = typ.toDTO() - case *HTTPResponse: - v = typ.toDTO() - case TCPResponse: - v = typ.toDTO() - case *TCPResponse: - v = typ.toDTO() - default: - return nil, errors.New("invalid response type") - } - return v, nil -} - -// toDTO maps a Response value to a responseDTO value; used for json.Marshal. -func (r Response) toDTO() (responseDTO, error) { - dto := responseDTO{} - - v, err := getResponseSubTypeDTO(r.Value) - if err != nil { - return dto, err - } - - b, err := json.Marshal(v) - if err != nil { - return dto, err - } - - dto[r.Type] = b - - if r.Behaviors != nil { - behaviors, err := json.Marshal(r.Behaviors) - if err != nil { - return dto, err - } - dto[behaviorsKey] = behaviors - } - - return dto, nil -} - -// responseDTO is an data-transfer object used as an intermediary value -// for delaying the marshalling and un-marshalling of its inner response -// value until the protocol is known at runtime. See the unmarshalProto -// method for more details. -type responseDTO map[string]json.RawMessage - -// unmarshalProto un-marshals the responseDTO dto into a Response value -// with its Value field set to the appropriate type based on the specified -// network protocol proto - currently either type HTTPResponse or TCPResponse. -func (dto responseDTO) unmarshalProto(proto string) (resp Response, err error) { - if len(dto) != 1 { - if len(dto) == 2 { - if _, ok := dto[behaviorsKey]; !ok { - err = errors.New("unexpected Predicate JSON structure") - return - } - } - } - - for key, b := range dto { - resp.Type = key - - switch proto { - case "http": - r := httpResponseDTO{} - if err = json.Unmarshal(b, &r); err != nil { - return - } - var h map[string][]string - h, err = fromMapValues(r.Headers) - if err != nil { - } - resp.Value = HTTPResponse{ - StatusCode: r.StatusCode, - Headers: h, - Body: r.Body, - Mode: r.Mode, - } - - case "tcp": - r := tcpResponseDTO{} - if err = json.Unmarshal(b, &r); err != nil { - return - } - resp.Value = TCPResponse{ - Data: r.Data, - } - } - } - return -} - // Stub adds behaviour to Imposters where one or more registered Responses // will be returned if an incoming request matches all of the registered // Predicates. Any Stub value without Predicates always matches and returns @@ -530,75 +161,6 @@ type Stub struct { Responses []Response } -// MarshalJSON implements the json.Marshaler interface for Stub, -// used to map a Stub value to its JSON structure. -func (s Stub) MarshalJSON() ([]byte, error) { - dto, err := s.toDTO() - if err != nil { - return nil, err - } - return json.Marshal(dto) -} - -// toDTO maps a Stub value to a stubDTO value; used for json.Marshal. -func (s Stub) toDTO() (stubDTO, error) { - dto := stubDTO{ - Predicates: make([]predicateDTO, len(s.Predicates)), - Responses: make([]responseDTO, len(s.Responses)), - } - for i, p := range s.Predicates { - v, err := p.toDTO() - if err != nil { - return dto, err - } - dto.Predicates[i] = v - } - for i, r := range s.Responses { - v, err := r.toDTO() - if err != nil { - return dto, err - } - dto.Responses[i] = v - } - return dto, nil -} - -// stubDTO is an data-transfer object used as an intermediary value -// for delaying the marshalling and un-marshalling the JSON structure -// of a Stub value. -type stubDTO struct { - Predicates []predicateDTO `json:"predicates,omitempty"` - Responses []responseDTO `json:"responses"` -} - -// unmarshalProto un-marshals the stubDTO dto into a Stub value with its -// inner Predicate.Request fields set to the appropriate type based on the -// specified network protocol proto. -func (dto stubDTO) unmarshalProto(proto string) (s Stub, err error) { - // build up Predicate.Request values based on the protocol. - s.Predicates = make([]Predicate, 0, len(dto.Predicates)) - for _, v := range dto.Predicates { - var p Predicate - p, err = v.unmarshalProto(proto) - if err != nil { - return - } - s.Predicates = append(s.Predicates, p) - } - - // likewise, build up the Response.Value values based on the protocol - s.Responses = make([]Response, 0, len(dto.Responses)) - for _, v := range dto.Responses { - var r Response - r, err = v.unmarshalProto(proto) - if err != nil { - return - } - s.Responses = append(s.Responses, r) - } - return -} - // Imposter is the primary mountebank resource, representing a server/service // that listens for networked traffic of a specified protocol and port, with the // ability to match incoming requests and respond to them based on the behaviour @@ -645,96 +207,3 @@ type Imposter struct { // Stubs contains zero or more valid Stubs associated with the Imposter. Stubs []Stub } - -// MarshalJSON implements the json.Marshaler interface for Imposter, -// used to map an Imposter value to its JSON structure for creation. -// -// See details about the full creation structure of an Imposter at: -// http://www.mbtest.org/docs/api/contracts?type=imposter -func (imp Imposter) MarshalJSON() ([]byte, error) { - m := make(map[string]interface{}) - - // required fields - m["port"] = imp.Port - m["protocol"] = imp.Proto - - // optional fields - if imp.Name != "" { - m["name"] = imp.Name - } - if imp.RecordRequests { - m["recordRequests"] = imp.RecordRequests - } - if len(imp.Stubs) > 0 { - m["stubs"] = imp.Stubs - } - if imp.AllowCORS { - m["allowCORS"] = imp.AllowCORS - } - if imp.DefaultResponse != nil { - v, err := getResponseSubTypeDTO(imp.DefaultResponse) - if err != nil { - return nil, err - } - m["defaultResponse"] = v - } - return json.Marshal(&m) -} - -// imposterResponseDTO is an data-transfer object used as an intermediary -// value for an Imposter value from a mountebank API response. Note that -// this JSON structure differs from the structure provided during creation. -// -// See details about the full response structure of an Imposter at: -// http://www.mbtest.org/docs/api/contracts?type=imposter -type imposterResponseDTO struct { - Port int `json:"port"` - Proto string `json:"protocol"` - Name string `json:"name,omitempty"` - RequestCount int `json:"numberOfRequests"` - Stubs []stubDTO `json:"stubs,omitempty"` - Requests []json.RawMessage `json:"requests,omitempty"` -} - -// UnmarshalJSON implements the json.Unmarshaler interface for Imposter. -// -// The un-marshalling of any nested Predicate.Request or Response values -// within the Stubs will be determined at runtime based on the protocol -// used by the Imposter. For instance, all Predicate.Request values would -// be of type HTTPRequest and all Response.Value values of type HTTPResponse -// if Proto == "http". -// -// See details about the full structure of an Imposter at: -// http://www.mbtest.org/docs/api/contracts?type=imposter -func (imp *Imposter) UnmarshalJSON(b []byte) error { - dto := imposterResponseDTO{} - if err := json.Unmarshal(b, &dto); err != nil { - return err - } - imp.Port = dto.Port - imp.Proto = dto.Proto - imp.Name = dto.Name - imp.RequestCount = dto.RequestCount - if len(dto.Stubs) > 0 { - imp.Stubs = make([]Stub, 0, len(dto.Stubs)) - for _, v := range dto.Stubs { - stub, err := v.unmarshalProto(imp.Proto) - if err != nil { - return err - } - imp.Stubs = append(imp.Stubs, stub) - } - } - if len(dto.Requests) > 0 { - imp.Requests = make([]interface{}, 0, len(dto.Requests)) - - for _, b := range dto.Requests { - req, err := unmarshalRequest(imp.Proto, b) - if err != nil { - return err - } - imp.Requests = append(imp.Requests, req) - } - } - return nil -} diff --git a/internal/rest/rest_integration_test.go b/internal/rest/rest_integration_test.go index 777c5ab..3475233 100644 --- a/internal/rest/rest_integration_test.go +++ b/internal/rest/rest_integration_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2018 Senseye Ltd. All rights reserved. +// Use of this source code is governed by the MIT License that can be found in the LICENSE file. + +//go:build integration // +build integration package rest_test