Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 32 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,29 +289,41 @@ Run `./hammer -h` for the live list.

## Profile format

For anything more than a single endpoint, use a profile. A profile is a **stream
of JSON `Call` objects** (no enclosing array — just concatenate them, whitespace
between objects is fine). Pass it with `-profile FILE`, or stream it on stdin
with `-profile -`.
For anything more than a single endpoint, use a profile: a list of weighted
`Call` objects. Pass it with `-profile FILE`, or stream it on stdin with
`-profile -`. JSON field names are case-insensitive, so `"weight"` and
`"Weight"` are equivalent.

The natural form is a **JSON array**:

```json
{
"Weight": 40,
"Method": "GET",
"URL": "https://httpbin.org/get",
"Body": "",
"Headers": {
"Authorization": "Bearer your-token",
"X-Trace-Id": "hammer-load-test"
[
{
"Weight": 40,
"Method": "GET",
"URL": "https://httpbin.org/get",
"Headers": {
"Authorization": "Bearer your-token",
"X-Trace-Id": "hammer-load-test"
}
},
{
"Weight": 20,
"Method": "POST",
"URL": "https://httpbin.org/post",
"Body": "{\"test\":\"hammer\"}",
"Type": "REST"
}
}
{
"Weight": 20,
"Method": "POST",
"URL": "https://httpbin.org/post",
"Body": "{\"test\":\"hammer\"}",
"Type": "REST"
}
]
```

A bare **stream of objects** (concatenated, no enclosing array — whitespace
between them optional) is also accepted, which is handy for appending calls
to a file or generating them line by line:

```json
{"Weight": 40, "Method": "GET", "URL": "https://httpbin.org/get"}
{"Weight": 20, "Method": "POST", "URL": "https://httpbin.org/post", "Body": "{\"test\":\"hammer\"}", "Type": "REST"}
```

| Field | Required | Description |
Expand Down
63 changes: 51 additions & 12 deletions profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,23 +107,62 @@ func LoadFromFile(path string) (*Profile, error) {
return p, nil
}

