diff --git a/configio/builder.go b/configio/builder.go new file mode 100644 index 0000000..400ba95 --- /dev/null +++ b/configio/builder.go @@ -0,0 +1,187 @@ +package configio + +import ( + "io" + "net/http" + "net/url" + "time" + + "github.com/benchttp/sdk/benchttp" +) + +// 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) +} + +// DecodeJSON 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) DecodeJSON(in []byte) error { + return b.decode(in, FormatJSON) +} + +// DecodeYAML 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) DecodeYAML(in []byte) error { + return b.decode(in, FormatYAML) +} + +func (b *Builder) decode(in []byte, format Format) error { + repr := representation{} + 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.parseAndMutate(dst); err != nil { + panicInternal("Builder.decode", "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..4665d0d --- /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.DecodeJSON(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.DecodeYAML(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 index 0014f70..41ca421 100644 --- a/configio/decoder.go +++ b/configio/decoder.go @@ -7,11 +7,6 @@ import ( "github.com/benchttp/sdk/benchttp" ) -type Decoder interface { - Decode(dst *Representation) error - DecodeRunner(dst *benchttp.Runner) error -} - type Format string const ( @@ -19,9 +14,22 @@ const ( 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 *representation) error +} + +func decoderOf(format Format, in []byte) decoder { r := bytes.NewReader(in) switch format { case FormatYAML: diff --git a/configio/example_test.go b/configio/example_test.go new file mode 100644 index 0000000..e9f9f61 --- /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.DecodeJSON(jsonConfig) + _ = b.DecodeYAML(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 index f0aff7d..8a73d1e 100644 --- a/configio/file.go +++ b/configio/file.go @@ -49,7 +49,7 @@ func UnmarshalFile(filename string, dst *benchttp.Runner) error { type file struct { prev *file path string - repr Representation + repr representation } // decodeAll reads f.path as a file and decodes it into f.repr. @@ -88,7 +88,7 @@ func (f *file) decode() (err error) { return err } - if err := DecoderOf(ext, b).Decode(&f.repr); err != nil { + if err := decoderOf(ext, b).decodeRepr(&f.repr); err != nil { return errorutil.WithDetails(ErrFileParse, f.path, err) } @@ -122,7 +122,7 @@ func (f file) seen(p string) bool { // reprs returns a slice of Representation, starting with the receiver // and ending with the last child. func (f file) reprs() representations { - reprs := []Representation{f.repr} + reprs := []representation{f.repr} if f.prev != nil { reprs = append(reprs, f.prev.reprs()...) } diff --git a/configio/json.go b/configio/json.go index debd94e..9d83a11 100644 --- a/configio/json.go +++ b/configio/json.go @@ -11,45 +11,40 @@ import ( "github.com/benchttp/sdk/benchttp" ) -// UnmarshalJSON parses the JSON-encoded data and stores the result -// in the Representation pointed to by dst. -func UnmarshalJSON(in []byte, dst *Representation) error { - dec := NewJSONDecoder(bytes.NewReader(in)) - return dec.Decode(dst) -} +// JSONDecoder implements Decoder +type JSONDecoder struct{ r io.Reader } -// UnmarshalJSONRunner parses the JSON-encoded data and stores the result +var _ decoder = (*JSONDecoder)(nil) + +// UnmarshalJSON parses the JSON-encoded data and stores the result // in the benchttp.Runner pointed to by dst. -func UnmarshalJSONRunner(in []byte, dst *benchttp.Runner) error { +func UnmarshalJSON(in []byte, dst *benchttp.Runner) error { dec := NewJSONDecoder(bytes.NewReader(in)) - return dec.DecodeRunner(dst) + return dec.Decode(dst) } -// JSONDecoder implements Decoder -type JSONDecoder struct{ r io.Reader } - 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 := representation{} + if err := d.decodeRepr(&repr); err != nil { + return err + } + return repr.parseAndMutate(dst) +} + +// decodeRepr reads the next JSON-encoded value from its input // and stores it in the Representation pointed to by dst. -func (d JSONDecoder) Decode(dst *Representation) error { +func (d JSONDecoder) decodeRepr(dst *representation) error { decoder := json.NewDecoder(d.r) decoder.DisallowUnknownFields() return d.handleError(decoder.Decode(dst)) } -// Decode reads the next JSON-encoded value from its input -// and stores it in the benchttp.Runner pointed to by dst. -func (d JSONDecoder) DecodeRunner(dst *benchttp.Runner) error { - repr := Representation{} - if err := d.Decode(&repr); err != nil { - return err - } - return repr.Into(dst) -} - // handleError handles an error from package json, // transforms it into a user-friendly standardized format // and returns the resulting error. diff --git a/configio/json_test.go b/configio/json_test.go index c04bba2..d21a98b 100644 --- a/configio/json_test.go +++ b/configio/json_test.go @@ -57,7 +57,7 @@ func TestMarshalJSON(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { gotRunner := benchttp.DefaultRunner() - gotError := configio.UnmarshalJSONRunner(tc.input, &gotRunner) + gotError := configio.UnmarshalJSON(tc.input, &gotRunner) if !tc.isValidRunner(benchttp.DefaultRunner(), gotRunner) { t.Errorf("unexpected runner:\n%+v", gotRunner) @@ -103,7 +103,7 @@ func TestJSONDecoder(t *testing.T) { runner := benchttp.Runner{} decoder := configio.NewJSONDecoder(bytes.NewReader(tc.in)) - gotErr := decoder.DecodeRunner(&runner) + gotErr := decoder.Decode(&runner) if tc.exp == "" { if gotErr != nil { diff --git a/configio/representation.go b/configio/representation.go index c668f0d..6097283 100644 --- a/configio/representation.go +++ b/configio/representation.go @@ -14,12 +14,12 @@ import ( "github.com/benchttp/sdk/internal/errorutil" ) -// Representation is a raw data model for formatted runner config (json, yaml). +// representation 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 Representation struct { +type representation struct { Extends *string `yaml:"extends" json:"extends"` Request struct { @@ -49,10 +49,14 @@ type Representation struct { } `yaml:"tests" json:"tests"` } -// Into parses the Representation receiver as a benchttp.Runner +func (repr representation) validate() error { + return repr.parseAndMutate(&benchttp.Runner{}) +} + +// parseAndMutate parses the Representation receiver as a benchttp.Runner // and stores any non-nil field value into the corresponding field // of dst. -func (repr Representation) Into(dst *benchttp.Runner) error { +func (repr representation) parseAndMutate(dst *benchttp.Runner) error { if err := repr.parseRequestInto(dst); err != nil { return err } @@ -62,7 +66,7 @@ func (repr Representation) Into(dst *benchttp.Runner) error { return repr.parseTestsInto(dst) } -func (repr Representation) parseRequestInto(dst *benchttp.Runner) error { +func (repr representation) parseRequestInto(dst *benchttp.Runner) error { if dst.Request == nil { dst.Request = &http.Request{} } @@ -99,7 +103,7 @@ func (repr Representation) parseRequestInto(dst *benchttp.Runner) error { return nil } -func (repr Representation) parseRunnerInto(dst *benchttp.Runner) error { +func (repr representation) parseRunnerInto(dst *benchttp.Runner) error { if requests := repr.Runner.Requests; requests != nil { dst.Requests = *requests } @@ -135,7 +139,7 @@ func (repr Representation) parseRunnerInto(dst *benchttp.Runner) error { return nil } -func (repr Representation) parseTestsInto(dst *benchttp.Runner) error { +func (repr representation) parseTestsInto(dst *benchttp.Runner) error { testSuite := repr.Tests if len(testSuite) == 0 { return nil @@ -250,7 +254,7 @@ func requireConfigFields(fields map[string]interface{}) error { return nil } -type representations []Representation +type representations []representation // mergeInto successively parses the given representations into dst. // @@ -261,7 +265,7 @@ func (reprs representations) mergeInto(dst *benchttp.Runner) error { } for _, repr := range reprs { - if err := repr.Into(dst); err != nil { + if err := repr.parseAndMutate(dst); err != nil { return errorutil.WithDetails(ErrFileParse, err) } } diff --git a/configio/yaml.go b/configio/yaml.go index d1130c3..9bc604b 100644 --- a/configio/yaml.go +++ b/configio/yaml.go @@ -12,45 +12,40 @@ import ( "github.com/benchttp/sdk/benchttp" ) -// UnmarshalYAML parses the YAML-encoded data and stores the result -// in the Representation pointed to by dst. -func UnmarshalYAML(in []byte, dst *Representation) error { - dec := NewYAMLDecoder(bytes.NewReader(in)) - return dec.Decode(dst) -} +// YAMLDecoder implements Decoder +type YAMLDecoder struct{ r io.Reader } -// UnmarshalYAMLRunner parses the YAML-encoded data and stores the result +var _ decoder = (*YAMLDecoder)(nil) + +// UnmarshalYAML parses the YAML-encoded data and stores the result // in the benchttp.Runner pointed to by dst. -func UnmarshalYAMLRunner(in []byte, dst *benchttp.Runner) error { +func UnmarshalYAML(in []byte, dst *benchttp.Runner) error { dec := NewYAMLDecoder(bytes.NewReader(in)) - return dec.DecodeRunner(dst) + return dec.Decode(dst) } -// YAMLDecoder implements Decoder -type YAMLDecoder struct{ r io.Reader } - 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 := representation{} + if err := d.decodeRepr(&repr); err != nil { + return err + } + return repr.parseAndMutate(dst) +} + +// decodeRepr reads the next YAML-encoded value from its input // and stores it in the Representation pointed to by dst. -func (d YAMLDecoder) Decode(dst *Representation) error { +func (d YAMLDecoder) decodeRepr(dst *representation) error { decoder := yaml.NewDecoder(d.r) decoder.KnownFields(true) return d.handleError(decoder.Decode(dst)) } -// Decode reads the next YAML-encoded value from its input -// and stores it in the benchttp.Runner pointed to by dst. -func (d YAMLDecoder) DecodeRunner(dst *benchttp.Runner) error { - repr := Representation{} - if err := d.Decode(&repr); err != nil { - return err - } - return repr.Into(dst) -} - // handleError handles a raw yaml decoder.Decode error, filters it, // and return the resulting error. func (d YAMLDecoder) handleError(err error) error { diff --git a/configio/yaml_test.go b/configio/yaml_test.go index 2f191cf..4bff966 100644 --- a/configio/yaml_test.go +++ b/configio/yaml_test.go @@ -69,7 +69,7 @@ func TestYAMLDecoder(t *testing.T) { runner := benchttp.Runner{} decoder := configio.NewYAMLDecoder(bytes.NewReader(tc.in)) - gotErr := decoder.DecodeRunner(&runner) + gotErr := decoder.Decode(&runner) if tc.expErr == nil { if gotErr != nil {