Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Develop #3

Merged
merged 17 commits into from
Aug 13, 2017
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
vendor/
dist/
testdata/
44 changes: 40 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
[![Build
Status](https://travis-ci.org/5Sigma/spyder.svg?branch=master)](https://travis-ci.org/5Sigma/spyder)

# spyder
# Spyder
API Testing and Request Framework

## Installation

### OSX

On OSX, spyder can be install with brew:

```
brew install 5sigma/tap/spyder
```

### Linux

Download the linux package from for the latest release:

https://github.com/5Sigma/spyder/releases/latest

### Windows

Windows binaries can be found in the release:

https://github.com/5Sigma/spyder/releases/latest

## API Testing and Requests

Expand Down Expand Up @@ -75,7 +96,7 @@ For POST requests the node is submitted as stringified JSON in the post body.
}
```

## Handling dynamic data
For more information about endpoints check out the [Endpoint Configuration Reference](https://github.com/5Sigma/spyder/wiki/Endpoint-Configuration-Reference)

The easiest way of handling dynamic data is by using variables directly inside
the configuration. There are two configuration files:
Expand Down Expand Up @@ -175,6 +196,21 @@ This request uses a transform script located at `scripts/signRequest.js`. That
could look like:

```js
signature = $hmac($variables.get('session_token_secret'), $payload.get());
$headers.set('Authorization', $variables.get('session_token_id') + ':' + signature)
signature = $hmac($variables.get('session_token_secret'), $request.body);
$request.headers.set('Authorization', $variables.get('session_token_id') + ':' + signature)
```

For more information on scripting see the [Scripting Reference](https://github.com/5Sigma/spyder/wiki/Script-Reference)


# Stress testing

Endpoints can be rapidly requested for stress testing using the `hammer`
command. The request will be made a number of times specified by the count
flag, or 100 times by default.

```
spyder hammer --count 1000 myEndpoint
```

For more information on scripting see the [Scripting Reference](https://github.com/5Sigma/spyder/wiki/Script-Reference)
77 changes: 77 additions & 0 deletions cmd/hammer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cmd

import (
"errors"
"fmt"
"github.com/5sigma/spyder/config"
"github.com/5sigma/spyder/endpoint"
"github.com/5sigma/spyder/output"
"github.com/5sigma/spyder/request"
"github.com/dustin/go-humanize"
"github.com/spf13/cobra"
"path"
"time"
)

// hammerCmd represents the hammer command
var hammerCmd = &cobra.Command{
Use: "hammer",
Short: "Makes an endpoint request a number of times rapidly.",
Long: `Make a number of request to an endpoint very rapidly and record the request timing. The hammer command expects an endpoint to be passed in the same manner as the 'request' command:

spyder hammer --count 100 myEndpoint`,
Run: func(cmd *cobra.Command, args []string) {
var (
count int
totalTime time.Duration
maxTime time.Duration
minTime time.Duration
totalBytes int64
)

count, _ = cmd.Flags().GetInt("count")
if len(args) == 0 {
output.PrintFatal(errors.New("No endpoint specified"))
}

configPath := path.Join(config.ProjectPath, "endpoints", args[0]+".json")
config, err := endpoint.Load(configPath)
if err != nil {
output.PrintFatal(err)
}

bar := output.NewProgress(count)
for i := 0; i <= count; i++ {
res, err := request.Do(config)
if err != nil {
output.PrintFatal(err)
}
totalTime += res.RequestTime
bar.Inc()
if minTime == 0 {
minTime = res.RequestTime
}
if res.RequestTime > maxTime {
maxTime = res.RequestTime
}
if res.RequestTime < minTime {
minTime = res.RequestTime
}
totalBytes += res.Response.ContentLength
}

avgTime := totalTime / time.Duration(count)

output.PrintProperty("Number of requests", fmt.Sprintf("%d", count))
output.PrintProperty("Average time", fmt.Sprintf("%s", avgTime))
output.PrintProperty("Fastest", fmt.Sprintf("%s", minTime))
output.PrintProperty("Slowest", fmt.Sprintf("%s", maxTime))
output.PrintProperty("Total data transfer",
humanize.Bytes(uint64(totalBytes)))
},
}

func init() {
RootCmd.AddCommand(hammerCmd)
hammerCmd.PersistentFlags().Int("count", 100, "Request count")
}
4 changes: 2 additions & 2 deletions cmd/request.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"github.com/5sigma/spyder/config"
"github.com/5sigma/spyder/endpoint"
"github.com/5sigma/spyder/explorer"
"github.com/5sigma/spyder/output"
Expand All @@ -24,7 +25,7 @@ requested using:
$ spyder request sessions/auth
`,
Run: func(cmd *cobra.Command, args []string) {
config, err := endpoint.Load(path.Join("endpoints", args[0]+".json"))
config, err := endpoint.Load(path.Join(config.ProjectPath, "endpoints", args[0]+".json"))
if err != nil {
output.PrintFatal(err)
}
Expand All @@ -33,7 +34,6 @@ $ spyder request sessions/auth
if err != nil {
output.PrintFatal(err)
}

explorer.Start(args[0], config, res)

},
Expand Down
19 changes: 17 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ var LocalConfig = loadConfigFile("config.local.json")
// GlobalConfig - The global configuration read from config.json
var GlobalConfig = loadConfigFile("config.json")

// The path to the project root
var ProjectPath = "."

// InMemory - When true the config will not write to the disk. This is used for
// testing.
var InMemory = false
Expand Down Expand Up @@ -61,15 +64,27 @@ func ExpandString(str string) string {

// LoadConfigFile - Loads a config from a file on the disk.
func loadConfigFile(filename string) *Config {
var (
c *Config
)
if InMemory {
return loadDefaultConfig()
}

if _, err := os.Stat(filename); !os.IsNotExist(err) {
bytes, _ := ioutil.ReadFile(filename)
return LoadConfig(bytes)
if strings.TrimSpace(string(bytes)) == "" {
c = loadDefaultConfig()
c.Filename = filename
return c
}
c = LoadConfig(bytes)
c.Filename = filename
return c
}
return loadDefaultConfig()
c = loadDefaultConfig()
c.Filename = filename
return c
}

// Loads a config from a byte array.
Expand Down
7 changes: 7 additions & 0 deletions endpoint/config.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"variables": {
"host": "127.0.0.1",
"method": "POST",
"var": "123"
}
}
41 changes: 26 additions & 15 deletions endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@ type (
Url string
OnComplete []string
Transform []string
Headers map[string][]string
}
)