// LoadFromReader parses a traffic profile from any reader containing a stream
// of JSON-encoded Call objects. It lets callers feed a profile from stdin or
// any other source without first materializing a file.
// LoadFromReader parses a traffic profile from any reader. It accepts either
// form interchangeably:
//
// - a JSON array of Call objects: [ {…}, {…} ] (the natural shape most
// callers and agents reach for first), or
// - a bare stream of Call objects: {…} {…} (concatenated, whitespace
// between them optional).
//
// This lets callers feed a profile from stdin or any other source without
// first materializing a file.
func LoadFromReader(r io.Reader) (*Profile, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
if len(bytes.TrimSpace(data)) == 0 {
return nil, fmt.Errorf("no calls found")
}

p := &Profile{}
dec := json.NewDecoder(r)
for {
c := &Call{}
if err := dec.Decode(c); err == io.EOF {
break
} else if err != nil {
return nil, err
dec := json.NewDecoder(bytes.NewReader(data))

// Peek the first JSON token to tell an array apart from an object stream
// without consuming a whole value.
tok, err := dec.Token()
if err != nil {
return nil, err
}
if d, ok := tok.(json.Delim); ok && d == '[' {
// JSON array form: decode each element until the closing ']'.
for dec.More() {
c := &Call{}
if err := dec.Decode(c); err != nil {
return nil, err
}
if err := p.add(c); err != nil {
return nil, err
}
}
if err := p.add(c); err != nil {
return nil, err
} else {
// Object-stream form. The peeked token was the opening '{' of the
// first object, so re-decode from the start rather than mid-object.
dec = json.NewDecoder(bytes.NewReader(data))
for {
c := &Call{}
if err := dec.Decode(c); err == io.EOF {
break
} else if err != nil {
return nil, err
}
if err := p.add(c); err != nil {
return nil, err
}
}
}

if len(p.calls) == 0 {
return nil, fmt.Errorf("no calls found")
}
Expand Down
81 changes: 81 additions & 0 deletions profile/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,87 @@ func TestLoadFromReader_parsesStream(t *testing.T) {
}
}

func TestLoadFromReader_parsesArray(t *testing.T) {
src := strings.NewReader(`[
{"Weight": 1, "Method": "get", "URL": "http://example.com/a", "Body": ""},
{"Weight": 3, "Method": "post", "URL": "http://example.com/b", "Body": "x", "Type": "rest"}
]`)
prof, err := LoadFromReader(src)
if err != nil {
t.Fatalf("LoadFromReader(array): %v", err)
}
if got, want := len(prof.calls), 2; got != want {
t.Fatalf("calls=%d, want %d", got, want)
}
if prof.calls[0].Method != "GET" {
t.Errorf("Method not upper-cased: %q", prof.calls[0].Method)
}
if prof.calls[1].Type != "REST" {
t.Errorf("Type not upper-cased: %q", prof.calls[1].Type)
}
if prof.totalWeight != 4 {
t.Errorf("totalWeight=%v, want 4", prof.totalWeight)
}
if prof.calls[0].randomWeight != 1 || prof.calls[1].randomWeight != 4 {
t.Errorf("cumulative weights = %v, %v; want 1, 4",
prof.calls[0].randomWeight, prof.calls[1].randomWeight)
}
}

// A single-element array is a common shape too (e.g. a generated profile that
// happens to have one call); it must not be confused with the stream form.
func TestLoadFromReader_singleElementArray(t *testing.T) {
prof, err := LoadFromReader(strings.NewReader(`[{"Weight":2,"Method":"GET","URL":"http://x/"}]`))
if err != nil {
t.Fatalf("LoadFromReader: %v", err)
}
if len(prof.calls) != 1 || prof.calls[0].URL != "http://x/" {
t.Fatalf("unexpected calls: %+v", prof.calls)
}
}

// Go's encoding/json matches field names case-insensitively, so the lowercase
// keys an agent is likely to emit must load just like the documented form.
func TestLoadFromReader_lowercaseKeys(t *testing.T) {
prof, err := LoadFromReader(strings.NewReader(
`[{"weight":1,"method":"get","url":"http://x/","body":"b","type":"rest"}]`))
if err != nil {
t.Fatalf("LoadFromReader: %v", err)
}
c := prof.calls[0]
if c.Method != "GET" || c.URL != "http://x/" || c.Body != "b" || c.Type != "REST" {
t.Errorf("lowercase keys not parsed: %+v", c)
}
}

func TestLoadFromReader_emptyArray(t *testing.T) {
_, err := LoadFromReader(strings.NewReader(`[]`))
if err == nil || !strings.Contains(err.Error(), "no calls") {
t.Fatalf("expected 'no calls' error for empty array, got %v", err)
}
}

func TestLoadFromReader_malformedArray(t *testing.T) {
if _, err := LoadFromReader(strings.NewReader(`[{"Weight":1,"URL":"http://x/"`)); err == nil {
t.Fatal("expected error on truncated array")
}
}

// Array and stream forms must be interchangeable through the file loader too.
func TestLoadFromFile_parsesArray(t *testing.T) {
p := writeTempProfile(t, `[
{"Weight": 1, "Method": "GET", "URL": "http://example.com/a"},
{"Weight": 1, "Method": "GET", "URL": "http://example.com/b"}
]`)
prof, err := LoadFromFile(p)
if err != nil {
t.Fatalf("LoadFromFile(array): %v", err)
}
if len(prof.calls) != 2 {
t.Fatalf("calls=%d, want 2", len(prof.calls))
}
}

func TestLoadFromReader_empty(t *testing.T) {
_, err := LoadFromReader(strings.NewReader(""))
if err == nil || !strings.Contains(err.Error(), "no calls") {
Expand Down