diff --git a/.env.development b/.env.development
deleted file mode 100644
index 98da8de..0000000
--- a/.env.development
+++ /dev/null
@@ -1 +0,0 @@
-SERVER_PORT=8080
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3b682a6..f41d910 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,7 +18,7 @@ jobs:
go-version: 1.17
- name: Install coverage tool
- run: go get github.com/ory/go-acc
+ run: go install github.com/ory/go-acc@v0.2.8
# Check #1: Lint
- name: Lint
@@ -28,7 +28,7 @@ jobs:
# Check #2: Test & generate coverage report
- name: Test & coverage
- run: make test-cov
+ run: ./script/coverage
- name: Upload coverage report
uses: codecov/codecov-action@v1.0.2
diff --git a/.gitignore b/.gitignore
index 575893a..a960199 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,3 @@
-# Env files
-.env
-
-# Binary files
-/bin
-
# IDE files
/.vscode
/.idea
@@ -12,6 +6,3 @@
/.benchttp.yml
/.benchttp.yaml
/.benchttp.json
-
-# Benchttp reports
-/benchttp.report.*.json
diff --git a/.golangci.yml b/.golangci.yml
index 786a2a0..d9e410a 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -111,7 +111,7 @@ linters-settings:
extra-rules: true
goimports:
- local-prefixes: github.com/benchttp/engine
+ local-prefixes: github.com/benchttp/sdk
misspell:
locale: US
@@ -131,7 +131,6 @@ linters:
disable-all: true
enable:
- bodyclose # enforce resp.Body.Close()
- - deadcode
- dupl # duplicate code
- errcheck
- exportloopref
@@ -145,10 +144,8 @@ linters:
- prealloc # enforce capacity allocation when possible
- revive # golint enhancement
- staticcheck # go vet enhancement
- - structcheck # unused struct fields
- testpackage # checks on tests (*_test)
- thelper # enforce t.Helper()
- - varcheck # unused global var and const
- wastedassign
fast: false
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 5f4e1e0..0000000
--- a/Makefile
+++ /dev/null
@@ -1,45 +0,0 @@
-# Default command
-
-.PHONY: default
-default:
- @make check
-
-# Check code
-
-.PHONY: check
-check:
- @make lint
- @make tests
-
-.PHONY: lint
-lint:
- @golangci-lint run
-
-.PHONY: tests
-tests:
- @go test -race -timeout 10s ./...
-
-TEST_FUNC=^.*$$
-ifdef t
-TEST_FUNC=$(t)
-endif
-TEST_PKG=./...
-ifdef p
-TEST_PKG=./$(p)
-endif
-
-.PHONY: test
-test:
- @go test -race -timeout 10s -run $(TEST_FUNC) $(TEST_PKG)
-
-
-.PHONY: test-cov
-test-cov:
- @go-acc ./...
-
-# Docs
-
-.PHONY: docs
-docs:
- @echo "\033[4mhttp://localhost:9995/pkg/github.com/benchttp/engine/\033[0m"
- @godoc -http=localhost:9995
diff --git a/README.md b/README.md
index 07a2a00..00fc294 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,16 @@
benchttp/engine
-
+
-
-
+
+
-
+
-
+
@@ -28,7 +28,7 @@ Go1.17 environment or higher is required.
Install.
```txt
-go get github.com/benchttp/engine
+go get github.com/benchttp/sdk
```
## Usage
@@ -42,22 +42,20 @@ import (
"context"
"fmt"
- "github.com/benchttp/engine/runner"
+ "github.com/benchttp/sdk/benchttp"
)
-func main(t *testing.T) {
- // Set runner configuration
- config := runner.DefaultConfig()
- config.Request = config.Request.WithURL("https://example.com")
-
- // Instantiate runner and run benchmark
- report, _ := runner.New(nil).Run(context.Background(), config)
+func main() {
+ report, _ := benchttp.
+ DefaultRunner(). // Default runner with safe configuration
+ WithNewRequest("GET", "http://localhost:3000", nil). // Attach request
+ Run(context.Background()) // Run benchmark, retrieve report
fmt.Println(report.Metrics.ResponseTimes.Mean)
}
```
-### Usage with JSON config via `configparse`
+### Usage with JSON config via `configio`
```go
package main
@@ -66,8 +64,8 @@ import (
"context"
"fmt"
- "github.com/benchttp/engine/configparse"
- "github.com/benchttp/engine/runner"
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/configio"
)
func main() {
@@ -75,18 +73,24 @@ func main() {
jsonConfig := []byte(`
{
"request": {
- "url": "https://example.com"
+ "url": "http://localhost:3000"
}
}`)
- config, _ := configparse.JSON(jsonConfig)
- report, _ := runner.New(nil).Run(context.Background(), config)
+ // Instantiate a base Runner (here the default with a safe configuration)
+ runner := benchttp.DefaultRunner()
+
+ // Parse the json configuration into the Runner
+ _ = configio.UnmarshalJSONRunner(jsonConfig, &runner)
+
+ // Run benchmark, retrieve report
+ report, _ := runner.Run(context.Background())
fmt.Println(report.Metrics.ResponseTimes.Mean)
}
```
-📄 Please refer to [our Wiki](https://github.com/benchttp/engine/wiki/IO-Structures) for exhaustive `Config` and `Report` structures (and more!)
+📄 Please refer to [our Wiki](https://github.com/benchttp/sdk/wiki/IO-Structures) for exhaustive `Runner` and `Report` structures (and more!)
## Development
@@ -97,8 +101,8 @@ func main() {
### Main commands
-| Command | Description |
-| ------------ | ----------------------------------- |
-| `make lint` | Runs lint on the codebase |
-| `make tests` | Runs tests suites from all packages |
-| `make check` | Runs both lint and tests |
+| Command | Description |
+| --------------- | ------------------------------------------------- |
+| `./script/lint` | Runs lint on the codebase |
+| `./script/test` | Runs tests suites from all packages |
+| `./script/doc` | Serves Go doc for this module at `localhost:9995` |
diff --git a/runner/internal/config/error.go b/benchttp/error.go
similarity index 69%
rename from runner/internal/config/error.go
rename to benchttp/error.go
index a69cf82..19ceeac 100644
--- a/runner/internal/config/error.go
+++ b/benchttp/error.go
@@ -1,15 +1,15 @@
-package config
+package benchttp
import "strings"
-// InvalidConfigError is the errors returned by Global.Validate
+// InvalidRunnerError is the errors returned by Config.Validate
// when values are missing or invalid.
-type InvalidConfigError struct {
+type InvalidRunnerError struct {
Errors []error
}
// Error returns the joined errors of InvalidConfigError as a string.
-func (e *InvalidConfigError) Error() string {
+func (e *InvalidRunnerError) Error() string {
const sep = "\n - "
var b strings.Builder
diff --git a/runner/internal/config/error_test.go b/benchttp/error_test.go
similarity index 69%
rename from runner/internal/config/error_test.go
rename to benchttp/error_test.go
index 8ff4421..cdbb1c9 100644
--- a/runner/internal/config/error_test.go
+++ b/benchttp/error_test.go
@@ -1,14 +1,14 @@
-package config_test
+package benchttp_test
import (
"errors"
"testing"
- "github.com/benchttp/engine/runner/internal/config"
+ "github.com/benchttp/sdk/benchttp"
)
-func TestInvalidConfigError_Error(t *testing.T) {
- e := config.InvalidConfigError{
+func TestInvalidRunnerError(t *testing.T) {
+ e := benchttp.InvalidRunnerError{
Errors: []error{
errors.New("error 0"),
errors.New("error 1\nwith new line"),
diff --git a/runner/internal/metrics/aggregate.go b/benchttp/internal/metrics/aggregate.go
similarity index 96%
rename from runner/internal/metrics/aggregate.go
rename to benchttp/internal/metrics/aggregate.go
index 7a40929..b44723e 100644
--- a/runner/internal/metrics/aggregate.go
+++ b/benchttp/internal/metrics/aggregate.go
@@ -3,8 +3,8 @@ package metrics
import (
"time"
- "github.com/benchttp/engine/runner/internal/metrics/timestats"
- "github.com/benchttp/engine/runner/internal/recorder"
+ "github.com/benchttp/sdk/benchttp/internal/metrics/timestats"
+ "github.com/benchttp/sdk/benchttp/internal/recorder"
)
type TimeStats = timestats.TimeStats
diff --git a/runner/internal/metrics/aggregate_test.go b/benchttp/internal/metrics/aggregate_test.go
similarity index 94%
rename from runner/internal/metrics/aggregate_test.go
rename to benchttp/internal/metrics/aggregate_test.go
index 5883a20..a2af956 100644
--- a/runner/internal/metrics/aggregate_test.go
+++ b/benchttp/internal/metrics/aggregate_test.go
@@ -5,9 +5,9 @@ import (
"testing"
"time"
- "github.com/benchttp/engine/runner/internal/metrics"
- "github.com/benchttp/engine/runner/internal/metrics/timestats"
- "github.com/benchttp/engine/runner/internal/recorder"
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/metrics/timestats"
+ "github.com/benchttp/sdk/benchttp/internal/recorder"
)
func TestNewAggregate(t *testing.T) {
diff --git a/runner/internal/metrics/compare.go b/benchttp/internal/metrics/compare.go
similarity index 100%
rename from runner/internal/metrics/compare.go
rename to benchttp/internal/metrics/compare.go
diff --git a/runner/internal/metrics/field.go b/benchttp/internal/metrics/field.go
similarity index 94%
rename from runner/internal/metrics/field.go
rename to benchttp/internal/metrics/field.go
index e1a8d73..2228ab2 100644
--- a/runner/internal/metrics/field.go
+++ b/benchttp/internal/metrics/field.go
@@ -3,7 +3,7 @@ package metrics
import (
"errors"
- "github.com/benchttp/engine/internal/errorutil"
+ "github.com/benchttp/sdk/internal/errorutil"
)
// ErrUnknownField occurs when a Field is used with an invalid path.
diff --git a/runner/internal/metrics/field_test.go b/benchttp/internal/metrics/field_test.go
similarity index 96%
rename from runner/internal/metrics/field_test.go
rename to benchttp/internal/metrics/field_test.go
index f3a32c0..41cea87 100644
--- a/runner/internal/metrics/field_test.go
+++ b/benchttp/internal/metrics/field_test.go
@@ -3,7 +3,7 @@ package metrics_test
import (
"testing"
- "github.com/benchttp/engine/runner/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
)
func TestField_Type(t *testing.T) {
diff --git a/runner/internal/metrics/metrics.go b/benchttp/internal/metrics/metrics.go
similarity index 97%
rename from runner/internal/metrics/metrics.go
rename to benchttp/internal/metrics/metrics.go
index 61e946d..5eb51ea 100644
--- a/runner/internal/metrics/metrics.go
+++ b/benchttp/internal/metrics/metrics.go
@@ -3,7 +3,7 @@ package metrics
import (
"strings"
- "github.com/benchttp/engine/runner/internal/reflectpath"
+ "github.com/benchttp/sdk/benchttp/internal/reflectpath"
)
// Value is a concrete metric value, e.g. 120 or 3 * time.Second.
diff --git a/runner/internal/metrics/metrics_test.go b/benchttp/internal/metrics/metrics_test.go
similarity index 96%
rename from runner/internal/metrics/metrics_test.go
rename to benchttp/internal/metrics/metrics_test.go
index b9ff933..b1cab65 100644
--- a/runner/internal/metrics/metrics_test.go
+++ b/benchttp/internal/metrics/metrics_test.go
@@ -4,8 +4,8 @@ import (
"testing"
"time"
- "github.com/benchttp/engine/runner/internal/metrics"
- "github.com/benchttp/engine/runner/internal/metrics/timestats"
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/metrics/timestats"
)
func TestMetric_Compare(t *testing.T) {
diff --git a/runner/internal/metrics/timestats/sort.go b/benchttp/internal/metrics/timestats/sort.go
similarity index 100%
rename from runner/internal/metrics/timestats/sort.go
rename to benchttp/internal/metrics/timestats/sort.go
diff --git a/runner/internal/metrics/timestats/timestats.go b/benchttp/internal/metrics/timestats/timestats.go
similarity index 100%
rename from runner/internal/metrics/timestats/timestats.go
rename to benchttp/internal/metrics/timestats/timestats.go
diff --git a/runner/internal/metrics/timestats/timestats_test.go b/benchttp/internal/metrics/timestats/timestats_test.go
similarity index 97%
rename from runner/internal/metrics/timestats/timestats_test.go
rename to benchttp/internal/metrics/timestats/timestats_test.go
index e3854f4..65c0537 100644
--- a/runner/internal/metrics/timestats/timestats_test.go
+++ b/benchttp/internal/metrics/timestats/timestats_test.go
@@ -4,7 +4,7 @@ import (
"testing"
"time"
- "github.com/benchttp/engine/runner/internal/metrics/timestats"
+ "github.com/benchttp/sdk/benchttp/internal/metrics/timestats"
)
func TestCompute(t *testing.T) {
diff --git a/runner/internal/recorder/error.go b/benchttp/internal/recorder/error.go
similarity index 100%
rename from runner/internal/recorder/error.go
rename to benchttp/internal/recorder/error.go
diff --git a/runner/internal/recorder/httputil.go b/benchttp/internal/recorder/httputil.go
similarity index 100%
rename from runner/internal/recorder/httputil.go
rename to benchttp/internal/recorder/httputil.go
diff --git a/runner/internal/recorder/progress.go b/benchttp/internal/recorder/progress.go
similarity index 100%
rename from runner/internal/recorder/progress.go
rename to benchttp/internal/recorder/progress.go
diff --git a/runner/internal/recorder/recorder.go b/benchttp/internal/recorder/recorder.go
similarity index 98%
rename from runner/internal/recorder/recorder.go
rename to benchttp/internal/recorder/recorder.go
index 1e9be56..54fc0ac 100644
--- a/runner/internal/recorder/recorder.go
+++ b/benchttp/internal/recorder/recorder.go
@@ -7,7 +7,7 @@ import (
"sync"
"time"
- "github.com/benchttp/engine/internal/dispatcher"
+ "github.com/benchttp/sdk/internal/dispatcher"
)
const (
diff --git a/runner/internal/recorder/recorder_internal_test.go b/benchttp/internal/recorder/recorder_internal_test.go
similarity index 99%
rename from runner/internal/recorder/recorder_internal_test.go
rename to benchttp/internal/recorder/recorder_internal_test.go
index bc79fe8..9ef9bf8 100644
--- a/runner/internal/recorder/recorder_internal_test.go
+++ b/benchttp/internal/recorder/recorder_internal_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"time"
- "github.com/benchttp/engine/internal/dispatcher"
+ "github.com/benchttp/sdk/internal/dispatcher"
)
var errTest = errors.New("test-generated error")
diff --git a/runner/internal/recorder/tracer.go b/benchttp/internal/recorder/tracer.go
similarity index 100%
rename from runner/internal/recorder/tracer.go
rename to benchttp/internal/recorder/tracer.go
diff --git a/runner/internal/recorder/tracer_internal_test.go b/benchttp/internal/recorder/tracer_internal_test.go
similarity index 100%
rename from runner/internal/recorder/tracer_internal_test.go
rename to benchttp/internal/recorder/tracer_internal_test.go
diff --git a/runner/internal/reflectpath/resolver.go b/benchttp/internal/reflectpath/resolver.go
similarity index 100%
rename from runner/internal/reflectpath/resolver.go
rename to benchttp/internal/reflectpath/resolver.go
diff --git a/runner/internal/reflectpath/type.go b/benchttp/internal/reflectpath/type.go
similarity index 100%
rename from runner/internal/reflectpath/type.go
rename to benchttp/internal/reflectpath/type.go
diff --git a/runner/internal/reflectpath/value.go b/benchttp/internal/reflectpath/value.go
similarity index 100%
rename from runner/internal/reflectpath/value.go
rename to benchttp/internal/reflectpath/value.go
diff --git a/runner/internal/tests/predicate.go b/benchttp/internal/tests/predicate.go
similarity index 91%
rename from runner/internal/tests/predicate.go
rename to benchttp/internal/tests/predicate.go
index d8146d0..17e508f 100644
--- a/runner/internal/tests/predicate.go
+++ b/benchttp/internal/tests/predicate.go
@@ -3,8 +3,8 @@ package tests
import (
"errors"
- "github.com/benchttp/engine/internal/errorutil"
- "github.com/benchttp/engine/runner/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
+ "github.com/benchttp/sdk/internal/errorutil"
)
var ErrUnknownPredicate = errors.New("tests: unknown predicate")
diff --git a/runner/internal/tests/predicate_test.go b/benchttp/internal/tests/predicate_test.go
similarity index 94%
rename from runner/internal/tests/predicate_test.go
rename to benchttp/internal/tests/predicate_test.go
index f9aee16..b70ce87 100644
--- a/runner/internal/tests/predicate_test.go
+++ b/benchttp/internal/tests/predicate_test.go
@@ -4,8 +4,8 @@ import (
"testing"
"time"
- "github.com/benchttp/engine/runner/internal/metrics"
- "github.com/benchttp/engine/runner/internal/tests"
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/tests"
)
func TestPredicate(t *testing.T) {
diff --git a/runner/internal/tests/tests.go b/benchttp/internal/tests/tests.go
similarity index 95%
rename from runner/internal/tests/tests.go
rename to benchttp/internal/tests/tests.go
index a3e3f36..3fb03cf 100644
--- a/runner/internal/tests/tests.go
+++ b/benchttp/internal/tests/tests.go
@@ -3,7 +3,7 @@ package tests
import (
"fmt"
- "github.com/benchttp/engine/runner/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
)
type Case struct {
diff --git a/runner/internal/tests/tests_test.go b/benchttp/internal/tests/tests_test.go
similarity index 95%
rename from runner/internal/tests/tests_test.go
rename to benchttp/internal/tests/tests_test.go
index a358601..6c58019 100644
--- a/runner/internal/tests/tests_test.go
+++ b/benchttp/internal/tests/tests_test.go
@@ -5,9 +5,9 @@ import (
"testing"
"time"
- "github.com/benchttp/engine/runner/internal/metrics"
- "github.com/benchttp/engine/runner/internal/metrics/timestats"
- "github.com/benchttp/engine/runner/internal/tests"
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/metrics/timestats"
+ "github.com/benchttp/sdk/benchttp/internal/tests"
)
func TestRun(t *testing.T) {
diff --git a/runner/internal/report/report.go b/benchttp/report.go
similarity index 65%
rename from runner/internal/report/report.go
rename to benchttp/report.go
index 8f972c9..da11c31 100644
--- a/runner/internal/report/report.go
+++ b/benchttp/report.go
@@ -1,11 +1,10 @@
-package report
+package benchttp
import (
"time"
- "github.com/benchttp/engine/runner/internal/config"
- "github.com/benchttp/engine/runner/internal/metrics"
- "github.com/benchttp/engine/runner/internal/tests"
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/tests"
)
// Report represents a run result as exported by the runner.
@@ -17,14 +16,14 @@ type Report struct {
// Metadata contains contextual information about a run.
type Metadata struct {
- Config config.Global
+ Config Runner
FinishedAt time.Time
TotalDuration time.Duration
}
-// New returns an initialized *Report.
-func New(
- cfg config.Global,
+// newReport returns an initialized *Report.
+func newReport(
+ r Runner,
d time.Duration,
m metrics.Aggregate,
t tests.SuiteResult,
@@ -33,7 +32,7 @@ func New(
Metrics: m,
Tests: t,
Metadata: Metadata{
- Config: cfg,
+ Config: r,
FinishedAt: time.Now(), // TODO: change, unreliable
TotalDuration: d,
},
diff --git a/benchttp/runner.go b/benchttp/runner.go
new file mode 100644
index 0000000..3141b6f
--- /dev/null
+++ b/benchttp/runner.go
@@ -0,0 +1,163 @@
+package benchttp
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/benchttp/sdk/benchttp/internal/metrics"
+ "github.com/benchttp/sdk/benchttp/internal/recorder"
+ "github.com/benchttp/sdk/benchttp/internal/tests"
+)
+
+type (
+ RecordingProgress = recorder.Progress
+ RecordingStatus = recorder.Status
+
+ MetricsAggregate = metrics.Aggregate
+ MetricsField = metrics.Field
+ MetricsValue = metrics.Value
+ MetricsTimeStats = metrics.TimeStats
+
+ TestCase = tests.Case
+ TestPredicate = tests.Predicate
+ TestSuiteResults = tests.SuiteResult
+ TestCaseResult = tests.CaseResult
+)
+
+const (
+ StatusRunning = recorder.StatusRunning
+ StatusCanceled = recorder.StatusCanceled
+ StatusTimeout = recorder.StatusTimeout
+ StatusDone = recorder.StatusDone
+)
+
+var ErrCanceled = recorder.ErrCanceled
+
+type Runner struct {
+ Request *http.Request
+
+ Requests int
+ Concurrency int
+ Interval time.Duration
+ RequestTimeout time.Duration
+ GlobalTimeout time.Duration
+
+ Tests []tests.Case
+
+ OnProgress func(RecordingProgress)
+
+ recorder *recorder.Recorder
+}
+
+// DefaultRunner returns a default Runner that is ready to use,
+// except for Runner.Request that still needs to be set.
+func DefaultRunner() Runner {
+ return Runner{
+ Concurrency: 10,
+ Requests: 100,
+ Interval: 0 * time.Second,
+ RequestTimeout: 5 * time.Second,
+ GlobalTimeout: 30 * time.Second,
+ }
+}
+
+// WithRequest attaches the given HTTP request to the Runner.
+func (r Runner) WithRequest(req *http.Request) Runner {
+ r.Request = req
+ return r
+}
+
+// WithNewRequest calls http.NewRequest with the given parameters
+// and attaches the result to the Runner. If the call to http.NewRequest
+// returns a non-nil error, it panics with the content of that error.
+func (r Runner) WithNewRequest(method, uri string, body io.Reader) Runner {
+ req, err := http.NewRequest(method, uri, body)
+ if err != nil {
+ panic(err)
+ }
+ return r.WithRequest(req)
+}
+
+func (r Runner) Run(ctx context.Context) (*Report, error) {
+ // Validate input config
+ if err := r.Validate(); err != nil {
+ return nil, err
+ }
+
+ // Create and attach request recorder
+ r.recorder = recorder.New(r.recorderConfig())
+
+ startTime := time.Now()
+
+ // Run request recorder
+ records, err := r.recorder.Record(ctx, r.Request)
+ if err != nil {
+ return nil, err
+ }
+
+ duration := time.Since(startTime)
+
+ agg := metrics.NewAggregate(records)
+
+ testResults := tests.Run(agg, r.Tests)
+
+ return newReport(r, duration, agg, testResults), nil
+}
+
+// recorderConfig returns a runner.RequesterConfig generated from cfg.
+func (r Runner) recorderConfig() recorder.Config {
+ return recorder.Config{
+ Requests: r.Requests,
+ Concurrency: r.Concurrency,
+ Interval: r.Interval,
+ RequestTimeout: r.RequestTimeout,
+ GlobalTimeout: r.GlobalTimeout,
+ OnProgress: r.OnProgress,
+ }
+}
+
+// Validate returns a non-nil InvalidConfigError if any of its fields
+// does not meet the requirements.
+func (r Runner) Validate() error { //nolint:gocognit
+ errs := []error{}
+ appendError := func(err error) {
+ errs = append(errs, err)
+ }
+
+ if r.Request == nil {
+ appendError(errors.New("Runner.Request must not be nil"))
+ }
+
+ if r.Requests < 1 && r.Requests != -1 {
+ appendError(fmt.Errorf("requests (%d): want >= 0", r.Requests))
+ }
+
+ if r.Concurrency < 1 || r.Concurrency > r.Requests {
+ appendError(fmt.Errorf(
+ "concurrency (%d): want > 0 and <= requests (%d)",
+ r.Concurrency, r.Requests,
+ ))
+ }
+
+ if r.Interval < 0 {
+ appendError(fmt.Errorf("interval (%d): want >= 0", r.Interval))
+ }
+
+ if r.RequestTimeout < 1 {
+ appendError(fmt.Errorf("requestTimeout (%d): want > 0", r.RequestTimeout))
+ }
+
+ if r.GlobalTimeout < 1 {
+ appendError(fmt.Errorf("globalTimeout (%d): want > 0", r.GlobalTimeout))
+ }
+
+ if len(errs) > 0 {
+ return &InvalidRunnerError{errs}
+ }
+
+ return nil
+}
diff --git a/benchttp/runner_test.go b/benchttp/runner_test.go
new file mode 100644
index 0000000..db60da1
--- /dev/null
+++ b/benchttp/runner_test.go
@@ -0,0 +1,101 @@
+package benchttp_test
+
+import (
+ "errors"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/benchttp/sdk/benchttp"
+)
+
+func TestRunner_WithRequest(t *testing.T) {
+ request := httptest.NewRequest("POST", "https://example.com", nil)
+ runner := benchttp.DefaultRunner().WithRequest(request)
+
+ if runner.Request != request {
+ t.Error("request not set")
+ }
+}
+
+func TestRunner_WithNewRequest(t *testing.T) {
+ t.Run("attach valid request", func(t *testing.T) {
+ const method = "POST"
+ const urlString = "https://example.com"
+
+ runner := benchttp.DefaultRunner().WithNewRequest(method, urlString, nil)
+
+ if runner.Request.Method != method || runner.Request.URL.String() != urlString {
+ t.Error("request incorrectly seet")
+ }
+ })
+
+ t.Run("panic for bad request params", func(t *testing.T) {
+ defer func() {
+ if r := recover(); r == nil {
+ t.Error("did not panic")
+ }
+ }()
+ _ = benchttp.DefaultRunner().WithNewRequest("ù", "", nil)
+ })
+}
+
+func TestRunner_Validate(t *testing.T) {
+ t.Run("return nil if config is valid", func(t *testing.T) {
+ runner := benchttp.Runner{
+ Request: httptest.NewRequest("GET", "https://a.b/#c?d=e&f=g", nil),
+ Requests: 5,
+ Concurrency: 5,
+ Interval: 5,
+ RequestTimeout: 5,
+ GlobalTimeout: 5,
+ }
+
+ if err := runner.Validate(); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("return cumulated errors if config is invalid", func(t *testing.T) {
+ runner := benchttp.Runner{
+ Request: nil,
+ Requests: -5,
+ Concurrency: -5,
+ Interval: -5,
+ RequestTimeout: -5,
+ GlobalTimeout: -5,
+ }
+
+ err := runner.Validate()
+ if err == nil {
+ t.Fatal("invalid configuration considered valid")
+ }
+
+ var errInvalid *benchttp.InvalidRunnerError
+ if !errors.As(err, &errInvalid) {
+ t.Fatalf("unexpected error type: %T", err)
+ }
+
+ errs := errInvalid.Errors
+ assertError(t, errs, "Runner.Request must not be nil")
+ assertError(t, errs, "requests (-5): want >= 0")
+ assertError(t, errs, "concurrency (-5): want > 0 and <= requests (-5)")
+ assertError(t, errs, "interval (-5): want >= 0")
+ assertError(t, errs, "requestTimeout (-5): want > 0")
+ assertError(t, errs, "globalTimeout (-5): want > 0")
+
+ t.Logf("got error:\n%v", errInvalid)
+ })
+}
+
+// helpers
+
+// assertError fails t if no error in src matches msg.
+func assertError(t *testing.T, src []error, msg string) {
+ t.Helper()
+ for _, err := range src {
+ if err.Error() == msg {
+ return
+ }
+ }
+ t.Errorf("missing error: %v", msg)
+}
diff --git a/benchttptest/benchttptest.go b/benchttptest/benchttptest.go
new file mode 100644
index 0000000..190a73b
--- /dev/null
+++ b/benchttptest/benchttptest.go
@@ -0,0 +1,2 @@
+// Package benchttptest proovides utilities for benchttp testing.
+package benchttptest
diff --git a/benchttptest/compare.go b/benchttptest/compare.go
new file mode 100644
index 0000000..70708d6
--- /dev/null
+++ b/benchttptest/compare.go
@@ -0,0 +1,143 @@
+package benchttptest
+
+import (
+ "bytes"
+ "crypto/tls"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+
+ "github.com/benchttp/sdk/benchttp"
+)
+
+// RunnerCmpOptions is the cmp.Options used to compare benchttp.Runner.
+// By default, it ignores unexported fields and includes RequestCmpOptions.
+var RunnerCmpOptions = cmp.Options{
+ cmpopts.IgnoreUnexported(benchttp.Runner{}),
+ RequestCmpOptions,
+}
+
+// RequestCmpOptions is the cmp.Options used to compare *http.Request.
+// It behaves as follows:
+//
+// - Nil and empty values are considered equal
+//
+// - Fields that depend on how the request was created are ignored
+// to avoid false negatives when comparing requests created in different
+// ways (http.NewRequest vs httptest.NewRequest vs &http.Request{})
+//
+// - Function fields are ignored
+//
+// - Body is ignored: it is compared separately
+var RequestCmpOptions = cmp.Options{
+ cmp.Transformer("Request", instantiateNilRequest),
+ cmp.Transformer("Request.Header", instantiateNilHeader),
+ cmp.Transformer("Request.URL", stringifyURL),
+ cmpopts.IgnoreUnexported(http.Request{}, tls.ConnectionState{}),
+ cmpopts.IgnoreFields(http.Request{}, unreliableRequestFields...),
+}
+
+var unreliableRequestFields = []string{
+ // These fields are automatically set by NewRequest constructor
+ // from packages http and httptest, as a consequence they can
+ // trigger false positives when comparing requests that were
+ // created differently.
+ "Proto", "ProtoMajor", "ProtoMinor", "ContentLength",
+ "Host", "RemoteAddr", "RequestURI", "TLS", "Cancel",
+
+ // Function fields cannot be reliably compared
+ "GetBody",
+
+ // Body field can't be read without altering the Request, causing
+ // cmp-go to panic. We perform a custom comparison instead.
+ "Body",
+}
+
+// AssertEqualRunners fails t and shows a diff if a and b are not equal,
+// as determined by RunnerCmpOptions.
+func AssertEqualRunners(t *testing.T, x, y benchttp.Runner) {
+ t.Helper()
+ if !EqualRunners(x, y) {
+ t.Error(DiffRunner(x, y))
+ }
+}
+
+// EqualRunners returns true if x and y are equal, as determined by
+// RunnerCmpOptions.
+func EqualRunners(x, y benchttp.Runner) bool {
+ return cmp.Equal(x, y, RunnerCmpOptions) &&
+ compareRequestBody(x.Request, y.Request)
+}
+
+// DiffRunner returns a string showing the diff between x and y,
+// as determined by RunnerCmpOptions.
+func DiffRunner(x, y benchttp.Runner) string {
+ b := strings.Builder{}
+ b.WriteString(cmp.Diff(x, y, RunnerCmpOptions))
+ if x.Request != nil && y.Request != nil {
+ xbody := nopreadBody(x.Request)
+ ybody := nopreadBody(y.Request)
+ if !bytes.Equal(xbody, ybody) {
+ b.WriteString("Request.Body: ")
+ b.WriteString(cmp.Diff(string(xbody), string(ybody)))
+ }
+ }
+ return b.String()
+}
+
+// helpers
+
+func instantiateNilRequest(r *http.Request) *http.Request {
+ if r == nil {
+ return &http.Request{}
+ }
+ return r
+}
+
+func instantiateNilHeader(h http.Header) http.Header {
+ if h == nil {
+ return http.Header{}
+ }
+ return h
+}
+
+func stringifyURL(u *url.URL) string {
+ if u == nil {
+ return ""
+ }
+ return u.String()
+}
+
+func compareRequestBody(a, b *http.Request) bool {
+ ba, bb := nopreadBody(a), nopreadBody(b)
+ return bytes.Equal(ba, bb)
+}
+
+func nopreadBody(r *http.Request) []byte {
+ if r == nil || r.Body == nil {
+ return []byte{}
+ }
+
+ bbuf := bytes.Buffer{}
+
+ if _, err := io.Copy(&bbuf, r.Body); err != nil {
+ panic("benchttptest: error reading Request.Body: " + err.Error())
+ }
+
+ if r.GetBody != nil {
+ newbody, err := r.GetBody()
+ if err != nil {
+ panic("benchttptest: Request.GetBody error: " + err.Error())
+ }
+ r.Body = newbody
+ } else {
+ r.Body = io.NopCloser(&bbuf)
+ }
+
+ return bbuf.Bytes()
+}
diff --git a/benchttptest/compare_test.go b/benchttptest/compare_test.go
new file mode 100644
index 0000000..7df633e
--- /dev/null
+++ b/benchttptest/compare_test.go
@@ -0,0 +1,189 @@
+package benchttptest_test
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/benchttptest"
+)
+
+func TestAssertEqualRunners(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ pass bool
+ a, b benchttp.Runner
+ }{
+ {
+ name: "pass if runners are equal",
+ pass: true,
+ a: benchttp.Runner{Requests: 1},
+ b: benchttp.Runner{Requests: 1},
+ },
+ {
+ name: "fail if runners are not equal",
+ pass: false,
+ a: benchttp.Runner{Requests: 1},
+ b: benchttp.Runner{Requests: 2},
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ tt := &testing.T{}
+
+ benchttptest.AssertEqualRunners(tt, tc.a, tc.b)
+ if tt.Failed() == tc.pass {
+ t.Fail()
+ }
+ })
+ }
+}
+
+func TestEqualRunners(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ want bool
+ a, b benchttp.Runner
+ }{
+ {
+ name: "equal runners",
+ want: true,
+ a: benchttp.Runner{Requests: 1},
+ b: benchttp.Runner{Requests: 1},
+ },
+ {
+ name: "different runners",
+ want: false,
+ a: benchttp.Runner{Requests: 1},
+ b: benchttp.Runner{Requests: 2},
+ },
+ {
+ name: "consider zero requests equal",
+ want: true,
+ a: benchttp.Runner{Request: nil},
+ b: benchttp.Runner{Request: &http.Request{}},
+ },
+ {
+ name: "consider zero request headers equal",
+ want: true,
+ a: benchttp.Runner{Request: &http.Request{Header: nil}},
+ b: benchttp.Runner{Request: &http.Request{Header: http.Header{}}},
+ },
+ {
+ name: "consider zero request bodies equal",
+ want: true,
+ a: benchttp.Runner{Request: &http.Request{Body: nil}},
+ b: benchttp.Runner{Request: &http.Request{Body: http.NoBody}},
+ },
+ {
+ name: "zero request vs non zero request",
+ want: false,
+ a: benchttp.Runner{Request: &http.Request{Method: "GET"}},
+ b: benchttp.Runner{Request: nil},
+ },
+ {
+ name: "different request field values",
+ want: false,
+ a: benchttp.Runner{Request: &http.Request{Method: "GET"}},
+ b: benchttp.Runner{Request: &http.Request{Method: "POST"}},
+ },
+ {
+ name: "ignore unreliable request fields",
+ want: true,
+ a: benchttp.Runner{
+ Request: httptest.NewRequest( // sets Proto, ContentLength, ...
+ "POST",
+ "https://example.com",
+ nil,
+ ),
+ },
+ b: benchttp.Runner{
+ Request: &http.Request{
+ Method: "POST",
+ URL: mustParseRequestURI("https://example.com"),
+ },
+ },
+ },
+ {
+ name: "equal request bodies",
+ want: true,
+ a: benchttp.Runner{
+ Request: &http.Request{
+ Body: io.NopCloser(strings.NewReader("hello")),
+ },
+ },
+ b: benchttp.Runner{
+ Request: &http.Request{
+ Body: io.NopCloser(strings.NewReader("hello")),
+ },
+ },
+ },
+ {
+ name: "different request bodies",
+ want: false,
+ a: benchttp.Runner{
+ Request: &http.Request{
+ Body: io.NopCloser(strings.NewReader("hello")),
+ },
+ },
+ b: benchttp.Runner{
+ Request: &http.Request{
+ Body: io.NopCloser(strings.NewReader("world")),
+ },
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if benchttptest.EqualRunners(tc.a, tc.b) != tc.want {
+ t.Error(benchttptest.DiffRunner(tc.a, tc.b))
+ }
+ })
+ }
+
+ t.Run("restore request body", func(t *testing.T) {
+ a := benchttp.Runner{
+ Request: httptest.NewRequest(
+ "POST",
+ "https://example.com",
+ strings.NewReader("hello"),
+ ),
+ }
+ b := benchttp.Runner{
+ Request: &http.Request{
+ Method: "POST",
+ URL: mustParseRequestURI("https://example.com"),
+ Body: io.NopCloser(bytes.NewReader([]byte("hello"))),
+ },
+ }
+
+ _ = benchttptest.EqualRunners(a, b)
+
+ ba, bb := mustRead(a.Request.Body), mustRead(b.Request.Body)
+ want := []byte("hello")
+ if !bytes.Equal(want, ba) || !bytes.Equal(want, bb) {
+ t.Fail()
+ }
+ })
+}
+
+// helpers
+
+func mustParseRequestURI(s string) *url.URL {
+ u, err := url.ParseRequestURI(s)
+ if err != nil {
+ panic(err)
+ }
+ return u
+}
+
+func mustRead(r io.Reader) []byte {
+ b, err := io.ReadAll(r)
+ if err != nil {
+ panic("mustRead: " + err.Error())
+ }
+ return b
+}
diff --git a/configio/builder.go b/configio/builder.go
new file mode 100644
index 0000000..58f7fe4
--- /dev/null
+++ b/configio/builder.go
@@ -0,0 +1,188 @@
+package configio
+
+import (
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/configio/internal/conversion"
+)
+
+// A Builder is used to incrementally build a benchttp.Runner
+// using Set and Write methods.
+// The zero value is ready to use.
+type Builder struct {
+ mutations []func(*benchttp.Runner)
+}
+
+// WriteJSON decodes the input bytes as a JSON benchttp configuration
+// and appends the resulting modifier to the builder.
+// It returns any encountered during the decoding process or if the
+// decoded configuration is invalid.
+func (b *Builder) WriteJSON(in []byte) error {
+ return b.decodeAndWrite(in, FormatJSON)
+}
+
+// WriteYAML decodes the input bytes as a YAML benchttp configuration
+// and appends the resulting modifier to the builder.
+// It returns any encountered during the decoding process or if the
+// decoded configuration is invalid.
+func (b *Builder) WriteYAML(in []byte) error {
+ return b.decodeAndWrite(in, FormatYAML)
+}
+
+func (b *Builder) decodeAndWrite(in []byte, format Format) error {
+ repr := conversion.Repr{}
+ if err := decoderOf(format, in).decodeRepr(&repr); err != nil {
+ return err
+ }
+ // early check for invalid configuration
+ if err := repr.Validate(); err != nil {
+ return err
+ }
+ b.append(func(dst *benchttp.Runner) {
+ // err is already checked via repr.validate(), so nil is expected.
+ if err := repr.Decode(dst); err != nil {
+ panicInternal("Builder.decodeAndWrite", "unexpected error: "+err.Error())
+ }
+ })
+ return nil
+}
+
+// Runner successively applies the Builder's mutations
+// to a zero benchttp.Runner and returns it.
+func (b *Builder) Runner() benchttp.Runner {
+ runner := benchttp.Runner{}
+ b.Mutate(&runner)
+ return runner
+}
+
+// Mutate successively applies the Builder's mutations
+// to the benchttp.Runner value pointed to by dst.
+func (b *Builder) Mutate(dst *benchttp.Runner) {
+ for _, mutate := range b.mutations {
+ mutate(dst)
+ }
+}
+
+// setters
+
+// SetRequest adds a mutation that sets a runner's request to r.
+func (b *Builder) SetRequest(r *http.Request) {
+ b.append(func(runner *benchttp.Runner) {
+ runner.Request = r
+ })
+}
+
+// SetRequestMethod adds a mutation that sets a runner's request method to v.
+func (b *Builder) SetRequestMethod(v string) {
+ b.append(func(runner *benchttp.Runner) {
+ if runner.Request == nil {
+ runner.Request = &http.Request{}
+ }
+ runner.Request.Method = v
+ })
+}
+
+// SetRequestURL adds a mutation that sets a runner's request URL to v.
+func (b *Builder) SetRequestURL(v *url.URL) {
+ b.append(func(runner *benchttp.Runner) {
+ if runner.Request == nil {
+ runner.Request = &http.Request{}
+ }
+ runner.Request.URL = v
+ })
+}
+
+// SetRequestHeader adds a mutation that sets a runner's request header to v.
+func (b *Builder) SetRequestHeader(v http.Header) {
+ b.SetRequestHeaderFunc(func(_ http.Header) http.Header {
+ return v
+ })
+}
+
+// SetRequestHeaderFunc adds a mutation that sets a runner's request header
+// to the result of calling f with its current request header.
+func (b *Builder) SetRequestHeaderFunc(f func(prev http.Header) http.Header) {
+ b.append(func(runner *benchttp.Runner) {
+ if runner.Request == nil {
+ runner.Request = &http.Request{}
+ }
+ runner.Request.Header = f(runner.Request.Header)
+ })
+}
+
+// SetRequestBody adds a mutation that sets a runner's request body to v.
+func (b *Builder) SetRequestBody(v io.ReadCloser) {
+ b.append(func(runner *benchttp.Runner) {
+ if runner.Request == nil {
+ runner.Request = &http.Request{}
+ }
+ runner.Request.Body = v
+ })
+}
+
+// SetRequests adds a mutation that sets a runner's
+// Requests field to v.
+func (b *Builder) SetRequests(v int) {
+ b.append(func(runner *benchttp.Runner) {
+ runner.Requests = v
+ })
+}
+
+// SetConcurrency adds a mutation that sets a runner's
+// Concurrency field to v.
+func (b *Builder) SetConcurrency(v int) {
+ b.append(func(runner *benchttp.Runner) {
+ runner.Concurrency = v
+ })
+}
+
+// SetInterval adds a mutation that sets a runner's
+// Interval field to v.
+func (b *Builder) SetInterval(v time.Duration) {
+ b.append(func(runner *benchttp.Runner) {
+ runner.Interval = v
+ })
+}
+
+// SetRequestTimeout adds a mutation that sets a runner's
+// RequestTimeout field to v.
+func (b *Builder) SetRequestTimeout(v time.Duration) {
+ b.append(func(runner *benchttp.Runner) {
+ runner.RequestTimeout = v
+ })
+}
+
+// SetGlobalTimeout adds a mutation that sets a runner's
+// GlobalTimeout field to v.
+func (b *Builder) SetGlobalTimeout(v time.Duration) {
+ b.append(func(runner *benchttp.Runner) {
+ runner.GlobalTimeout = v
+ })
+}
+
+// SetTests adds a mutation that sets a runner's
+// Tests field to v.
+func (b *Builder) SetTests(v []benchttp.TestCase) {
+ b.append(func(runner *benchttp.Runner) {
+ runner.Tests = v
+ })
+}
+
+// SetTests adds a mutation that appends the given benchttp.TestCases
+// to a runner's Tests field.
+func (b *Builder) AddTests(v ...benchttp.TestCase) {
+ b.append(func(runner *benchttp.Runner) {
+ runner.Tests = append(runner.Tests, v...)
+ })
+}
+
+func (b *Builder) append(modifier func(runner *benchttp.Runner)) {
+ if modifier == nil {
+ panicInternal("Builder.append", "call with nil modifier")
+ }
+ b.mutations = append(b.mutations, modifier)
+}
diff --git a/configio/builder_test.go b/configio/builder_test.go
new file mode 100644
index 0000000..4123882
--- /dev/null
+++ b/configio/builder_test.go
@@ -0,0 +1,148 @@
+package configio_test
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/benchttptest"
+ "github.com/benchttp/sdk/configio"
+)
+
+func TestBuilder_WriteJSON(t *testing.T) {
+ in := []byte(`{"runner":{"requests": 5}}`)
+ dest := benchttp.Runner{Requests: 0, Concurrency: 2}
+ want := benchttp.Runner{Requests: 5, Concurrency: 2}
+
+ b := configio.Builder{}
+ if err := b.WriteJSON(in); err != nil {
+ t.Fatal(err)
+ }
+ b.Mutate(&dest)
+
+ benchttptest.AssertEqualRunners(t, want, dest)
+}
+
+func TestBuilder_WriteYAML(t *testing.T) {
+ in := []byte(`runner: { requests: 5 }`)
+ dest := benchttp.Runner{Requests: 0, Concurrency: 2}
+ want := benchttp.Runner{Requests: 5, Concurrency: 2}
+
+ b := configio.Builder{}
+ if err := b.WriteYAML(in); err != nil {
+ t.Fatal(err)
+ }
+ b.Mutate(&dest)
+
+ benchttptest.AssertEqualRunners(t, want, dest)
+}
+
+func TestBuilder_Set(t *testing.T) {
+ t.Run("basic fields", func(t *testing.T) {
+ want := benchttp.Runner{
+ Requests: 5,
+ Concurrency: 2,
+ Interval: 10 * time.Millisecond,
+ RequestTimeout: 1 * time.Second,
+ GlobalTimeout: 10 * time.Second,
+ }
+
+ b := configio.Builder{}
+ b.SetRequests(want.Requests)
+ b.SetConcurrency(-1)
+ b.SetConcurrency(want.Concurrency)
+ b.SetInterval(want.Interval)
+ b.SetRequestTimeout(want.RequestTimeout)
+ b.SetGlobalTimeout(want.GlobalTimeout)
+
+ benchttptest.AssertEqualRunners(t, want, b.Runner())
+ })
+
+ t.Run("request", func(t *testing.T) {
+ want := benchttp.Runner{
+ Request: httptest.NewRequest("GET", "https://example.com", nil),
+ }
+
+ b := configio.Builder{}
+ b.SetRequest(want.Request)
+
+ benchttptest.AssertEqualRunners(t, want, b.Runner())
+ })
+
+ t.Run("request fields", func(t *testing.T) {
+ want := benchttp.Runner{
+ Request: &http.Request{
+ Method: "PUT",
+ URL: mustParseRequestURI("https://example.com"),
+ Header: http.Header{
+ "API_KEY": []string{"abc"},
+ "Accept": []string{"text/html", "application/json"},
+ },
+ Body: readcloser("hello"),
+ },
+ }
+
+ b := configio.Builder{}
+ b.SetRequestMethod(want.Request.Method)
+ b.SetRequestURL(want.Request.URL)
+ b.SetRequestHeader(http.Header{"API_KEY": []string{"abc"}})
+ b.SetRequestHeaderFunc(func(prev http.Header) http.Header {
+ prev.Add("Accept", "text/html")
+ prev.Add("Accept", "application/json")
+ return prev
+ })
+ b.SetRequestBody(readcloser("hello"))
+
+ benchttptest.AssertEqualRunners(t, want, b.Runner())
+ })
+
+ t.Run("test cases", func(t *testing.T) {
+ want := benchttp.Runner{
+ Tests: []benchttp.TestCase{
+ {
+ Name: "maximum response time",
+ Field: "ResponseTimes.Max",
+ Predicate: "LT",
+ Target: 100 * time.Millisecond,
+ },
+ {
+ Name: "similar response times",
+ Field: "ResponseTimes.StdDev",
+ Predicate: "LTE",
+ Target: 20 * time.Millisecond,
+ },
+ {
+ Name: "100% availability",
+ Field: "RequestFailureCount",
+ Predicate: "EQ",
+ Target: 0,
+ },
+ },
+ }
+
+ b := configio.Builder{}
+ b.SetTests([]benchttp.TestCase{want.Tests[0]})
+ b.AddTests(want.Tests[1:]...)
+
+ benchttptest.AssertEqualRunners(t, want, b.Runner())
+ })
+}
+
+// helpers
+
+func mustParseRequestURI(s string) *url.URL {
+ u, err := url.ParseRequestURI(s)
+ if err != nil {
+ panic("mustParseRequestURI: " + err.Error())
+ }
+ return u
+}
+
+func readcloser(s string) io.ReadCloser {
+ return io.NopCloser(strings.NewReader(s))
+}
diff --git a/configio/decoder.go b/configio/decoder.go
new file mode 100644
index 0000000..448e95b
--- /dev/null
+++ b/configio/decoder.go
@@ -0,0 +1,43 @@
+package configio
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/configio/internal/conversion"
+)
+
+type Format string
+
+const (
+ FormatJSON Format = "json"
+ FormatYAML Format = "yaml"
+)
+
+type Decoder interface {
+ Decode(dst *benchttp.Runner) error
+}
+
+// DecoderOf returns the appropriate Decoder for the given Format.
+// It panics if the format is not a Format declared in configio.
+func DecoderOf(format Format, in []byte) Decoder {
+ return decoderOf(format, in)
+}
+
+type decoder interface {
+ Decoder
+ decodeRepr(dst *conversion.Repr) error
+}
+
+func decoderOf(format Format, in []byte) decoder {
+ r := bytes.NewReader(in)
+ switch format {
+ case FormatYAML:
+ return NewYAMLDecoder(r)
+ case FormatJSON:
+ return NewJSONDecoder(r)
+ default:
+ panic(fmt.Sprintf("configio.DecoderOf: unexpected format: %q", format))
+ }
+}
diff --git a/configio/error.go b/configio/error.go
new file mode 100644
index 0000000..d3d46b4
--- /dev/null
+++ b/configio/error.go
@@ -0,0 +1,36 @@
+package configio
+
+import (
+ "errors"
+ "fmt"
+)
+
+var (
+ // ErrFileNotFound signals a config file not found.
+ ErrFileNotFound = errors.New("file not found")
+
+ // ErrFileRead signals an error trying to read a config file.
+ // It can be due to a corrupted file or an invalid permission
+ // for instance.
+ ErrFileRead = errors.New("invalid file")
+
+ // ErrFileExt signals an unsupported extension for the config file.
+ ErrFileExt = errors.New("invalid extension")
+
+ // ErrFileParse signals an error parsing a retrieved config file.
+ // It is returned if it contains an unexpected field or an unexpected
+ // value for a field.
+ ErrFileParse = errors.New("parsing error: invalid config file")
+
+ // ErrFileCircular signals a circular reference in the config file.
+ ErrFileCircular = errors.New("circular reference detected")
+)
+
+func panicInternal(funcname, detail string) {
+ const reportURL = "https://github.com/benchttp/sdk/issues/new"
+ source := fmt.Sprintf("configio.%s", funcname)
+ panic(fmt.Sprintf(
+ "%s: unexpected internal error: %s, please file an issue at %s",
+ source, detail, reportURL,
+ ))
+}
diff --git a/configio/example_test.go b/configio/example_test.go
new file mode 100644
index 0000000..b2f86dc
--- /dev/null
+++ b/configio/example_test.go
@@ -0,0 +1,40 @@
+package configio_test
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/configio"
+)
+
+var jsonConfig = []byte(
+ `{"request": {"method": "GET", "url": "https://example.com"}}`,
+)
+
+var yamlConfig = []byte(
+ `{request: {method: PUT}, runner: {requests: 42}}`,
+)
+
+func ExampleBuilder() {
+ runner := benchttp.Runner{Requests: -1, Concurrency: 3}
+
+ b := configio.Builder{}
+ _ = b.WriteJSON(jsonConfig)
+ _ = b.WriteYAML(yamlConfig)
+ b.SetInterval(100 * time.Millisecond)
+
+ b.Mutate(&runner)
+
+ // Output:
+ // PUT
+ // https://example.com
+ // 42
+ // 3
+ // 100ms
+ fmt.Println(runner.Request.Method)
+ fmt.Println(runner.Request.URL)
+ fmt.Println(runner.Requests)
+ fmt.Println(runner.Concurrency)
+ fmt.Println(runner.Interval)
+}
diff --git a/configio/file.go b/configio/file.go
new file mode 100644
index 0000000..0386102
--- /dev/null
+++ b/configio/file.go
@@ -0,0 +1,131 @@
+package configio
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/configio/internal/conversion"
+
+ "github.com/benchttp/sdk/internal/errorutil"
+)
+
+// DefaultPaths is the default list of paths looked up by FindFile when
+// called without parameters.
+var DefaultPaths = []string{
+ "./.benchttp.yml",
+ "./.benchttp.yaml",
+ "./.benchttp.json",
+}
+
+// FindFile returns the first name that matches a file path.
+// If input paths is empty, it uses DefaultPaths.
+// If no match is found, it returns an empty string.
+func FindFile(paths ...string) string {
+ if len(paths) == 0 {
+ paths = DefaultPaths
+ }
+ for _, path := range paths {
+ if _, err := os.Stat(path); err == nil { // err IS nil: file exists
+ return path
+ }
+ }
+ return ""
+}
+
+// UnmarshalFile parses given filename as a benchttp runner configuration
+// into a runner.Runner and stores the retrieved values into *dst.
+// It returns the first error occurring in the process, which can be
+// any of the values declared in the package.
+func UnmarshalFile(filename string, dst *benchttp.Runner) error {
+ f, err := file{path: filename}.decodeAll()
+ if err != nil {
+ return err
+ }
+ return f.reprs().MergeInto(dst)
+}
+
+// file represents a config file
+type file struct {
+ prev *file
+ path string
+ repr conversion.Repr
+}
+
+// decodeAll reads f.path as a file and decodes it into f.repr.
+// If the decoded file references another file, the operation
+// is repeated recursively until root file is reached.
+func (f file) decodeAll() (file, error) {
+ if err := f.decode(); err != nil {
+ return file{}, err
+ }
+
+ if isRoot := f.repr.Extends == nil; isRoot {
+ return f, nil
+ }
+
+ nextPath := filepath.Join(filepath.Dir(f.path), *f.repr.Extends)
+ if f.seen(nextPath) {
+ return file{}, errorutil.WithDetails(ErrFileCircular, nextPath)
+ }
+
+ return f.extend(nextPath).decodeAll()
+}
+
+// decode reads f.path as a file and decodes it into f.repr.
+func (f *file) decode() (err error) {
+ b, err := os.ReadFile(f.path)
+ switch {
+ case err == nil:
+ case errors.Is(err, os.ErrNotExist):
+ return errorutil.WithDetails(ErrFileNotFound, f.path)
+ default:
+ return errorutil.WithDetails(ErrFileRead, f.path, err)
+ }
+
+ ext, err := f.format()
+ if err != nil {
+ return err
+ }
+
+ if err := decoderOf(ext, b).decodeRepr(&f.repr); err != nil {
+ return errorutil.WithDetails(ErrFileParse, f.path, err)
+ }
+
+ return nil
+}
+
+func (f file) format() (Format, error) {
+ switch ext := filepath.Ext(f.path); ext {
+ case ".yml", ".yaml":
+ return FormatYAML, nil
+ case ".json":
+ return FormatJSON, nil
+ default:
+ return "", errorutil.WithDetails(ErrFileExt, ext, f.path)
+ }
+}
+
+func (f file) extend(nextPath string) file {
+ return file{prev: &f, path: nextPath}
+}
+
+// seen returns true if the given path has already been decoded
+// by the receiver or any of its ancestors.
+func (f file) seen(p string) bool {
+ if f.path == "" || p == "" {
+ panicInternal("file.seen", "empty f.path or p")
+ }
+ return f.path == p || (f.prev != nil && f.prev.seen(p))
+}
+
+// reprs returns a slice of Representation, starting with the receiver
+// and ending with the last child.
+func (f file) reprs() conversion.Reprs {
+ reprs := []conversion.Repr{f.repr}
+ if f.prev != nil {
+ reprs = append(reprs, f.prev.reprs()...)
+ }
+ return reprs
+}
diff --git a/configio/file_test.go b/configio/file_test.go
new file mode 100644
index 0000000..1f637f2
--- /dev/null
+++ b/configio/file_test.go
@@ -0,0 +1,200 @@
+package configio_test
+
+import (
+ "errors"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/benchttptest"
+ "github.com/benchttp/sdk/configio"
+ "github.com/benchttp/sdk/configio/internal/testdata"
+)
+
+func TestFindFile(t *testing.T) {
+ var (
+ fileYAML = testdata.ValidFullYAML().Path
+ fileJSON = testdata.ValidFullJSON().Path
+ nofile = testdata.InvalidPath().Path
+ )
+
+ testcases := []struct {
+ name string
+ inputPaths []string
+ defaultPaths []string
+ exp string
+ }{
+ {
+ name: "return first existing input path",
+ inputPaths: []string{nofile, fileYAML, fileJSON},
+ defaultPaths: []string{},
+ exp: fileYAML,
+ },
+ {
+ name: "return first existing default path if no input",
+ inputPaths: []string{},
+ defaultPaths: []string{nofile, fileYAML, fileJSON},
+ exp: fileYAML,
+ },
+ {
+ name: "return empty string if no file found",
+ inputPaths: []string{nofile},
+ defaultPaths: []string{},
+ exp: "",
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ if len(tc.defaultPaths) > 0 {
+ configio.DefaultPaths = tc.defaultPaths
+ }
+ got := configio.FindFile(tc.inputPaths...)
+ if got != tc.exp {
+ t.Errorf("exp %q, got %q", tc.exp, got)
+ }
+ })
+ }
+}
+
+func TestUnmarshalFile(t *testing.T) {
+ t.Run("return file errors early", func(t *testing.T) {
+ testcases := []struct {
+ label string
+ file testdata.ConfigFile
+ expErr error
+ }{
+ {
+ label: "empty path",
+ file: testdata.ConfigFile{Path: ""},
+ expErr: configio.ErrFileNotFound,
+ },
+ {
+ label: "not found",
+ file: testdata.InvalidPath(),
+ expErr: configio.ErrFileNotFound,
+ },
+ {
+ label: "unsupported extension",
+ file: testdata.InvalidExtension(),
+ expErr: configio.ErrFileExt,
+ },
+ {
+ label: "yaml invalid fields",
+ file: testdata.InvalidFieldsYML(),
+ expErr: configio.ErrFileParse,
+ },
+ {
+ label: "json invalid fields",
+ file: testdata.InvalidFieldsJSON(),
+ expErr: configio.ErrFileParse,
+ },
+ {
+ label: "self reference",
+ file: testdata.InvalidExtendsSelf(),
+ expErr: configio.ErrFileCircular,
+ },
+ {
+ label: "circular reference",
+ file: testdata.InvalidExtendsCircular(),
+ expErr: configio.ErrFileCircular,
+ },
+ {
+ label: "empty reference",
+ file: testdata.InvalidExtendsEmpty(),
+ expErr: configio.ErrFileNotFound,
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.label, func(t *testing.T) {
+ runner := benchttp.Runner{}
+ err := configio.UnmarshalFile(tc.file.Path, &runner)
+
+ assertStaticError(t, tc.expErr, err)
+ benchttptest.AssertEqualRunners(t, tc.file.Runner, runner)
+ })
+ }
+ })
+
+ t.Run("happy path all extensions", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ file testdata.ConfigFile
+ }{
+ {name: "full json", file: testdata.ValidFullJSON()},
+ {name: "full yaml", file: testdata.ValidFullYAML()},
+ {name: "full yml", file: testdata.ValidFullYML()},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ runner := benchttp.Runner{}
+ err := configio.UnmarshalFile(tc.file.Path, &runner)
+
+ mustAssertNilError(t, err)
+ benchttptest.AssertEqualRunners(t, tc.file.Runner, runner)
+ })
+ }
+ })
+
+ t.Run("override dst with set config values", func(t *testing.T) {
+ cfg := testdata.ValidPartial()
+ runner := benchttp.Runner{
+ Request: httptest.NewRequest("GET", "http://a.b", nil), // overridden
+ GlobalTimeout: 1 * time.Second, // overridden
+ }
+
+ err := configio.UnmarshalFile(cfg.Path, &runner)
+
+ mustAssertNilError(t, err)
+ benchttptest.AssertEqualRunners(t, cfg.Runner, runner)
+ })
+
+ t.Run("keep dst values not set in config", func(t *testing.T) {
+ const keptConcurrency = 5 // not set in config file
+
+ cfg := testdata.ValidPartial()
+ exp := cfg.Runner
+ exp.Concurrency = keptConcurrency
+ dst := benchttp.Runner{Concurrency: keptConcurrency}
+
+ err := configio.UnmarshalFile(cfg.Path, &dst)
+
+ mustAssertNilError(t, err)
+ benchttptest.AssertEqualRunners(t, exp, dst)
+ })
+
+ t.Run("extend config files", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ cfg testdata.ConfigFile
+ }{
+ {name: "same directory", cfg: testdata.ValidExtends()},
+ {name: "nested directory", cfg: testdata.ValidExtendsNested()},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dst := benchttp.Runner{}
+ err := configio.UnmarshalFile(tc.cfg.Path, &dst)
+
+ mustAssertNilError(t, err)
+ benchttptest.AssertEqualRunners(t, tc.cfg.Runner, dst)
+ })
+ }
+ })
+}
+
+// helpers
+
+func mustAssertNilError(t *testing.T, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatalf("exp nil error, got %v", err)
+ }
+}
+
+func assertStaticError(t *testing.T, exp, got error) {
+ t.Helper()
+ if !errors.Is(got, exp) {
+ t.Errorf("unexpected error:\nexp %v\ngot %v", exp, got)
+ }
+}
diff --git a/configio/internal/conversion/converters.go b/configio/internal/conversion/converters.go
new file mode 100644
index 0000000..e9437bf
--- /dev/null
+++ b/configio/internal/conversion/converters.go
@@ -0,0 +1,247 @@
+package conversion
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/benchttp/sdk/benchttp"
+)
+
+type converter struct {
+ encode func(src benchttp.Runner, dst *Repr)
+ decode func(src Repr, dst *benchttp.Runner) error
+}
+
+type requestConverter struct {
+ encode func(src *http.Request, dst *Repr)
+ decode func(src Repr, dst *http.Request) error
+}
+
+var converters = []converter{
+ fieldRequest,
+ fieldRunner,
+ fieldTests,
+}
+
+var fieldRequest = converter{
+ decode: func(src Repr, dst *benchttp.Runner) error {
+ if dst.Request == nil {
+ dst.Request = &http.Request{}
+ }
+ for _, c := range requestConverters {
+ if err := c.decode(src, dst.Request); err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ encode: func(src benchttp.Runner, dst *Repr) {
+ if src.Request != nil {
+ for _, c := range requestConverters {
+ c.encode(src.Request, dst)
+ }
+ }
+ },
+}
+
+var fieldRunner = converter{
+ decode: func(src Repr, dst *benchttp.Runner) error {
+ for _, c := range runnerConverters {
+ if err := c.decode(src, dst); err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ encode: func(src benchttp.Runner, dst *Repr) {
+ for _, c := range runnerConverters {
+ c.encode(src, dst)
+ }
+ },
+}
+
+var fieldTests = converter{
+ decode: func(src Repr, dst *benchttp.Runner) error {
+ if tests := src.Tests; tests != nil {
+ cases, err := parseTests(tests)
+ if err != nil {
+ return err
+ }
+ dst.Tests = cases
+ return nil
+ }
+ return nil
+ },
+ encode: func(src benchttp.Runner, dst *Repr) {
+ for _, c := range src.Tests {
+ // /!\ loop ref hazard
+ name := c.Name
+ field := string(c.Field)
+ predicate := string(c.Predicate)
+ target := c.Target
+ switch t := target.(type) {
+ case time.Duration:
+ target = t.String()
+ }
+ dst.Tests = append(dst.Tests, testcaseRepr{
+ Name: &name,
+ Field: &field,
+ Predicate: &predicate,
+ Target: &target,
+ })
+ }
+ },
+}
+
+var requestConverters = []requestConverter{
+ fieldRequestMethod,
+ fieldRequestURL,
+ fieldRequestHeader,
+ fieldRequestBody,
+}
+
+var runnerConverters = []converter{
+ fieldRunnerRequests,
+ fieldRunnerConcurrency,
+ fieldRunnerInterval,
+ fieldRunnerRequestTimeout,
+ fieldRunnerGlobalTimeout,
+}
+
+var fieldRequestMethod = requestConverter{
+ decode: func(src Repr, dst *http.Request) error {
+ if m := src.Request.Method; m != nil {
+ dst.Method = *m
+ }
+ return nil
+ },
+ encode: func(src *http.Request, dst *Repr) {
+ dst.Request.Method = &src.Method
+ },
+}
+
+var fieldRequestURL = requestConverter{
+ decode: func(src Repr, dst *http.Request) error {
+ if rawURL := src.Request.URL; rawURL != nil {
+ parsedURL, err := parseAndBuildURL(*rawURL, src.Request.QueryParams)
+ if err != nil {
+ return fmt.Errorf(`configio: invalid url: %q`, *rawURL)
+ }
+ dst.URL = parsedURL
+ }
+ return nil
+ },
+ encode: func(src *http.Request, dst *Repr) {
+ s := src.URL.String()
+ dst.Request.URL = &s
+ },
+}
+
+var fieldRequestHeader = requestConverter{
+ decode: func(src Repr, dst *http.Request) error {
+ if header := src.Request.Header; len(header) != 0 {
+ httpHeader := http.Header{}
+ for key, val := range header {
+ httpHeader[key] = val
+ }
+ dst.Header = httpHeader
+ }
+ return nil
+ },
+ encode: func(src *http.Request, dst *Repr) {
+ dst.Request.Header = src.Header
+ },
+}
+
+var fieldRequestBody = requestConverter{
+ decode: func(src Repr, dst *http.Request) error {
+ if body := src.Request.Body; body != nil {
+ switch body.Type {
+ case "raw":
+ dst.Body = io.NopCloser(bytes.NewReader([]byte(body.Content)))
+ default:
+ return errors.New(`configio: request.body.type: only "raw" accepted`)
+ }
+ }
+ return nil
+ },
+ encode: func(src *http.Request, dst *Repr) {
+ // TODO
+ },
+}
+
+var fieldRunnerRequests = bindInt(
+ func(src *Repr, dst *benchttp.Runner) (*int, *int) {
+ return src.Runner.Requests, &dst.Requests
+ },
+)
+
+var fieldRunnerConcurrency = bindInt(
+ func(src *Repr, dst *benchttp.Runner) (*int, *int) {
+ return src.Runner.Concurrency, &dst.Concurrency
+ },
+)
+
+var fieldRunnerInterval = bindDuration(
+ func(src *Repr, dst *benchttp.Runner) (*string, *time.Duration) {
+ return src.Runner.Interval, &dst.Interval
+ },
+)
+
+var fieldRunnerRequestTimeout = bindDuration(
+ func(src *Repr, dst *benchttp.Runner) (*string, *time.Duration) {
+ return src.Runner.RequestTimeout, &dst.RequestTimeout
+ },
+)
+
+var fieldRunnerGlobalTimeout = bindDuration(
+ func(src *Repr, dst *benchttp.Runner) (*string, *time.Duration) {
+ return src.Runner.GlobalTimeout, &dst.GlobalTimeout
+ },
+)
+
+func bindDuration(
+ bind func(*Repr, *benchttp.Runner) (*string, *time.Duration),
+) converter {
+ return converter{
+ decode: func(src Repr, dst *benchttp.Runner) error {
+ if vsrc, vdst := bind(&src, dst); vsrc != nil {
+ parsed, err := parseOptionalDuration(*vsrc)
+ if err != nil {
+ return err
+ }
+ *vdst = parsed
+ }
+ return nil
+ },
+ encode: func(src benchttp.Runner, dst *Repr) {
+ if vdst, vsrc := bind(dst, &src); vsrc != nil {
+ // FIXME: nil pointer deref
+ *vdst = vsrc.String()
+ }
+ },
+ }
+}
+
+func bindInt(
+ bind func(*Repr, *benchttp.Runner) (*int, *int),
+) converter {
+ return converter{
+ decode: func(src Repr, dst *benchttp.Runner) error {
+ if vsrc, vdst := bind(&src, dst); vsrc != nil {
+ *vdst = *vsrc
+ }
+ return nil
+ },
+ encode: func(src benchttp.Runner, dst *Repr) {
+ if vdst, vsrc := bind(dst, &src); vsrc != nil {
+ // FIXME: nil pointer deref
+ *vdst = *vsrc
+ }
+ },
+ }
+}
diff --git a/configio/internal/conversion/parsing.go b/configio/internal/conversion/parsing.go
new file mode 100644
index 0000000..26125f9
--- /dev/null
+++ b/configio/internal/conversion/parsing.go
@@ -0,0 +1,130 @@
+package conversion
+
+import (
+ "fmt"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/benchttp/sdk/benchttp"
+)
+
+// parseAndBuildURL parses a raw string as a *url.URL and adds any extra
+// query parameters. It returns the first non-nil error occurring in the
+// process.
+func parseAndBuildURL(raw string, qp map[string]string) (*url.URL, error) {
+ u, err := url.ParseRequestURI(raw)
+ if err != nil {
+ return nil, err
+ }
+
+ // retrieve url query, add extra params, re-attach to url
+ if qp != nil {
+ q := u.Query()
+ for k, v := range qp {
+ q.Add(k, v)
+ }
+ u.RawQuery = q.Encode()
+ }
+
+ return u, nil
+}
+
+// parseOptionalDuration parses the raw string as a time.Duration
+// and returns the parsed value or a non-nil error.
+// Contrary to time.ParseDuration, it does not return an error
+// if raw == "".
+func parseOptionalDuration(raw string) (time.Duration, error) {
+ if raw == "" {
+ return 0, nil
+ }
+ return time.ParseDuration(raw)
+}
+
+func parseTests(tests []testcaseRepr) ([]benchttp.TestCase, error) {
+ cases := make([]benchttp.TestCase, len(tests))
+ for i, in := range tests {
+ c, err := parseTestcase(in, i)
+ if err != nil {
+ return nil, err
+ }
+ cases[i] = c
+ }
+ return cases, nil
+}
+
+func parseTestcase(in testcaseRepr, idx int) (benchttp.TestCase, error) {
+ for fieldName, fieldValue := range map[string]interface{}{
+ "name": in.Name,
+ "field": in.Field,
+ "predicate": in.Predicate,
+ "target": in.Target,
+ } {
+ if fieldValue == nil {
+ return benchttp.TestCase{}, errMissingTestField(fieldName, idx)
+ }
+ }
+
+ field := benchttp.MetricsField(*in.Field)
+ if err := field.Validate(); err != nil {
+ return benchttp.TestCase{}, errInvalidTestField("field", idx, err)
+ }
+
+ predicate := benchttp.TestPredicate(*in.Predicate)
+ if err := predicate.Validate(); err != nil {
+ return benchttp.TestCase{}, errInvalidTestField("predicate", idx, err)
+ }
+
+ target, err := parseMetricValue(field, fmt.Sprint(in.Target))
+ if err != nil {
+ return benchttp.TestCase{}, errInvalidTestField("target", idx, err)
+ }
+
+ return benchttp.TestCase{
+ Name: *in.Name,
+ Field: field,
+ Predicate: predicate,
+ Target: target,
+ }, nil
+}
+
+func parseMetricValue(
+ field benchttp.MetricsField,
+ inputValue string,
+) (benchttp.MetricsValue, error) {
+ fieldType := field.Type()
+ handleError := func(v interface{}, err error) (benchttp.MetricsValue, error) {
+ if err != nil {
+ return nil, fmt.Errorf(
+ "value %q is incompatible with field %s (want %s)",
+ inputValue, field, fieldType,
+ )
+ }
+ return v, nil
+ }
+ switch fieldType {
+ case "int":
+ return handleError(strconv.Atoi(inputValue))
+ case "time.Duration":
+ return handleError(time.ParseDuration(inputValue))
+ default:
+ return nil, fmt.Errorf("unknown field: %s", field)
+ }
+}
+
+func assertDefinedFields(fields map[string]interface{}) error {
+ for name, value := range fields {
+ if value == nil {
+ return fmt.Errorf("%s: missing field", name)
+ }
+ }
+ return nil
+}
+
+func errMissingTestField(fieldName string, idx int) error {
+ return fmt.Errorf("tests[%d]: missing field %s", idx, fieldName)
+}
+
+func errInvalidTestField(fieldName string, idx int, err error) error {
+ return fmt.Errorf("tests[%d].%s: invalid value: %w", idx, fieldName, err)
+}
diff --git a/configio/internal/conversion/representation.go b/configio/internal/conversion/representation.go
new file mode 100644
index 0000000..91de501
--- /dev/null
+++ b/configio/internal/conversion/representation.go
@@ -0,0 +1,96 @@
+package conversion
+
+import (
+ "fmt"
+
+ "github.com/benchttp/sdk/benchttp"
+)
+
+// Repr is a raw data model for formatted runner config (json, yaml).
+// It serves as a receiver for unmarshaling processes and for that reason
+// its types are kept simple (certain types are incompatible with certain
+// unmarshalers).
+// It exposes a method Unmarshal to convert its values into a runner.Config.
+type Repr struct {
+ Extends *string `yaml:"extends" json:"extends"`
+ Request requestRepr `yaml:"request" json:"request"`
+ Runner runnerRepr `yaml:"runner" json:"runner"`
+ Tests []testcaseRepr `yaml:"tests" json:"tests"`
+}
+
+type requestRepr struct {
+ Method *string `yaml:"method" json:"method"`
+ URL *string `yaml:"url" json:"url"`
+ QueryParams map[string]string `yaml:"queryParams" json:"queryParams"`
+ Header map[string][]string `yaml:"header" json:"header"`
+ Body *struct {
+ Type string `yaml:"type" json:"type"`
+ Content string `yaml:"content" json:"content"`
+ } `yaml:"body" json:"body"`
+}
+
+type runnerRepr struct {
+ Requests *int `yaml:"requests" json:"requests"`
+ Concurrency *int `yaml:"concurrency" json:"concurrency"`
+ Interval *string `yaml:"interval" json:"interval"`
+ RequestTimeout *string `yaml:"requestTimeout" json:"requestTimeout"`
+ GlobalTimeout *string `yaml:"globalTimeout" json:"globalTimeout"`
+}
+
+type testcaseRepr struct {
+ Name *string `yaml:"name" json:"name"`
+ Field *string `yaml:"field" json:"field"`
+ Predicate *string `yaml:"predicate" json:"predicate"`
+ Target interface{} `yaml:"target" json:"target"`
+}
+
+func (repr Repr) Validate() error {
+ return repr.Decode(&benchttp.Runner{})
+}
+
+// Decode parses the Representation receiver as a benchttp.Runner
+// and stores any non-nil field value into the corresponding field
+// of dst.
+func (repr Repr) Decode(dst *benchttp.Runner) error {
+ for _, decoder := range converters {
+ if err := decoder.decode(repr, dst); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (repr *Repr) Encode(src benchttp.Runner) {
+ for _, c := range converters {
+ c.encode(src, repr)
+ }
+}
+
+type Reprs []Repr
+
+// MergeInto successively parses the given representations into dst.
+//
+// The input Representation slice must never be nil or empty, otherwise it panics.
+func (reprs Reprs) MergeInto(dst *benchttp.Runner) error {
+ if len(reprs) == 0 { // supposedly catched upstream, should not occur
+ panicInternal("parseAndMergeReprs", "nil or empty []Representation")
+ }
+
+ for _, repr := range reprs {
+ if err := repr.Decode(dst); err != nil {
+ return err
+ // TODO: uncomment once wrapped from configio/file.go
+ // return errorutil.WithDetails(ErrFileParse, err)
+ }
+ }
+ return nil
+}
+
+func panicInternal(funcname, detail string) {
+ const reportURL = "https://github.com/benchttp/sdk/issues/new"
+ source := fmt.Sprintf("configio.%s", funcname)
+ panic(fmt.Sprintf(
+ "%s: unexpected internal error: %s, please file an issue at %s",
+ source, detail, reportURL,
+ ))
+}
diff --git a/configio/internal/testdata/invalid/extends/circular-0.yml b/configio/internal/testdata/invalid/extends/circular-0.yml
new file mode 100644
index 0000000..38b6d95
--- /dev/null
+++ b/configio/internal/testdata/invalid/extends/circular-0.yml
@@ -0,0 +1 @@
+extends: ./circular-1.yml
diff --git a/configio/internal/testdata/invalid/extends/circular-1.yml b/configio/internal/testdata/invalid/extends/circular-1.yml
new file mode 100644
index 0000000..11a1535
--- /dev/null
+++ b/configio/internal/testdata/invalid/extends/circular-1.yml
@@ -0,0 +1 @@
+extends: ./circular-2.yml
diff --git a/configio/internal/testdata/invalid/extends/circular-2.yml b/configio/internal/testdata/invalid/extends/circular-2.yml
new file mode 100644
index 0000000..c20965b
--- /dev/null
+++ b/configio/internal/testdata/invalid/extends/circular-2.yml
@@ -0,0 +1 @@
+extends: ./circular-0.yml
diff --git a/configio/internal/testdata/invalid/extends/circular-self.yml b/configio/internal/testdata/invalid/extends/circular-self.yml
new file mode 100644
index 0000000..3345ad3
--- /dev/null
+++ b/configio/internal/testdata/invalid/extends/circular-self.yml
@@ -0,0 +1 @@
+extends: ./circular-self.yml
diff --git a/configio/internal/testdata/invalid/extends/empty-path.yml b/configio/internal/testdata/invalid/extends/empty-path.yml
new file mode 100644
index 0000000..b1f06eb
--- /dev/null
+++ b/configio/internal/testdata/invalid/extends/empty-path.yml
@@ -0,0 +1 @@
+extends: ""
diff --git a/configio/internal/testdata/invalid/extension.yams b/configio/internal/testdata/invalid/extension.yams
new file mode 100644
index 0000000..79c97a3
--- /dev/null
+++ b/configio/internal/testdata/invalid/extension.yams
@@ -0,0 +1,2 @@
+request:
+ url: https://localhost:3000/not-read
diff --git a/configio/internal/testdata/invalid/fields.json b/configio/internal/testdata/invalid/fields.json
new file mode 100644
index 0000000..9b45468
--- /dev/null
+++ b/configio/internal/testdata/invalid/fields.json
@@ -0,0 +1,7 @@
+{
+ "runner": {
+ "requests": [123],
+ "concurrency": "123"
+ },
+ "notafield": 123
+}
diff --git a/configio/internal/testdata/invalid/fields.yml b/configio/internal/testdata/invalid/fields.yml
new file mode 100644
index 0000000..6899cd8
--- /dev/null
+++ b/configio/internal/testdata/invalid/fields.yml
@@ -0,0 +1,4 @@
+runner:
+ requests: [123] # error: invalid type for field requests
+ concurrency: "123" # error: invalid type for field concurrency
+notafield: 123 # error: field does not exist
diff --git a/configio/internal/testdata/testdata.go b/configio/internal/testdata/testdata.go
new file mode 100644
index 0000000..4be6a8a
--- /dev/null
+++ b/configio/internal/testdata/testdata.go
@@ -0,0 +1,164 @@
+package testdata
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "time"
+
+ "github.com/benchttp/sdk/benchttp"
+)
+
+// ConfigFile represents a testdata configuration file.
+type ConfigFile struct {
+ // Path is the relative file path from configio.
+ Path string
+ // Runner is the expected benchttp.Runner.
+ Runner benchttp.Runner
+}
+
+// ValidFullJSON returns a valid full configuration file.
+func ValidFullJSON() ConfigFile {
+ return validConfig("full.json", kindFull)
+}
+
+func ValidFullYAML() ConfigFile {
+ return validConfig("full.yaml", kindFull)
+}
+
+func ValidFullYML() ConfigFile {
+ return validConfig("full.yml", kindFull)
+}
+
+func ValidPartial() ConfigFile {
+ return validConfig("partial.yml", kindPartial)
+}
+
+func ValidExtends() ConfigFile {
+ return validConfig("extends/child.yml", kindExtended)
+}
+
+func ValidExtendsNested() ConfigFile {
+ return validConfig("extends/nest-0/nest-1/child.yml", kindExtended)
+}
+
+func InvalidPath() ConfigFile {
+ return invalidConfig("does-not-exist.json")
+}
+
+func InvalidFieldsJSON() ConfigFile {
+ return invalidConfig("fields.json")
+}
+
+func InvalidFieldsYML() ConfigFile {
+ return invalidConfig("fields.yml")
+}
+
+func InvalidExtension() ConfigFile {
+ return invalidConfig("extension.yams")
+}
+
+func InvalidExtendsCircular() ConfigFile {
+ return invalidConfig("extends/circular-0.yml")
+}
+
+func InvalidExtendsSelf() ConfigFile {
+ return invalidConfig("extends/circular-self.yml")
+}
+
+func InvalidExtendsEmpty() ConfigFile {
+ return invalidConfig("extends/empty.yml")
+}
+
+type kind uint8
+
+const (
+ kindFull kind = iota
+ kindPartial
+ kindExtended
+)
+
+var basePath = filepath.Join("internal", "testdata")
+
+func validConfig(name string, k kind) ConfigFile {
+ return ConfigFile{
+ Path: filepath.Join(basePath, "valid", name),
+ Runner: runnerOf(k),
+ }
+}
+
+func invalidConfig(name string) ConfigFile {
+ return ConfigFile{
+ Path: filepath.Join(basePath, "invalid", name),
+ Runner: benchttp.Runner{},
+ }
+}
+
+func runnerOf(k kind) benchttp.Runner {
+ switch k {
+ case kindFull:
+ return fullRunner()
+ case kindPartial:
+ return partialRunner()
+ case kindExtended:
+ return extendedRunner()
+ default:
+ panic("invalid kind")
+ }
+}
+
+// fullRunner returns the expected runner from full configurations
+func fullRunner() benchttp.Runner {
+ request := httptest.NewRequest(
+ "POST",
+ "http://localhost:3000/benchttp?param0=value0¶m1=value1",
+ bytes.NewReader([]byte(`{"key0":"val0","key1":"val1"}`)),
+ )
+ request.Header = http.Header{
+ "key0": []string{"val0", "val1"},
+ "key1": []string{"val0"},
+ }
+ return benchttp.Runner{
+ Request: request,
+
+ Requests: 100,
+ Concurrency: 1,
+ Interval: 50 * time.Millisecond,
+ RequestTimeout: 2 * time.Second,
+ GlobalTimeout: 60 * time.Second,
+
+ Tests: []benchttp.TestCase{
+ {
+ Name: "maximum response time",
+ Field: "ResponseTimes.Max",
+ Predicate: "LTE",
+ Target: 120 * time.Millisecond,
+ },
+ {
+ Name: "100% availability",
+ Field: "RequestFailureCount",
+ Predicate: "EQ",
+ Target: 0,
+ },
+ },
+ }
+}
+
+// partialRunner returns the expected runner from partial configurations
+func partialRunner() benchttp.Runner {
+ return benchttp.Runner{
+ Request: httptest.NewRequest("GET", "http://localhost:3000/partial", nil),
+ GlobalTimeout: 42 * time.Second,
+ }
+}
+
+// extendedRunner returns the expected runner from extending configurations.
+func extendedRunner() benchttp.Runner {
+ return benchttp.Runner{
+ // child override
+ Request: httptest.NewRequest("PUT", "http://localhost:3000/child", nil),
+ // parent kept value
+ GlobalTimeout: 42 * time.Second,
+ }
+}
diff --git a/configio/internal/testdata/valid/extends/child.yml b/configio/internal/testdata/valid/extends/child.yml
new file mode 100644
index 0000000..0bc8912
--- /dev/null
+++ b/configio/internal/testdata/valid/extends/child.yml
@@ -0,0 +1,5 @@
+extends: ./parent.yml
+
+request:
+ method: PUT
+ url: http://localhost:3000/child
diff --git a/configio/internal/testdata/valid/extends/nest-0/nest-1/child.yml b/configio/internal/testdata/valid/extends/nest-0/nest-1/child.yml
new file mode 100644
index 0000000..a298cac
--- /dev/null
+++ b/configio/internal/testdata/valid/extends/nest-0/nest-1/child.yml
@@ -0,0 +1,5 @@
+extends: ../../parent.yml
+
+request:
+ method: PUT
+ url: http://localhost:3000/child
diff --git a/configio/internal/testdata/valid/extends/parent.yml b/configio/internal/testdata/valid/extends/parent.yml
new file mode 100644
index 0000000..7c88117
--- /dev/null
+++ b/configio/internal/testdata/valid/extends/parent.yml
@@ -0,0 +1,5 @@
+request:
+ url: http://localhost:3000/parent # overridden
+
+runner:
+ globalTimeout: 42s # kept
diff --git a/configio/internal/testdata/valid/full.json b/configio/internal/testdata/valid/full.json
new file mode 100644
index 0000000..5f46337
--- /dev/null
+++ b/configio/internal/testdata/valid/full.json
@@ -0,0 +1,38 @@
+{
+ "request": {
+ "method": "POST",
+ "url": "http://localhost:3000/benchttp?param0=value0",
+ "queryParams": {
+ "param1": "value1"
+ },
+ "header": {
+ "key0": ["val0", "val1"],
+ "key1": ["val0"]
+ },
+ "body": {
+ "type": "raw",
+ "content": "{\"key0\":\"val0\",\"key1\":\"val1\"}"
+ }
+ },
+ "runner": {
+ "requests": 100,
+ "concurrency": 1,
+ "interval": "50ms",
+ "requestTimeout": "2s",
+ "globalTimeout": "60s"
+ },
+ "tests": [
+ {
+ "name": "maximum response time",
+ "field": "ResponseTimes.Max",
+ "predicate": "LTE",
+ "target": "120ms"
+ },
+ {
+ "name": "100% availability",
+ "field": "RequestFailureCount",
+ "predicate": "EQ",
+ "target": "0"
+ }
+ ]
+}
diff --git a/configio/internal/testdata/valid/full.yaml b/configio/internal/testdata/valid/full.yaml
new file mode 100644
index 0000000..50d64a9
--- /dev/null
+++ b/configio/internal/testdata/valid/full.yaml
@@ -0,0 +1,31 @@
+x-custom: &data
+ method: POST
+ url: http://localhost:3000/benchttp?param0=value0
+
+request:
+ <<: *data
+ queryParams:
+ param1: value1
+ header:
+ key0: [val0, val1]
+ key1: [val0]
+ body:
+ type: raw
+ content: '{"key0":"val0","key1":"val1"}'
+
+runner:
+ requests: 100
+ concurrency: 1
+ interval: 50ms
+ requestTimeout: 2s
+ globalTimeout: 60s
+
+tests:
+ - name: maximum response time
+ field: ResponseTimes.Max
+ predicate: LTE
+ target: 120ms
+ - name: 100% availability
+ field: RequestFailureCount
+ predicate: EQ
+ target: 0
diff --git a/configio/internal/testdata/valid/full.yml b/configio/internal/testdata/valid/full.yml
new file mode 100644
index 0000000..981512e
--- /dev/null
+++ b/configio/internal/testdata/valid/full.yml
@@ -0,0 +1,28 @@
+request:
+ method: POST
+ url: http://localhost:3000/benchttp?param0=value0
+ queryParams:
+ param1: value1
+ header:
+ key0: [val0, val1]
+ key1: [val0]
+ body:
+ type: raw
+ content: '{"key0":"val0","key1":"val1"}'
+
+runner:
+ requests: 100
+ concurrency: 1
+ interval: 50ms
+ requestTimeout: 2s
+ globalTimeout: 60s
+
+tests:
+ - name: maximum response time
+ field: ResponseTimes.Max
+ predicate: LTE
+ target: 120ms
+ - name: 100% availability
+ field: RequestFailureCount
+ predicate: EQ
+ target: 0
diff --git a/configio/internal/testdata/valid/partial.yml b/configio/internal/testdata/valid/partial.yml
new file mode 100644
index 0000000..4969794
--- /dev/null
+++ b/configio/internal/testdata/valid/partial.yml
@@ -0,0 +1,6 @@
+request:
+ method: GET
+ url: http://localhost:3000/partial
+
+runner:
+ globalTimeout: 42s
diff --git a/configio/json.go b/configio/json.go
new file mode 100644
index 0000000..1bfc4b8
--- /dev/null
+++ b/configio/json.go
@@ -0,0 +1,99 @@
+package configio
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/configio/internal/conversion"
+)
+
+// JSONDecoder implements Decoder
+type JSONDecoder struct{ r io.Reader }
+
+var _ decoder = (*JSONDecoder)(nil)
+
+func MarshalJSON(runner benchttp.Runner) ([]byte, error) {
+ repr := conversion.Repr{}
+ repr.Encode(runner)
+ return json.Marshal(repr)
+}
+
+// UnmarshalJSON parses the JSON-encoded data and stores the result
+// in the benchttp.Runner pointed to by dst.
+func UnmarshalJSON(in []byte, dst *benchttp.Runner) error {
+ dec := NewJSONDecoder(bytes.NewReader(in))
+ return dec.Decode(dst)
+}
+
+func NewJSONDecoder(r io.Reader) JSONDecoder {
+ return JSONDecoder{r: r}
+}
+
+// Decode reads the next JSON-encoded value from its input
+// and stores it in the benchttp.Runner pointed to by dst.
+func (d JSONDecoder) Decode(dst *benchttp.Runner) error {
+ repr := conversion.Repr{}
+ if err := d.decodeRepr(&repr); err != nil {
+ return err
+ }
+ return repr.Decode(dst)
+}
+
+// decodeRepr reads the next JSON-encoded value from its input
+// and stores it in the Representation pointed to by dst.
+func (d JSONDecoder) decodeRepr(dst *conversion.Repr) error {
+ decoder := json.NewDecoder(d.r)
+ decoder.DisallowUnknownFields()
+ return d.handleError(decoder.Decode(dst))
+}
+
+// handleError handles an error from package json,
+// transforms it into a user-friendly standardized format
+// and returns the resulting error.
+func (d JSONDecoder) handleError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ // handle syntax error
+ var errSyntax *json.SyntaxError
+ if errors.As(err, &errSyntax) {
+ return fmt.Errorf("syntax error near %d: %w", errSyntax.Offset, err)
+ }
+
+ // handle type error
+ var errType *json.UnmarshalTypeError
+ if errors.As(err, &errType) {
+ return fmt.Errorf(
+ "wrong type for field %s: want %s, got %s",
+ errType.Field, errType.Type, errType.Value,
+ )
+ }
+
+ // handle unknown field error
+ if field := d.parseUnknownFieldError(err.Error()); field != "" {
+ return fmt.Errorf(`invalid field ("%s"): does not exist`, field)
+ }
+
+ return err
+}
+
+// parseJSONUnknownFieldError parses the raw string as a json error
+// from an unknown field and returns the field name.
+// If the raw string is not an unknown field error, it returns "".
+func (d JSONDecoder) parseUnknownFieldError(raw string) (field string) {
+ unknownFieldRgx := regexp.MustCompile(
+ // raw output example:
+ // json: unknown field "notafield"
+ `json: unknown field "(\S+)"`,
+ )
+ if matches := unknownFieldRgx.FindStringSubmatch(raw); len(matches) >= 2 {
+ return matches[1]
+ }
+ return ""
+}
diff --git a/configio/json_test.go b/configio/json_test.go
new file mode 100644
index 0000000..7e982e4
--- /dev/null
+++ b/configio/json_test.go
@@ -0,0 +1,171 @@
+package configio_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/benchttptest"
+ "github.com/benchttp/sdk/configio"
+ "github.com/benchttp/sdk/configio/internal/testdata"
+)
+
+func TestUnmarshalJSON(t *testing.T) {
+ const testURL = "https://example.com"
+ baseInput := object{
+ "request": object{
+ "url": testURL,
+ },
+ }
+
+ testcases := []struct {
+ name string
+ input []byte
+ isValidRunner func(base, got benchttp.Runner) bool
+ expError error
+ }{
+ {
+ name: "returns error if input json has bad keys",
+ input: baseInput.assign(object{
+ "badkey": "marcel-patulacci",
+ }).json(),
+ isValidRunner: func(_, _ benchttp.Runner) bool { return true },
+ expError: errors.New(`invalid field ("badkey"): does not exist`),
+ },
+ {
+ name: "returns error if input json has bad values",
+ input: baseInput.assign(object{
+ "runner": object{
+ "concurrency": "bad value", // want int
+ },
+ }).json(),
+ isValidRunner: func(_, _ benchttp.Runner) bool { return true },
+ expError: errors.New(`wrong type for field runner.concurrency: want int, got string`),
+ },
+ {
+ name: "unmarshals JSON config and merges it with base runner",
+ input: baseInput.json(),
+ isValidRunner: func(base, got benchttp.Runner) bool {
+ isParsed := got.Request.URL.String() == testURL
+ isMerged := got.GlobalTimeout == base.GlobalTimeout
+ return isParsed && isMerged
+ },
+ expError: nil,
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ gotRunner := benchttp.DefaultRunner()
+ gotError := configio.UnmarshalJSON(tc.input, &gotRunner)
+
+ if !tc.isValidRunner(benchttp.DefaultRunner(), gotRunner) {
+ t.Errorf("unexpected runner:\n%+v", gotRunner)
+ }
+ if !sameErrors(gotError, tc.expError) {
+ t.Errorf("unexpected error:\nexp %v,\ngot %v", tc.expError, gotError)
+ }
+ })
+ }
+}
+
+func TestJSONDecoder(t *testing.T) {
+ t.Run("return expected errors", func(t *testing.T) {
+ testcases := []struct {
+ label string
+ in []byte
+ exp string
+ }{
+ {
+ label: "syntax error",
+ in: []byte("{\n \"runner\": {},\n}\n"),
+ exp: "syntax error near 19: invalid character '}' looking for beginning of object key string",
+ },
+ {
+ label: "unknown field",
+ in: []byte("{\n \"notafield\": 123\n}\n"),
+ exp: `invalid field ("notafield"): does not exist`,
+ },
+ {
+ label: "wrong type",
+ in: []byte("{\n \"runner\": {\n \"requests\": [123]\n }\n}\n"),
+ exp: "wrong type for field runner.requests: want int, got array",
+ },
+ {
+ label: "valid config",
+ in: []byte("{\n \"runner\": {\n \"requests\": 123\n }\n}\n"),
+ exp: "",
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.label, func(t *testing.T) {
+ runner := benchttp.Runner{}
+ decoder := configio.NewJSONDecoder(bytes.NewReader(tc.in))
+
+ gotErr := decoder.Decode(&runner)
+
+ if tc.exp == "" {
+ if gotErr != nil {
+ t.Fatalf("unexpected error: %v", gotErr)
+ }
+ return
+ }
+ if gotErr.Error() != tc.exp {
+ t.Errorf(
+ "unexpected error message:\nexp %s\ngot %v",
+ tc.exp, gotErr,
+ )
+ }
+ })
+ }
+ })
+}
+
+func TestMarshalJSON(t *testing.T) {
+ src := testdata.ValidFullJSON().Runner
+
+ b, err := configio.MarshalJSON(src)
+ if err != nil {
+ t.Log(string(b))
+ t.Fatal(err)
+ }
+
+ got := benchttp.Runner{}
+ if err := configio.UnmarshalJSON(b, &got); err != nil {
+ t.Log(string(b))
+ t.Fatal(err)
+ }
+
+ benchttptest.AssertEqualRunners(t, src, got)
+ t.Log(string(b))
+}
+
+// helpers
+
+type object map[string]interface{}
+
+func (o object) json() []byte {
+ b, err := json.Marshal(o)
+ if err != nil {
+ panic(err)
+ }
+ return b
+}
+
+func (o object) assign(other object) object {
+ newObject := object{}
+ for k, v := range o {
+ newObject[k] = v
+ }
+ for k, v := range other {
+ newObject[k] = v
+ }
+ return newObject
+}
+
+func sameErrors(a, b error) bool {
+ return (a == nil && b == nil) || (a != nil && b != nil) || a.Error() == b.Error()
+}
diff --git a/configparse/parser_yaml.go b/configio/yaml.go
similarity index 65%
rename from configparse/parser_yaml.go
rename to configio/yaml.go
index 160cc7b..4ef91e3 100644
--- a/configparse/parser_yaml.go
+++ b/configio/yaml.go
@@ -1,28 +1,55 @@
-package configparse
+package configio
import (
"bytes"
"errors"
"fmt"
+ "io"
"regexp"
"gopkg.in/yaml.v3"
+
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/configio/internal/conversion"
)
-// YAMLParser implements configParser.
-type YAMLParser struct{}
+// YAMLDecoder implements Decoder
+type YAMLDecoder struct{ r io.Reader }
+
+var _ decoder = (*YAMLDecoder)(nil)
+
+// UnmarshalYAML parses the YAML-encoded data and stores the result
+// in the benchttp.Runner pointed to by dst.
+func UnmarshalYAML(in []byte, dst *benchttp.Runner) error {
+ dec := NewYAMLDecoder(bytes.NewReader(in))
+ return dec.Decode(dst)
+}
+
+func NewYAMLDecoder(r io.Reader) YAMLDecoder {
+ return YAMLDecoder{r: r}
+}
+
+// Decode reads the next YAML-encoded value from its input
+// and stores it in the benchttp.Runner pointed to by dst.
+func (d YAMLDecoder) Decode(dst *benchttp.Runner) error {
+ repr := conversion.Repr{}
+ if err := d.decodeRepr(&repr); err != nil {
+ return err
+ }
+ return repr.Decode(dst)
+}
-// Parse decodes a raw yaml input in strict mode (unknown fields disallowed)
-// and stores the resulting value into dst.
-func (p YAMLParser) Parse(in []byte, dst *Representation) error {
- decoder := yaml.NewDecoder(bytes.NewReader(in))
+// decodeRepr reads the next YAML-encoded value from its input
+// and stores it in the Representation pointed to by dst.
+func (d YAMLDecoder) decodeRepr(dst *conversion.Repr) error {
+ decoder := yaml.NewDecoder(d.r)
decoder.KnownFields(true)
- return p.handleError(decoder.Decode(dst))
+ return d.handleError(decoder.Decode(dst))
}
// handleError handles a raw yaml decoder.Decode error, filters it,
// and return the resulting error.
-func (p YAMLParser) handleError(err error) error {
+func (d YAMLDecoder) handleError(err error) error {
// yaml.TypeError errors require special handling, other errors
// (nil included) can be returned as is.
var typeError *yaml.TypeError
@@ -38,10 +65,10 @@ func (p YAMLParser) handleError(err error) error {
// It is a wanted behavior but prevents the usage of custom aliases.
// To work around this we allow an exception for that rule with fields
// starting with x- (inspired by docker compose api).
- if p.isCustomFieldError(msg) {
+ if d.isCustomFieldError(msg) {
continue
}
- filtered.Errors = append(filtered.Errors, p.prettyErrorMessage(msg))
+ filtered.Errors = append(filtered.Errors, d.prettyErrorMessage(msg))
}
if len(filtered.Errors) != 0 {
@@ -53,7 +80,7 @@ func (p YAMLParser) handleError(err error) error {
// isCustomFieldError returns true if the raw error message is due
// to an allowed custom field.
-func (p YAMLParser) isCustomFieldError(raw string) bool {
+func (d YAMLDecoder) isCustomFieldError(raw string) bool {
customFieldRgx := regexp.MustCompile(
// raw output example:
// line 9: field x-my-alias not found in type struct { ... }
@@ -65,7 +92,7 @@ func (p YAMLParser) isCustomFieldError(raw string) bool {
// prettyErrorMessage transforms a raw Decode error message into a more
// user-friendly one by removing noisy information and returns the resulting
// value.
-func (p YAMLParser) prettyErrorMessage(raw string) string {
+func (d YAMLDecoder) prettyErrorMessage(raw string) string {
// field not found error
fieldNotFoundRgx := regexp.MustCompile(
// raw output example (type unmarshaledConfig is entirely exposed):
diff --git a/configparse/parser_yaml_test.go b/configio/yaml_test.go
similarity index 85%
rename from configparse/parser_yaml_test.go
rename to configio/yaml_test.go
index e80439a..4bff966 100644
--- a/configparse/parser_yaml_test.go
+++ b/configio/yaml_test.go
@@ -1,16 +1,18 @@
-package configparse_test
+package configio_test
import (
+ "bytes"
"errors"
"reflect"
"testing"
"gopkg.in/yaml.v3"
- "github.com/benchttp/engine/configparse"
+ "github.com/benchttp/sdk/benchttp"
+ "github.com/benchttp/sdk/configio"
)
-func TestYAMLParser(t *testing.T) {
+func TestYAMLDecoder(t *testing.T) {
t.Run("return expected errors", func(t *testing.T) {
testcases := []struct {
label string
@@ -64,13 +66,10 @@ func TestYAMLParser(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.label, func(t *testing.T) {
- var (
- parser configparse.YAMLParser
- rawcfg configparse.Representation
- yamlErr *yaml.TypeError
- )
+ runner := benchttp.Runner{}
+ decoder := configio.NewYAMLDecoder(bytes.NewReader(tc.in))
- gotErr := parser.Parse(tc.in, &rawcfg)
+ gotErr := decoder.Decode(&runner)
if tc.expErr == nil {
if gotErr != nil {
@@ -79,6 +78,7 @@ func TestYAMLParser(t *testing.T) {
return
}
+ var yamlErr *yaml.TypeError
if !errors.As(gotErr, &yamlErr) && tc.expErr != nil {
t.Fatalf("unexpected error: %v", gotErr)
}
diff --git a/configparse/json.go b/configparse/json.go
deleted file mode 100644
index ed052c9..0000000
--- a/configparse/json.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package configparse
-
-import (
- "github.com/benchttp/engine/runner"
-)
-
-// JSON reads input bytes as JSON and unmarshals it into a runner.ConfigGlobal.
-func JSON(in []byte) (runner.Config, error) {
- parser := JSONParser{}
-
- var repr Representation
- if err := parser.Parse(in, &repr); err != nil {
- return runner.Config{}, err
- }
-
- cfg, err := ParseRepresentation(repr)
- if err != nil {
- return runner.Config{}, err
- }
-
- return cfg.Override(runner.DefaultConfig()), nil
-}
diff --git a/configparse/json_test.go b/configparse/json_test.go
deleted file mode 100644
index 142c98d..0000000
--- a/configparse/json_test.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package configparse_test
-
-import (
- "encoding/json"
- "errors"
- "net/url"
- "testing"
-
- "github.com/benchttp/engine/configparse"
- "github.com/benchttp/engine/runner"
-)
-
-func TestJSON(t *testing.T) {
- baseInput := object{
- "request": object{
- "url": "https://example.com",
- },
- }
-
- testcases := []struct {
- name string
- input []byte
- expConfig runner.Config
- expError error
- }{
- {
- name: "returns error if input json has bad keys",
- input: baseInput.assign(object{
- "badkey": "marcel-patulacci",
- }).json(),
- expConfig: runner.Config{},
- expError: errors.New(`invalid field ("badkey"): does not exist`),
- },
- {
- name: "returns error if input json has bad values",
- input: baseInput.assign(object{
- "runner": object{
- "concurrency": "bad value", // want int
- },
- }).json(),
- expConfig: runner.Config{},
- expError: errors.New(`wrong type for field runner.concurrency: want int, got string`),
- },
- {
- name: "unmarshals JSON config and merges it with default",
- input: baseInput.assign(object{
- "runner": object{"concurrency": 3},
- }).json(),
- expConfig: runner.Config{
- Request: runner.RequestConfig{
- URL: mustParseURL("https://example.com"),
- },
- Runner: runner.RecorderConfig{
- Concurrency: 3,
- },
- }.
- WithFields("url", "concurrency").
- Override(runner.DefaultConfig()),
- expError: nil,
- },
- }
-
- for _, tc := range testcases {
- t.Run(tc.name, func(t *testing.T) {
- gotConfig, gotError := configparse.JSON(tc.input)
- if !gotConfig.Equal(tc.expConfig) {
- t.Errorf("unexpected config:\nexp %+v\ngot %+v", tc.expConfig, gotConfig)
- }
-
- if !sameErrors(gotError, tc.expError) {
- t.Errorf("unexpected error:\nexp %v,\ngot %v", tc.expError, gotError)
- }
- })
- }
-}
-
-type object map[string]interface{}
-
-func (o object) json() []byte {
- b, err := json.Marshal(o)
- if err != nil {
- panic(err)
- }
- return b
-}
-
-func (o object) assign(other object) object {
- newObject := object{}
- for k, v := range o {
- newObject[k] = v
- }
- for k, v := range other {
- newObject[k] = v
- }
- return newObject
-}
-
-func mustParseURL(rawURL string) *url.URL {
- u, err := url.Parse(rawURL)
- if err != nil {
- panic(err)
- }
- return u
-}
-
-func sameErrors(a, b error) bool {
- if a == nil && b == nil {
- return true
- }
- if a == nil || b == nil {
- return false
- }
- return a.Error() == b.Error()
-}
diff --git a/configparse/parse.go b/configparse/parse.go
deleted file mode 100644
index 66761c7..0000000
--- a/configparse/parse.go
+++ /dev/null
@@ -1,242 +0,0 @@
-package configparse
-
-import (
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "time"
-
- "github.com/benchttp/engine/runner"
-)
-
-// Representation is a raw data model for runner config files.
-// It serves as a receiver for unmarshaling processes and for that reason
-// its types are kept simple (certain types are incompatible with certain
-// unmarshalers).
-type Representation struct {
- Extends *string `yaml:"extends" json:"extends"`
-
- Request struct {
- Method *string `yaml:"method" json:"method"`
- URL *string `yaml:"url" json:"url"`
- QueryParams map[string]string `yaml:"queryParams" json:"queryParams"`
- Header map[string][]string `yaml:"header" json:"header"`
- Body *struct {
- Type string `yaml:"type" json:"type"`
- Content string `yaml:"content" json:"content"`
- } `yaml:"body" json:"body"`
- } `yaml:"request" json:"request"`
-
- Runner struct {
- Requests *int `yaml:"requests" json:"requests"`
- Concurrency *int `yaml:"concurrency" json:"concurrency"`
- Interval *string `yaml:"interval" json:"interval"`
- RequestTimeout *string `yaml:"requestTimeout" json:"requestTimeout"`
- GlobalTimeout *string `yaml:"globalTimeout" json:"globalTimeout"`
- } `yaml:"runner" json:"runner"`
-
- Tests []struct {
- Name *string `yaml:"name" json:"name"`
- Field *string `yaml:"field" json:"field"`
- Predicate *string `yaml:"predicate" json:"predicate"`
- Target interface{} `yaml:"target" json:"target"`
- } `yaml:"tests" json:"tests"`
-}
-
-// ParseRepresentation parses an input raw config as a runner.ConfigGlobal and returns
-// a parsed Config or the first non-nil error occurring in the process.
-func ParseRepresentation(repr Representation) (runner.Config, error) { //nolint:gocognit // acceptable complexity for a parsing func
- cfg := runner.Config{}
- assignedFields := []string{}
-
- addField := func(field string) {
- assignedFields = append(assignedFields, field)
- }
-
- abort := func(err error) (runner.Config, error) {
- return runner.Config{}, err
- }
-
- if method := repr.Request.Method; method != nil {
- cfg.Request.Method = *method
- addField(runner.ConfigFieldMethod)
- }
-
- if rawURL := repr.Request.URL; rawURL != nil {
- parsedURL, err := parseAndBuildURL(*repr.Request.URL, repr.Request.QueryParams)
- if err != nil {
- return abort(err)
- }
- cfg.Request.URL = parsedURL
- addField(runner.ConfigFieldURL)
- }
-
- if header := repr.Request.Header; header != nil {
- httpHeader := http.Header{}
- for key, val := range header {
- httpHeader[key] = val
- }
- cfg.Request.Header = httpHeader
- addField(runner.ConfigFieldHeader)
- }
-
- if body := repr.Request.Body; body != nil {
- cfg.Request.Body = runner.RequestBody{
- Type: body.Type,
- Content: []byte(body.Content),
- }
- addField(runner.ConfigFieldBody)
- }
-
- if requests := repr.Runner.Requests; requests != nil {
- cfg.Runner.Requests = *requests
- addField(runner.ConfigFieldRequests)
- }
-
- if concurrency := repr.Runner.Concurrency; concurrency != nil {
- cfg.Runner.Concurrency = *concurrency
- addField(runner.ConfigFieldConcurrency)
- }
-
- if interval := repr.Runner.Interval; interval != nil {
- parsedInterval, err := parseOptionalDuration(*interval)
- if err != nil {
- return abort(err)
- }
- cfg.Runner.Interval = parsedInterval
- addField(runner.ConfigFieldInterval)
- }
-
- if requestTimeout := repr.Runner.RequestTimeout; requestTimeout != nil {
- parsedTimeout, err := parseOptionalDuration(*requestTimeout)
- if err != nil {
- return abort(err)
- }
- cfg.Runner.RequestTimeout = parsedTimeout
- addField(runner.ConfigFieldRequestTimeout)
- }
-
- if globalTimeout := repr.Runner.GlobalTimeout; globalTimeout != nil {
- parsedGlobalTimeout, err := parseOptionalDuration(*globalTimeout)
- if err != nil {
- return abort(err)
- }
- cfg.Runner.GlobalTimeout = parsedGlobalTimeout
- addField(runner.ConfigFieldGlobalTimeout)
- }
-
- testSuite := repr.Tests
- if len(testSuite) == 0 {
- return cfg.WithFields(assignedFields...), nil
- }
-
- cases := make([]runner.TestCase, len(testSuite))
- for i, t := range testSuite {
- fieldPath := func(caseField string) string {
- return fmt.Sprintf("tests[%d].%s", i, caseField)
- }
-
- if err := requireConfigFields(map[string]interface{}{
- fieldPath("name"): t.Name,
- fieldPath("field"): t.Field,
- fieldPath("predicate"): t.Predicate,
- fieldPath("target"): t.Target,
- }); err != nil {
- return abort(err)
- }
-
- field := runner.MetricsField(*t.Field)
- if err := field.Validate(); err != nil {
- return abort(fmt.Errorf("%s: %s", fieldPath("field"), err))
- }
-
- predicate := runner.TestPredicate(*t.Predicate)
- if err := predicate.Validate(); err != nil {
- return abort(fmt.Errorf("%s: %s", fieldPath("predicate"), err))
- }
-
- target, err := parseMetricValue(field, fmt.Sprint(t.Target))
- if err != nil {
- return abort(fmt.Errorf("%s: %s", fieldPath("target"), err))
- }
-
- cases[i] = runner.TestCase{
- Name: *t.Name,
- Field: field,
- Predicate: predicate,
- Target: target,
- }
- }
- cfg.Tests = cases
- addField(runner.ConfigFieldTests)
-
- return cfg.WithFields(assignedFields...), nil
-}
-
-// helpers
-
-// parseAndBuildURL parses a raw string as a *url.URL and adds any extra
-// query parameters. It returns the first non-nil error occurring in the
-// process.
-func parseAndBuildURL(raw string, qp map[string]string) (*url.URL, error) {
- u, err := url.ParseRequestURI(raw)
- if err != nil {
- return nil, err
- }
-
- // retrieve url query, add extra params, re-attach to url
- if qp != nil {
- q := u.Query()
- for k, v := range qp {
- q.Add(k, v)
- }
- u.RawQuery = q.Encode()
- }
-
- return u, nil
-}
-
-// parseOptionalDuration parses the raw string as a time.Duration
-// and returns the parsed value or a non-nil error.
-// Contrary to time.ParseDuration, it does not return an error
-// if raw == "".
-func parseOptionalDuration(raw string) (time.Duration, error) {
- if raw == "" {
- return 0, nil
- }
- return time.ParseDuration(raw)
-}
-
-func parseMetricValue(
- field runner.MetricsField,
- inputValue string,
-) (runner.MetricsValue, error) {
- fieldType := field.Type()
- handleError := func(v interface{}, err error) (runner.MetricsValue, error) {
- if err != nil {
- return nil, fmt.Errorf(
- "value %q is incompatible with field %s (want %s)",
- inputValue, field, fieldType,
- )
- }
- return v, nil
- }
- switch fieldType {
- case "int":
- return handleError(strconv.Atoi(inputValue))
- case "time.Duration":
- return handleError(time.ParseDuration(inputValue))
- default:
- return nil, fmt.Errorf("unknown field: %s", field)
- }
-}
-
-func requireConfigFields(fields map[string]interface{}) error {
- for name, value := range fields {
- if value == nil {
- return fmt.Errorf("%s: missing field", name)
- }
- }
- return nil
-}
diff --git a/configparse/parser_json.go b/configparse/parser_json.go
deleted file mode 100644
index ff7c64b..0000000
--- a/configparse/parser_json.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package configparse
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
- "regexp"
-)
-
-// JSONParser implements configParser.
-type JSONParser struct{}
-
-// Parse decodes a raw JSON input in strict mode (unknown fields disallowed)
-// and stores the resulting value into dst.
-func (p JSONParser) Parse(in []byte, dst *Representation) error {
- decoder := json.NewDecoder(bytes.NewReader(in))
- decoder.DisallowUnknownFields()
- return p.handleError(decoder.Decode(dst))
-}
-
-// handleError handle a json raw error, transforms it into a user-friendly
-// standardized format and returns the resulting error.
-func (p JSONParser) handleError(err error) error {
- if err == nil {
- return nil
- }
-
- // handle syntax error
- var errSyntax *json.SyntaxError
- if errors.As(err, &errSyntax) {
- return fmt.Errorf("syntax error near %d: %w", errSyntax.Offset, err)
- }
-
- // handle type error
- var errType *json.UnmarshalTypeError
- if errors.As(err, &errType) {
- return fmt.Errorf(
- "wrong type for field %s: want %s, got %s",
- errType.Field, errType.Type, errType.Value,
- )
- }
-
- // handle unknown field error
- if field := p.parseUnknownFieldError(err.Error()); field != "" {
- return fmt.Errorf(`invalid field ("%s"): does not exist`, field)
- }
-
- return err
-}
-
-// parseUnknownFieldError parses the raw string as a json error
-// from an unknown field and returns the field name.
-// If the raw string is not an unknown field error, it returns "".
-func (p JSONParser) parseUnknownFieldError(raw string) (field string) {
- unknownFieldRgx := regexp.MustCompile(
- // raw output example:
- // json: unknown field "notafield"
- `json: unknown field "(\S+)"`,
- )
- if matches := unknownFieldRgx.FindStringSubmatch(raw); len(matches) >= 2 {
- return matches[1]
- }
- return ""
-}
diff --git a/configparse/parser_json_test.go b/configparse/parser_json_test.go
deleted file mode 100644
index 51a4914..0000000
--- a/configparse/parser_json_test.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package configparse_test
-
-import (
- "testing"
-
- "github.com/benchttp/engine/configparse"
-)
-
-func TestJSONParser(t *testing.T) {
- t.Run("return expected errors", func(t *testing.T) {
- testcases := []struct {
- label string
- in []byte
- exp string
- }{
- {
- label: "syntax error",
- in: []byte("{\n \"runner\": {},\n}\n"),
- exp: "syntax error near 19: invalid character '}' looking for beginning of object key string",
- },
- {
- label: "unknown field",
- in: []byte("{\n \"notafield\": 123\n}\n"),
- exp: `invalid field ("notafield"): does not exist`,
- },
- {
- label: "wrong type",
- in: []byte("{\n \"runner\": {\n \"requests\": [123]\n }\n}\n"),
- exp: "wrong type for field runner.requests: want int, got array",
- },
- {
- label: "valid config",
- in: []byte("{\n \"runner\": {\n \"requests\": 123\n }\n}\n"),
- exp: "",
- },
- }
-
- for _, tc := range testcases {
- t.Run(tc.label, func(t *testing.T) {
- var (
- parser configparse.JSONParser
- rawcfg configparse.Representation
- )
-
- gotErr := parser.Parse(tc.in, &rawcfg)
-
- if tc.exp == "" {
- if gotErr != nil {
- t.Fatalf("unexpected error: %v", gotErr)
- }
- return
- }
-
- if gotErr.Error() != tc.exp {
- t.Errorf(
- "unexpected error messages:\nexp %s\ngot %v",
- tc.exp, gotErr,
- )
- }
- })
- }
- })
-}
diff --git a/go.mod b/go.mod
index 4de6a8f..6ac6d6f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,11 @@
-module github.com/benchttp/engine
+module github.com/benchttp/sdk
go 1.17
require (
- github.com/drykit-go/testx v1.2.0
+ github.com/google/go-cmp v0.5.9
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4
gopkg.in/yaml.v3 v3.0.1
)
-require github.com/drykit-go/cond v0.1.0 // indirect
+require github.com/google/go-cmp v0.5.9
diff --git a/go.sum b/go.sum
index 12452b0..cb4a9ff 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,5 @@
-github.com/drykit-go/cond v0.1.0 h1:y7MNxREQLT83vGfcfSKjyFPLC/ZDjYBNp6KuaVVjOg4=
-github.com/drykit-go/cond v0.1.0/go.mod h1:7MXBFjjaB5ZCEB8Q4w2euNOaWuTqf7NjOFZAyV1Jpfg=
-github.com/drykit-go/strcase v0.2.0/go.mod h1:cWK0/az2f09UPIbJ42Sb8Iqdv01uENrFX+XXKGjPo+8=
-github.com/drykit-go/testx v0.1.0/go.mod h1:qGXb49a8CzQ82crBeCVW8R3kGU1KRgWHnI+Q6CNVbz8=
-github.com/drykit-go/testx v1.2.0 h1:UsH+tFd24z3Xu+mwvwPY+9eBEg9CUyMsUeMYyUprG0o=
-github.com/drykit-go/testx v1.2.0/go.mod h1:qTzXJgnAg8n31woklBzNTaWzLMJrnFk93x/aeaIpc20=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
diff --git a/internal/dispatcher/dispatcher_test.go b/internal/dispatcher/dispatcher_test.go
index 6b5d9bd..953dad7 100644
--- a/internal/dispatcher/dispatcher_test.go
+++ b/internal/dispatcher/dispatcher_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"time"
- "github.com/benchttp/engine/internal/dispatcher"
+ "github.com/benchttp/sdk/internal/dispatcher"
)
func TestNew(t *testing.T) {
diff --git a/runner/internal/config/config.go b/runner/internal/config/config.go
deleted file mode 100644
index 4076333..0000000
--- a/runner/internal/config/config.go
+++ /dev/null
@@ -1,233 +0,0 @@
-package config
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "reflect"
- "time"
-
- "github.com/benchttp/engine/runner/internal/tests"
-)
-
-// RequestBody represents a request body associated with a type.
-// The type affects the way the content is processed.
-// If Type == "file", Content is read as a filepath to be resolved.
-// If Type == "raw", Content is attached as-is.
-//
-// Note: only "raw" is supported at the moment.
-type RequestBody struct {
- Type string
- Content []byte
-}
-
-// NewRequestBody returns a Body initialized with the given type and content.
-// For now, the only valid value for type is "raw".
-func NewRequestBody(typ, content string) RequestBody {
- return RequestBody{Type: typ, Content: []byte(content)}
-}
-
-// Request contains the confing options relative to a single request.
-type Request struct {
- Method string
- URL *url.URL
- Header http.Header
- Body RequestBody
-}
-
-// Value generates a *http.Request based on Request and returns it
-// or any non-nil error that occurred.
-func (r Request) Value() (*http.Request, error) {
- if r.URL == nil {
- return nil, errors.New("empty url")
- }
- rawURL := r.URL.String()
- if _, err := url.ParseRequestURI(rawURL); err != nil {
- return nil, errors.New("bad url")
- }
-
- req, err := http.NewRequest(r.Method, rawURL, bytes.NewReader(r.Body.Content))
- if err != nil {
- return nil, err
- }
- req.Header = r.Header
- return req, nil
-}
-
-// WithURL sets the current Request with the parsed *url.URL from rawURL
-// and returns it. Any errors is discarded as a Config can be invalid
-// until Config.Validate is called. The url is always non-nil.
-func (r Request) WithURL(rawURL string) Request {
- // ignore err: a Config can be invalid at this point
- urlURL, _ := url.ParseRequestURI(rawURL)
- if urlURL == nil {
- urlURL = &url.URL{}
- }
- r.URL = urlURL
- return r
-}
-
-// Runner contains options relative to the runner.
-type Runner struct {
- Requests int
- Concurrency int
- Interval time.Duration
- RequestTimeout time.Duration
- GlobalTimeout time.Duration
-}
-
-type set map[string]struct{}
-
-func (set set) add(values ...string) {
- for _, v := range values {
- set[v] = struct{}{}
- }
-}
-
-// Global represents the global configuration of the runner.
-// It must be validated using Global.Validate before usage.
-type Global struct {
- Request Request
- Runner Runner
-
- Tests []tests.Case
-
- assignedFields set
-}
-
-// WithField returns a new Global with the input fields marked as set.
-// Accepted options are limited to existing Fields, other values are
-// silently ignored.
-func (cfg Global) WithFields(fields ...string) Global {
- if cfg.assignedFields == nil {
- cfg.assignedFields = set{}
- }
- cfg.assignedFields.add(fields...)
- return cfg
-}
-
-// String implements fmt.Stringer. It returns an indented JSON representation
-// of Config for debugging purposes.
-func (cfg Global) String() string {
- b, _ := json.MarshalIndent(cfg, "", " ")
- return string(b)
-}
-
-// Equal returns true if cfg and c are equal configurations.
-func (cfg Global) Equal(c Global) bool {
- cfg.assignedFields = nil
- c.assignedFields = nil
- return reflect.DeepEqual(cfg, c)
-}
-
-// Override returns a new Config by overriding the values of base
-// with the values from the Config receiver.
-// Only fields previously specified by the receiver via Config.WithFields
-// are replaced.
-// All other values from base are preserved.
-//
-// The following example is equivalent to defaultConfig with the concurrency
-// value from myConfig:
-//
-// myConfig.
-// WithFields(FieldConcurrency).
-// Override(defaultConfig)
-//
-// The following example is equivalent to defaultConfig, as no field as been
-// tagged via WithFields by the receiver:
-//
-// myConfig.Override(defaultConfig)
-func (cfg Global) Override(base Global) Global {
- for field := range cfg.assignedFields {
- switch field {
- case FieldMethod:
- base.Request.Method = cfg.Request.Method
- case FieldURL:
- base.Request.URL = cfg.Request.URL
- case FieldHeader:
- base.overrideHeader(cfg.Request.Header)
- case FieldBody:
- base.Request.Body = cfg.Request.Body
- case FieldRequests:
- base.Runner.Requests = cfg.Runner.Requests
- case FieldConcurrency:
- base.Runner.Concurrency = cfg.Runner.Concurrency
- case FieldInterval:
- base.Runner.Interval = cfg.Runner.Interval
- case FieldRequestTimeout:
- base.Runner.RequestTimeout = cfg.Runner.RequestTimeout
- case FieldGlobalTimeout:
- base.Runner.GlobalTimeout = cfg.Runner.GlobalTimeout
- case FieldTests:
- base.Tests = cfg.Tests
- }
- }
- return base
-}
-
-// overrideHeader overrides cfg's Request.Header with the values from newHeader.
-// For every key in newHeader:
-//
-// - If it's not present in cfg.Request.Header, it is added.
-//
-// - If it's already present in cfg.Request.Header, the value is replaced.
-//
-// - All other keys in cfg.Request.Header are left untouched.
-func (cfg *Global) overrideHeader(newHeader http.Header) {
- if newHeader == nil {
- return
- }
- if cfg.Request.Header == nil {
- cfg.Request.Header = http.Header{}
- }
- for k, v := range newHeader {
- cfg.Request.Header[k] = v
- }
-}
-
-// Validate returns a non-nil InvalidConfigError if any of its fields
-// does not meet the requirements.
-func (cfg Global) Validate() error { //nolint:gocognit
- errs := []error{}
- appendError := func(err error) {
- errs = append(errs, err)
- }
-
- if cfg.Request.URL == nil {
- appendError(errors.New("url: missing"))
- } else if _, err := url.ParseRequestURI(cfg.Request.URL.String()); err != nil {
- appendError(fmt.Errorf("url (%q): invalid", cfg.Request.URL.String()))
- }
-
- if cfg.Runner.Requests < 1 && cfg.Runner.Requests != -1 {
- appendError(fmt.Errorf("requests (%d): want >= 0", cfg.Runner.Requests))
- }
-
- if cfg.Runner.Concurrency < 1 || cfg.Runner.Concurrency > cfg.Runner.Requests {
- appendError(fmt.Errorf(
- "concurrency (%d): want > 0 and <= requests (%d)",
- cfg.Runner.Concurrency, cfg.Runner.Requests,
- ))
- }
-
- if cfg.Runner.Interval < 0 {
- appendError(fmt.Errorf("interval (%d): want >= 0", cfg.Runner.Interval))
- }
-
- if cfg.Runner.RequestTimeout < 1 {
- appendError(fmt.Errorf("requestTimeout (%d): want > 0", cfg.Runner.RequestTimeout))
- }
-
- if cfg.Runner.GlobalTimeout < 1 {
- appendError(fmt.Errorf("globalTimeout (%d): want > 0", cfg.Runner.GlobalTimeout))
- }
-
- if len(errs) > 0 {
- return &InvalidConfigError{errs}
- }
-
- return nil
-}
diff --git a/runner/internal/config/config_test.go b/runner/internal/config/config_test.go
deleted file mode 100644
index 6d43ebe..0000000
--- a/runner/internal/config/config_test.go
+++ /dev/null
@@ -1,362 +0,0 @@
-package config_test
-
-import (
- "bytes"
- "errors"
- "io"
- "net/http"
- "net/url"
- "reflect"
- "testing"
- "time"
-
- "github.com/benchttp/engine/runner/internal/config"
-)
-
-var validBody = config.NewRequestBody("raw", `{"key0": "val0", "key1": "val1"}`)
-
-func TestGlobal_Validate(t *testing.T) {
- t.Run("return nil if config is valid", func(t *testing.T) {
- cfg := config.Global{
- Request: config.Request{
- Body: validBody,
- }.WithURL("https://github.com/benchttp/"),
- Runner: config.Runner{
- Requests: 5,
- Concurrency: 5,
- Interval: 5,
- RequestTimeout: 5,
- GlobalTimeout: 5,
- },
- }
- if err := cfg.Validate(); err != nil {
- t.Errorf("unexpected error: %v", err)
- }
- })
-
- t.Run("return cumulated errors if config is invalid", func(t *testing.T) {
- cfg := config.Global{
- Request: config.Request{
- Body: config.RequestBody{},
- }.WithURL("abc"),
- Runner: config.Runner{
- Requests: -5,
- Concurrency: -5,
- Interval: -5,
- RequestTimeout: -5,
- GlobalTimeout: -5,
- },
- }
-
- err := cfg.Validate()
- if err == nil {
- t.Fatal("invalid configuration considered valid")
- }
-
- var errInvalid *config.InvalidConfigError
- if !errors.As(err, &errInvalid) {
- t.Fatalf("unexpected error type: %T", err)
- }
-
- errs := errInvalid.Errors
- findErrorOrFail(t, errs, `url (""): invalid`)
- findErrorOrFail(t, errs, `requests (-5): want >= 0`)
- findErrorOrFail(t, errs, `concurrency (-5): want > 0 and <= requests (-5)`)
- findErrorOrFail(t, errs, `interval (-5): want >= 0`)
- findErrorOrFail(t, errs, `requestTimeout (-5): want > 0`)
- findErrorOrFail(t, errs, `globalTimeout (-5): want > 0`)
-
- t.Logf("got error:\n%v", errInvalid)
- })
-}
-
-func TestGlobal_Override(t *testing.T) {
- t.Run("do not override unspecified fields", func(t *testing.T) {
- baseCfg := config.Global{}
- nextCfg := config.Global{
- Request: config.Request{
- Body: config.RequestBody{},
- }.WithURL("http://a.b?p=2"),
- Runner: config.Runner{
- Requests: 1,
- Concurrency: 2,
- RequestTimeout: 3 * time.Second,
- GlobalTimeout: 4 * time.Second,
- },
- }
-
- if gotCfg := nextCfg.Override(baseCfg); !gotCfg.Equal(baseCfg) {
- t.Errorf("overrode unexpected fields:\nexp %#v\ngot %#v", baseCfg, gotCfg)
- }
- })
-
- t.Run("override specified fields", func(t *testing.T) {
- fields := []string{
- config.FieldMethod,
- config.FieldURL,
- config.FieldRequests,
- config.FieldConcurrency,
- config.FieldRequestTimeout,
- config.FieldGlobalTimeout,
- config.FieldBody,
- }
-
- baseCfg := config.Global{}
- nextCfg := config.Global{
- Request: config.Request{
- Body: validBody,
- }.WithURL("http://a.b?p=2"),
- Runner: config.Runner{
- Requests: 1,
- Concurrency: 2,
- RequestTimeout: 3 * time.Second,
- GlobalTimeout: 4 * time.Second,
- },
- }.WithFields(fields...)
-
- if gotCfg := nextCfg.Override(baseCfg); !gotCfg.Equal(nextCfg) {
- t.Errorf("did not override expected fields:\nexp %v\ngot %v", nextCfg, gotCfg)
- t.Log(fields)
- }
- })
-
- t.Run("override header selectively", func(t *testing.T) {
- testcases := []struct {
- label string
- oldHeader http.Header
- newHeader http.Header
- expHeader http.Header
- }{
- {
- label: "erase overridden keys",
- oldHeader: http.Header{"key": []string{"oldval"}},
- newHeader: http.Header{"key": []string{"newval"}},
- expHeader: http.Header{"key": []string{"newval"}},
- },
- {
- label: "do not erase not overridden keys",
- oldHeader: http.Header{"key": []string{"oldval"}},
- newHeader: http.Header{},
- expHeader: http.Header{"key": []string{"oldval"}},
- },
- {
- label: "add new keys",
- oldHeader: http.Header{"key0": []string{"oldval"}},
- newHeader: http.Header{"key1": []string{"newval"}},
- expHeader: http.Header{
- "key0": []string{"oldval"},
- "key1": []string{"newval"},
- },
- },
- {
- label: "erase only overridden keys",
- oldHeader: http.Header{
- "key0": []string{"oldval0", "oldval1"},
- "key1": []string{"oldval0", "oldval1"},
- },
- newHeader: http.Header{
- "key1": []string{"newval0", "newval1"},
- "key2": []string{"newval0", "newval1"},
- },
- expHeader: http.Header{
- "key0": []string{"oldval0", "oldval1"},
- "key1": []string{"newval0", "newval1"},
- "key2": []string{"newval0", "newval1"},
- },
- },
- {
- label: "nil new header does nothing",
- oldHeader: http.Header{"key": []string{"val"}},
- newHeader: nil,
- expHeader: http.Header{"key": []string{"val"}},
- },
- {
- label: "replace nil old header",
- oldHeader: nil,
- newHeader: http.Header{"key": []string{"val"}},
- expHeader: http.Header{"key": []string{"val"}},
- },
- {
- label: "nil over nil is nil",
- oldHeader: nil,
- newHeader: nil,
- expHeader: nil,
- },
- }
-
- for _, tc := range testcases {
- t.Run(tc.label, func(t *testing.T) {
- baseCfg := config.Global{
- Request: config.Request{
- Header: tc.oldHeader,
- },
- }
-
- nextCfg := config.Global{
- Request: config.Request{
- Header: tc.newHeader,
- },
- }.WithFields(config.FieldHeader)
-
- gotCfg := nextCfg.Override(baseCfg)
-
- if gotHeader := gotCfg.Request.Header; !reflect.DeepEqual(gotHeader, tc.expHeader) {
- t.Errorf("\nexp %#v\ngot %#v", tc.expHeader, gotHeader)
- }
- })
- }
- })
-}
-
-func TestRequest_WithURL(t *testing.T) {
- t.Run("set empty url if invalid", func(t *testing.T) {
- cfg := config.Global{Request: config.Request{}.WithURL("abc")}
- if got := cfg.Request.URL; !reflect.DeepEqual(got, &url.URL{}) {
- t.Errorf("exp empty *url.URL, got %v", got)
- }
- })
-
- t.Run("set parsed url", func(t *testing.T) {
- var (
- rawURL = "http://benchttp.app?cool=true"
- expURL, _ = url.ParseRequestURI(rawURL)
- gotURL = config.Request{}.WithURL(rawURL).URL
- )
-
- if !reflect.DeepEqual(gotURL, expURL) {
- t.Errorf("\nexp %v\ngot %v", expURL, gotURL)
- }
- })
-}
-
-func TestRequest_Value(t *testing.T) {
- testcases := []struct {
- label string
- in config.Request
- expMsg string
- }{
- {
- label: "return error if url is empty",
- in: config.Request{},
- expMsg: "empty url",
- },
- {
- label: "return error if url is invalid",
- in: config.Request{URL: &url.URL{Scheme: ""}},
- expMsg: "bad url",
- },
- {
- label: "return error if NewRequest fails",
- in: config.Request{Method: "é", URL: &url.URL{Scheme: "http"}},
- expMsg: `net/http: invalid method "é"`,
- },
- }
-
- for _, tc := range testcases {
- t.Run(tc.label, func(t *testing.T) {
- gotReq, gotErr := tc.in.Value()
- if gotErr == nil {
- t.Fatal("exp error, got nil")
- }
-
- if gotMsg := gotErr.Error(); gotMsg != tc.expMsg {
- t.Errorf("\nexp %q\ngot %q", tc.expMsg, gotMsg)
- }
-
- if gotReq != nil {
- t.Errorf("exp nil, got %v", gotReq)
- }
- })
- }
-
- t.Run("return request with added headers", func(t *testing.T) {
- in := config.Request{
- Method: "POST",
- Header: http.Header{"key": []string{"val"}},
- Body: config.RequestBody{Content: []byte("abc")},
- }.WithURL("http://a.b")
-
- expReq, err := http.NewRequest(
- in.Method,
- in.URL.String(),
- bytes.NewReader(in.Body.Content),
- )
- if err != nil {
- t.Fatal(err)
- }
- expReq.Header = in.Header
-
- gotReq, gotErr := in.Value()
- if gotErr != nil {
- t.Fatal(err)
- }
-
- if !sameRequests(gotReq, expReq) {
- t.Errorf("\nexp %#v\ngot %#v", expReq, gotReq)
- }
- })
-}
-
-func TestGlobal_Equal(t *testing.T) {
- t.Run("returns false for different configs", func(t *testing.T) {
- base := config.Default()
- diff := base
- diff.Runner.Requests = base.Runner.Requests + 1
-
- if base.Equal(diff) {
- t.Error("exp unequal configs")
- }
- })
-
- t.Run("ignores set fields", func(t *testing.T) {
- base := config.Default()
- same := base.WithFields(config.FieldRequests)
-
- if !base.Equal(same) {
- t.Error("exp equal configs")
- }
- })
-
- t.Run("does not alter configs", func(t *testing.T) {
- baseA := config.Default().WithFields(config.FieldRequests)
- copyA := config.Default().WithFields(config.FieldRequests)
- baseB := config.Default().WithFields(config.FieldURL)
- copyB := config.Default().WithFields(config.FieldURL)
-
- baseA.Equal(baseB)
-
- if !reflect.DeepEqual(baseA, copyA) {
- t.Error("altered receiver config")
- }
- if !reflect.DeepEqual(baseB, copyB) {
- t.Error("altered parameter config")
- }
- })
-}
-
-// helpers
-
-// findErrorOrFail fails t if no error in src matches msg.
-func findErrorOrFail(t *testing.T, src []error, msg string) {
- t.Helper()
- for _, err := range src {
- if err.Error() == msg {
- return
- }
- }
- t.Errorf("missing error: %v", msg)
-}
-
-func sameRequests(a, b *http.Request) bool {
- if a == nil || b == nil {
- return a == b
- }
-
- ab, _ := io.ReadAll(a.Body)
- bb, _ := io.ReadAll(b.Body)
-
- return a.Method == b.Method &&
- a.URL.String() == b.URL.String() &&
- bytes.Equal(ab, bb) &&
- reflect.DeepEqual(a.Header, b.Header)
-}
diff --git a/runner/internal/config/default.go b/runner/internal/config/default.go
deleted file mode 100644
index b5299de..0000000
--- a/runner/internal/config/default.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package config
-
-import (
- "net/http"
- "net/url"
- "time"
-)
-
-var defaultConfig = Global{
- Request: Request{
- Method: "GET",
- URL: &url.URL{},
- Header: http.Header{},
- Body: RequestBody{},
- },
- Runner: Runner{
- Concurrency: 10,
- Requests: 100,
- Interval: 0 * time.Second,
- RequestTimeout: 5 * time.Second,
- GlobalTimeout: 30 * time.Second,
- },
-}
-
-// Default returns a default config that is safe to use.
-func Default() Global {
- return defaultConfig
-}
diff --git a/runner/internal/config/field.go b/runner/internal/config/field.go
deleted file mode 100644
index c23c831..0000000
--- a/runner/internal/config/field.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package config
-
-const (
- FieldMethod = "method"
- FieldURL = "url"
- FieldHeader = "header"
- FieldBody = "body"
- FieldRequests = "requests"
- FieldConcurrency = "concurrency"
- FieldInterval = "interval"
- FieldRequestTimeout = "requestTimeout"
- FieldGlobalTimeout = "globalTimeout"
- FieldTests = "tests"
-)
-
-// FieldsUsage is a record of all available config fields and their usage.
-var FieldsUsage = map[string]string{
- FieldMethod: "HTTP request method",
- FieldURL: "HTTP request url",
- FieldHeader: "HTTP request header",
- FieldBody: "HTTP request body",
- FieldRequests: "Number of requests to run, use duration as exit condition if omitted",
- FieldConcurrency: "Number of connections to run concurrently",
- FieldInterval: "Minimum duration between two non concurrent requests",
- FieldRequestTimeout: "Timeout for each HTTP request",
- FieldGlobalTimeout: "Max duration of test",
- FieldTests: "Test suite",
-}
-
-func IsField(v string) bool {
- _, exists := FieldsUsage[v]
- return exists
-}
diff --git a/runner/internal/config/field_test.go b/runner/internal/config/field_test.go
deleted file mode 100644
index ad53a23..0000000
--- a/runner/internal/config/field_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package config_test
-
-import (
- "testing"
-
- "github.com/drykit-go/testx"
-
- "github.com/benchttp/engine/runner/internal/config"
-)
-
-func TestIsField(t *testing.T) {
- testx.Table(config.IsField).Cases([]testx.Case{
- {In: config.FieldMethod, Exp: true},
- {In: config.FieldURL, Exp: true},
- {In: config.FieldHeader, Exp: true},
- {In: config.FieldBody, Exp: true},
- {In: config.FieldRequests, Exp: true},
- {In: config.FieldConcurrency, Exp: true},
- {In: config.FieldInterval, Exp: true},
- {In: config.FieldRequestTimeout, Exp: true},
- {In: config.FieldGlobalTimeout, Exp: true},
- {In: "notafield", Exp: false},
- }).Run(t)
-}
diff --git a/runner/runner.go b/runner/runner.go
deleted file mode 100644
index a68db93..0000000
--- a/runner/runner.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package runner
-
-import (
- "context"
- "time"
-
- "github.com/benchttp/engine/runner/internal/config"
- "github.com/benchttp/engine/runner/internal/metrics"
- "github.com/benchttp/engine/runner/internal/recorder"
- "github.com/benchttp/engine/runner/internal/report"
- "github.com/benchttp/engine/runner/internal/tests"
-)
-
-type (
- Config = config.Global
- RequestConfig = config.Request
- RequestBody = config.RequestBody
- RecorderConfig = config.Runner
- InvalidConfigError = config.InvalidConfigError
-
- RecordingProgress = recorder.Progress
- RecordingStatus = recorder.Status
-
- Report = report.Report
-
- MetricsAggregate = metrics.Aggregate
- MetricsField = metrics.Field
- MetricsValue = metrics.Value
- MetricsTimeStats = metrics.TimeStats
-
- TestCase = tests.Case
- TestPredicate = tests.Predicate
- TestSuiteResults = tests.SuiteResult
- TestCaseResult = tests.CaseResult
-
- ReportMetadata = report.Metadata
-)
-
-const (
- StatusRunning = recorder.StatusRunning
- StatusCanceled = recorder.StatusCanceled
- StatusTimeout = recorder.StatusTimeout
- StatusDone = recorder.StatusDone
-
- ConfigFieldMethod = config.FieldMethod
- ConfigFieldURL = config.FieldURL
- ConfigFieldHeader = config.FieldHeader
- ConfigFieldBody = config.FieldBody
- ConfigFieldRequests = config.FieldRequests
- ConfigFieldConcurrency = config.FieldConcurrency
- ConfigFieldInterval = config.FieldInterval
- ConfigFieldRequestTimeout = config.FieldRequestTimeout
- ConfigFieldGlobalTimeout = config.FieldGlobalTimeout
- ConfigFieldTests = config.FieldTests
-)
-
-var (
- DefaultConfig = config.Default
- ConfigFieldsUsage = config.FieldsUsage
- NewRequestBody = config.NewRequestBody
- IsConfigField = config.IsField
-
- ErrCanceled = recorder.ErrCanceled
-)
-
-type Runner struct {
- recorder *recorder.Recorder
- onRecordingProgress func(RecordingProgress)
-}
-
-func New(onRecordingProgress func(RecordingProgress)) *Runner {
- return &Runner{onRecordingProgress: onRecordingProgress}
-}
-
-func (r *Runner) Run(ctx context.Context, cfg config.Global) (*Report, error) {
- // Validate input config
- if err := cfg.Validate(); err != nil {
- return nil, err
- }
-
- // Generate http request from input config
- rq, err := cfg.Request.Value()
- if err != nil {
- return nil, err
- }
-
- // Create and attach request recorder
- r.recorder = recorder.New(recorderConfig(cfg, r.onRecordingProgress))
-
- startTime := time.Now()
-
- // Run request recorder
- records, err := r.recorder.Record(ctx, rq)
- if err != nil {
- return nil, err
- }
-
- duration := time.Since(startTime)
-
- agg := metrics.NewAggregate(records)
-
- testResults := tests.Run(agg, cfg.Tests)
-
- return report.New(cfg, duration, agg, testResults), nil
-}
-
-// Progress returns the current progress of the recording.
-// r.Run must have been called before, otherwise it returns
-// a zero RecorderProgress.
-func (r *Runner) Progress() RecordingProgress {
- if r.recorder == nil {
- return RecordingProgress{}
- }
- return r.recorder.Progress()
-}
-
-// recorderConfig returns a runner.RequesterConfig generated from cfg.
-func recorderConfig(
- cfg config.Global,
- onRecordingProgress func(recorder.Progress),
-) recorder.Config {
- return recorder.Config{
- Requests: cfg.Runner.Requests,
- Concurrency: cfg.Runner.Concurrency,
- Interval: cfg.Runner.Interval,
- RequestTimeout: cfg.Runner.RequestTimeout,
- GlobalTimeout: cfg.Runner.GlobalTimeout,
- OnProgress: onRecordingProgress,
- }
-}
diff --git a/script/coverage b/script/coverage
new file mode 100755
index 0000000..677aed3
--- /dev/null
+++ b/script/coverage
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+go-acc ./... -- -race -timeout=10s
diff --git a/script/doc b/script/doc
new file mode 100755
index 0000000..715e407
--- /dev/null
+++ b/script/doc
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+domain="localhost"
+port="9995"
+addr="${domain}:${port}"
+
+main() {
+ echo "http://${addr}/pkg/github.com/benchttp/sdk/"
+ godoc -http="${addr}"
+}
+main
diff --git a/script/lint b/script/lint
new file mode 100755
index 0000000..3d13e8d
--- /dev/null
+++ b/script/lint
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+golangci-lint run
diff --git a/script/test b/script/test
new file mode 100755
index 0000000..5ae7c1c
--- /dev/null
+++ b/script/test
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+timeout="10s"
+
+main() {
+ go test -race -timeout "${timeout}" "${@:1}" ./...
+}
+main "${@}"