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

- + Github Worklow Status Code coverage - - Go Report Card + + Go Report Card
- + Go package Reference - + Latest version

@@ -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 "${@}"