func New() *EndpointConfig {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exported function New should have comment or be unexported

return &EndpointConfig{
json: &gabs.Container{},
OnComplete: []string{},
Transform: []string{},
}
}

// Load - Loads a confugruation from a file on the disk.
func Load(filename string) (*EndpointConfig, error) {
var (
Expand Down Expand Up @@ -51,12 +60,19 @@ func LoadBytes(fileBytes []byte) (*EndpointConfig, error) {
method, _ := jsonObject.Path("method").Data().(string)
url, _ := jsonObject.Path("url").Data().(string)

headerMap := make(map[string][]string)
children, _ := jsonObject.S("headers").ChildrenMap()
for key, child := range children {
headerMap[key] = []string{config.ExpandString(child.Data().(string))}
}

epConfig = &EndpointConfig{
json: jsonObject,
Method: method,
Url: url,
OnComplete: []string{},
Transform: []string{},
Headers: headerMap,
}

transformNodes, _ := jsonObject.S("transform").Children()
Expand All @@ -80,24 +96,17 @@ func (ep *EndpointConfig) GetString(path string) string {

// GetJSONString - returns the inner JSON at the path as a string.
func (ep *EndpointConfig) GetJSONString(path string) string {
return ep.json.Path("data").String()
if ep.json.Exists("data") {
return ep.json.Path("data").String()
}
return ""
}

// GetJSONBytes - returns the inner JSON at the path as a byte array.
func (ep *EndpointConfig) GetJSONBytes(path string) []byte {
return ep.json.Path("data").Bytes()
}

// Headers - returns the configured request headers as a string map
func (ep *EndpointConfig) Headers() map[string][]string {
headerMap := make(map[string][]string)
children, _ := ep.json.S("headers").ChildrenMap()
for key, child := range children {
headerMap[key] = []string{config.ExpandString(child.Data().(string))}
}
return headerMap
}

// RequestMethod - returns the request method.
func (ep *EndpointConfig) RequestMethod() string {
method := strings.ToUpper(ep.GetString("method"))
Expand All @@ -107,8 +116,8 @@ func (ep *EndpointConfig) RequestMethod() string {
// RequestURL - returns the full url for the request. If this is a GET request
// and has request parameters they are included in the URL.
func (ep *EndpointConfig) RequestURL() string {
if ep.Method == "GET" {
baseURL, _ := url.Parse(config.ExpandString(ep.Url))
if ep.RequestMethod() == "GET" {
baseURL, _ := url.Parse(expandFakes(config.ExpandString(ep.Url)))
params := url.Values{}
for k, v := range ep.GetRequestParams() {
params.Add(k, v)
Expand All @@ -129,15 +138,17 @@ func (ep *EndpointConfig) GetRequestParams() map[string]string {
paramsMap := make(map[string]string)
children, _ := ep.json.S("data").ChildrenMap()
for key, child := range children {
paramsMap[key] = config.ExpandString(child.Data().(string))
paramsMap[key] = expandFakes(config.ExpandString(child.Data().(string)))
}
return paramsMap
}

// RequestData - returns the data attribute from the config. This contains the
// payload, for a POST request, that will be sent to the server.
func (ep *EndpointConfig) RequestData() []byte {
dataJSON := config.ExpandString(ep.GetJSONString("data"))
dataJSON := ep.GetJSONString("data")
dataJSON = config.ExpandString(dataJSON)
dataJSON = expandFakes(dataJSON)
return []byte(dataJSON)
}

Expand Down
10 changes: 7 additions & 3 deletions endpoint/endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ func TestRequestUrl(t *testing.T) {
}

// POST request
ep.Method = "POST"
json.Set("post", "method")
ep, _ = LoadBytes(json.Bytes())
if ep.RequestURL() != ep.Url {
t.Errorf("Request URL missmatch:\nExpecting: %s\nReceived: %s", ep.Url,
ep.RequestURL())
Expand All @@ -50,8 +51,11 @@ func TestRequestUrl(t *testing.T) {
// GET request with variable expansion
config.LocalConfig.SetVariable("var", "value1")
config.LocalConfig.SetVariable("host", "127.0.0.1")
json.Set("http://$host/api/endpoint", "url")
json = buildConfig()
params, _ = json.Object("data")
params.Set("$var", "option2")
params.Set("3", "option1")
json.Set("http://$host/api/endpoint", "url")
ep, _ = LoadBytes(json.Bytes())
expectedUrl = "http://127.0.0.1/api/endpoint?option1=3&option2=value1"
if ep.RequestURL() != expectedUrl {
Expand All @@ -72,7 +76,7 @@ func TestHeaders(t *testing.T) {
t.Fatalf("Error reading config: %s", err.Error())
}

headerMap := ep.Headers()
headerMap := ep.Headers
contentTypeValues := headerMap["Content-Type"]
if contentTypeValues[0] != "application/json" {
t.Errorf("Header not stored or retrieved correctly")
Expand Down
Loading