diff --git a/cmd/config.go b/cmd/config.go index 4c194ae..70e785b 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "net/url" + "os" "sort" "strings" "time" @@ -30,6 +31,7 @@ type RunConfig struct { Duration time.Duration Method string Body string + BodyFile string Headers []string Verbose bool } @@ -58,6 +60,17 @@ func (c *RunConfig) Validate() error { } c.ParsedTarget = u + if c.BodyFile != "" { + if c.Body != "" { + return fmt.Errorf("cannot use both -b (body string) and -D (body file)") + } + data, err := os.ReadFile(c.BodyFile) + if err != nil { + return fmt.Errorf("failed to read body file %q: %w", c.BodyFile, err) + } + c.Body = string(data) + } + if c.Concurrency <= 0 { return fmt.Errorf("concurrency must be positive, got %d", c.Concurrency) } diff --git a/cmd/config_test.go b/cmd/config_test.go index a435321..aae9b59 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "strings" "testing" "time" @@ -8,6 +10,20 @@ import ( ) func TestRunConfig_Validate(t *testing.T) { + tmpFile, err := os.CreateTemp("", "body*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer func() { + _ = os.Remove(tmpFile.Name()) + }() + if _, err := tmpFile.Write([]byte("hello file body")); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("failed to close temp file: %v", err) + } + validConfig := func() RunConfig { return RunConfig{ Target: "https://example.com/api", @@ -25,6 +41,7 @@ func TestRunConfig_Validate(t *testing.T) { mutate func(*RunConfig) wantErr bool errMsg string + check func(*testing.T, *RunConfig) }{ { name: "Valid configuration", @@ -225,6 +242,35 @@ func TestRunConfig_Validate(t *testing.T) { wantErr: true, errMsg: `invalid header format ": some-value", expected 'Key: Value' without spaces in the key`, }, + { + name: "Both body and body file set", + mutate: func(c *RunConfig) { + c.Body = "some body text" + c.BodyFile = "some_file.txt" + }, + wantErr: true, + errMsg: "cannot use both -b (body string) and -D (body file)", + }, + { + name: "Nonexistent body file", + mutate: func(c *RunConfig) { + c.BodyFile = "does_not_exist_12345.txt" + }, + wantErr: true, + errMsg: "failed to read body file \"does_not_exist_12345.txt\"", + }, + { + name: "Valid body file", + mutate: func(c *RunConfig) { + c.BodyFile = tmpFile.Name() + }, + wantErr: false, + check: func(t *testing.T, c *RunConfig) { + if c.Body != "hello file body" { + t.Errorf("expected Body to be %q, got %q", "hello file body", c.Body) + } + }, + }, } for _, tt := range tests { @@ -236,7 +282,7 @@ func TestRunConfig_Validate(t *testing.T) { if tt.wantErr { if err == nil { t.Errorf("expected error but got nil") - } else if tt.errMsg != "" && err.Error() != tt.errMsg { + } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) } } else { @@ -244,6 +290,10 @@ func TestRunConfig_Validate(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + + if tt.check != nil { + tt.check(t, &cfg) + } }) } } diff --git a/cmd/configfile.go b/cmd/configfile.go index af90101..d7abe00 100644 --- a/cmd/configfile.go +++ b/cmd/configfile.go @@ -20,6 +20,7 @@ type fileConfig struct { Duration *string `json:"duration" yaml:"duration"` Method *string `json:"method" yaml:"method"` Body *string `json:"body" yaml:"body"` + BodyFile *string `json:"body_file" yaml:"body_file"` Headers []string `json:"headers" yaml:"headers"` Verbose *bool `json:"verbose" yaml:"verbose"` } @@ -116,6 +117,10 @@ func mergeConfig(file *fileConfig, cli RunConfig, changed map[string]bool) (RunC merged.Body = *file.Body } + if file.BodyFile != nil && !changed["body-file"] { + merged.BodyFile = *file.BodyFile + } + if len(file.Headers) > 0 && !changed["header"] { merged.Headers = make([]string, len(file.Headers)) copy(merged.Headers, file.Headers) diff --git a/cmd/run.go b/cmd/run.go index 1447376..4621ced 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -54,6 +54,7 @@ Latency Percentiles: duration, _ := f.GetDuration("duration") method, _ := f.GetString("method") body, _ := f.GetString("body") + bodyFile, _ := f.GetString("body-file") headers, _ := f.GetStringArray("header") verbose, _ := f.GetBool("verbose") outputFormat, _ := f.GetString("output") @@ -75,6 +76,7 @@ Latency Percentiles: Duration: duration, Method: strings.ToUpper(method), Body: body, + BodyFile: bodyFile, Headers: headers, Verbose: verbose, } @@ -135,6 +137,7 @@ Latency Percentiles: cmd.Flags().DurationP("duration", "d", 0, "Duration to run the test. Overrides -n when set (e.g., 10s, 1m)") cmd.Flags().StringP("method", "m", "GET", "HTTP method to use") cmd.Flags().StringP("body", "b", "", "Request body content") + cmd.Flags().StringP("body-file", "D", "", "Path to file containing the request body") cmd.Flags().StringArrayP("header", "H", []string{}, "HTTP header in 'Key: Value' format (can be repeated)") cmd.Flags().StringP("config", "f", "", "Path to configuration file (JSON/YAML)") cmd.Flags().BoolP("verbose", "v", false, "Enable verbose output")