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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"github.com/joho/godotenv"

"github.com/benchttp/engine/cmd/server/response"
"github.com/benchttp/engine/internal/configparse"
"github.com/benchttp/engine/configparse"
"github.com/benchttp/engine/runner"
)

Expand Down
10 changes: 5 additions & 5 deletions internal/configparse/json.go → configparse/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ import (

// JSON reads input bytes as JSON and unmarshals it into a runner.ConfigGlobal.
func JSON(in []byte) (runner.Config, error) {
parser := jsonParser{}
parser := JSONParser{}

var uconf UnmarshaledConfig
if err := parser.parse(in, &uconf); err != nil {
var repr Representation
if err := parser.Parse(in, &repr); err != nil {
return runner.Config{}, err
}

pconf, err := newParsedConfig(uconf)
cfg, err := ParseRepresentation(repr)
if err != nil {
return runner.Config{}, err
}

return runner.DefaultConfig().Override(pconf.config, pconf.fields...), nil
return cfg.Override(runner.DefaultConfig()), nil
}
25 changes: 11 additions & 14 deletions internal/configparse/json_test.go → configparse/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import (
"encoding/json"
"errors"
"net/url"
"reflect"
"testing"

"github.com/benchttp/engine/internal/configparse"
"github.com/benchttp/engine/configparse"
"github.com/benchttp/engine/runner"
)

Expand Down Expand Up @@ -47,26 +46,24 @@ func TestJSON(t *testing.T) {
input: baseInput.assign(object{
"runner": object{"concurrency": 3},
}).json(),
expConfig: runner.DefaultConfig().Override(
runner.Config{
Request: runner.RequestConfig{
URL: mustParseURL("https://example.com"),
},
Runner: runner.RecorderConfig{
Concurrency: 3,
},
expConfig: runner.Config{
Request: runner.RequestConfig{
URL: mustParseURL("https://example.com"),
},
"url",
"concurrency",
),
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 !reflect.DeepEqual(gotConfig, tc.expConfig) {
if !gotConfig.Equal(tc.expConfig) {
t.Errorf("unexpected config:\nexp %+v\ngot %+v", tc.expConfig, gotConfig)
}

Expand Down
88 changes: 35 additions & 53 deletions internal/configparse/parse.go → configparse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import (
"github.com/benchttp/engine/runner"
)

// UnmarshaledConfig is a raw data model for runner config files.
// 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 UnmarshaledConfig struct {
type Representation struct {
Extends *string `yaml:"extends" json:"extends"`

Request struct {
Expand All @@ -36,10 +36,6 @@ type UnmarshaledConfig struct {
GlobalTimeout *string `yaml:"globalTimeout" json:"globalTimeout"`
} `yaml:"runner" json:"runner"`

Output struct {
Silent *bool `yaml:"silent" json:"silent"`
} `yaml:"output" json:"output"`

Tests []struct {
Name *string `yaml:"name" json:"name"`
Field *string `yaml:"field" json:"field"`
Expand All @@ -48,105 +44,91 @@ type UnmarshaledConfig struct {
} `yaml:"tests" json:"tests"`
}

// parsedConfig embeds a parsed runner.ConfigGlobal and the list of its set fields.
type parsedConfig struct {
// TODO: do not embed, use field config
config runner.Config
fields []string
}

// addField adds a field to the list of set fields.
func (pconf *parsedConfig) add(field string) {
pconf.fields = append(pconf.fields, field)
}
// 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{}

// newParsedConfig parses an input raw config as a runner.ConfigGlobal and returns
// a parsedConfig or the first non-nil error occurring in the process.
func newParsedConfig(uconf UnmarshaledConfig) (parsedConfig, error) { //nolint:gocognit // acceptable complexity for a parsing func
maxFields := len(runner.ConfigFieldsUsage)
pconf := parsedConfig{fields: make([]string, 0, maxFields)}
cfg := &pconf.config
addField := func(field string) {
assignedFields = append(assignedFields, field)
}

abort := func(err error) (parsedConfig, error) {
return parsedConfig{}, err
abort := func(err error) (runner.Config, error) {
return runner.Config{}, err
}

if method := uconf.Request.Method; method != nil {
if method := repr.Request.Method; method != nil {
cfg.Request.Method = *method
pconf.add(runner.ConfigFieldMethod)
addField(runner.ConfigFieldMethod)
}

if rawURL := uconf.Request.URL; rawURL != nil {
parsedURL, err := parseAndBuildURL(*uconf.Request.URL, uconf.Request.QueryParams)
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
pconf.add(runner.ConfigFieldURL)
addField(runner.ConfigFieldURL)
}

if header := uconf.Request.Header; header != nil {
if header := repr.Request.Header; header != nil {
httpHeader := http.Header{}
for key, val := range header {
httpHeader[key] = val
}
cfg.Request.Header = httpHeader
pconf.add(runner.ConfigFieldHeader)
addField(runner.ConfigFieldHeader)
}

if body := uconf.Request.Body; body != nil {
if body := repr.Request.Body; body != nil {
cfg.Request.Body = runner.RequestBody{
Type: body.Type,
Content: []byte(body.Content),
}
pconf.add(runner.ConfigFieldBody)
addField(runner.ConfigFieldBody)
}

if requests := uconf.Runner.Requests; requests != nil {
if requests := repr.Runner.Requests; requests != nil {
cfg.Runner.Requests = *requests
pconf.add(runner.ConfigFieldRequests)
addField(runner.ConfigFieldRequests)
}

if concurrency := uconf.Runner.Concurrency; concurrency != nil {
if concurrency := repr.Runner.Concurrency; concurrency != nil {
cfg.Runner.Concurrency = *concurrency
pconf.add(runner.ConfigFieldConcurrency)
addField(runner.ConfigFieldConcurrency)
}

if interval := uconf.Runner.Interval; interval != nil {
if interval := repr.Runner.Interval; interval != nil {
parsedInterval, err := parseOptionalDuration(*interval)
if err != nil {
return abort(err)
}
cfg.Runner.Interval = parsedInterval
pconf.add(runner.ConfigFieldInterval)
addField(runner.ConfigFieldInterval)
}

if requestTimeout := uconf.Runner.RequestTimeout; requestTimeout != nil {
if requestTimeout := repr.Runner.RequestTimeout; requestTimeout != nil {
parsedTimeout, err := parseOptionalDuration(*requestTimeout)
if err != nil {
return abort(err)
}
cfg.Runner.RequestTimeout = parsedTimeout
pconf.add(runner.ConfigFieldRequestTimeout)
addField(runner.ConfigFieldRequestTimeout)
}

if globalTimeout := uconf.Runner.GlobalTimeout; globalTimeout != nil {
if globalTimeout := repr.Runner.GlobalTimeout; globalTimeout != nil {
parsedGlobalTimeout, err := parseOptionalDuration(*globalTimeout)
if err != nil {
return abort(err)
}
cfg.Runner.GlobalTimeout = parsedGlobalTimeout
pconf.add(runner.ConfigFieldGlobalTimeout)
}

if silent := uconf.Output.Silent; silent != nil {
cfg.Output.Silent = *silent
pconf.add(runner.ConfigFieldSilent)
addField(runner.ConfigFieldGlobalTimeout)
}

testSuite := uconf.Tests
testSuite := repr.Tests
if len(testSuite) == 0 {
return pconf, nil
return cfg.WithFields(assignedFields...), nil
}

cases := make([]runner.TestCase, len(testSuite))
Expand Down Expand Up @@ -187,9 +169,9 @@ func newParsedConfig(uconf UnmarshaledConfig) (parsedConfig, error) { //nolint:g
}
}
cfg.Tests = cases
pconf.add(runner.ConfigFieldTests)
addField(runner.ConfigFieldTests)

return pconf, nil
return cfg.WithFields(assignedFields...), nil
}

// helpers
Expand Down
65 changes: 65 additions & 0 deletions configparse/parser_json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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 ""
}
63 changes: 63 additions & 0 deletions configparse/parser_json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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,
)
}
})
}
})
}
Loading