From f9e3c2f4f1e88080eb76a010ee07c78dfa2057c8 Mon Sep 17 00:00:00 2001 From: Peter Kurfer Date: Thu, 9 Jun 2022 22:12:45 +0200 Subject: [PATCH] feat(protocols): add basic HTTP checks --- .golangci.yml | 11 ++- build.cue | 31 ++++++ check/api.go | 1 + go.mod | 1 + go.sum | 2 + main.go | 2 +- protocols/http/checks.go | 27 ++++++ protocols/http/checks_test.go | 144 ++++++++++++++++++++++++++++ protocols/http/get.go | 85 ++++++++++++++++ protocols/http/json_validation.go | 57 +++++++++++ protocols/http/status_validation.go | 34 +++++++ protocols/http/validation.go | 19 ++++ protocols/redis/checks_test.go | 3 - protocols/redis/get.go | 5 +- protocols/redis/ping.go | 11 ++- protocols/redis/validation.go | 71 +++----------- validation/comparator.go | 27 ++++-- validation/jsonpath.go | 6 +- validation/jsonpath_test.go | 21 ++-- validation/jsonval.go | 2 +- validation/jsonval_test.go | 53 +++++----- validation/registry.go | 75 +++++++++++++++ 22 files changed, 568 insertions(+), 120 deletions(-) create mode 100644 build.cue create mode 100644 protocols/http/checks.go create mode 100644 protocols/http/checks_test.go create mode 100644 protocols/http/get.go create mode 100644 protocols/http/json_validation.go create mode 100644 protocols/http/status_validation.go create mode 100644 protocols/http/validation.go create mode 100644 validation/registry.go diff --git a/.golangci.yml b/.golangci.yml index 3d6972a..b130c49 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -78,7 +78,7 @@ linters: - funlen - gocognit - goconst - # - gocritic + - gocritic - gocyclo - godox - gofumpt @@ -86,12 +86,12 @@ linters: - gomoddirectives - gomnd - gosec - - gosimple + # - gosimple - govet - ifshort - importas - ineffassign - # - ireturn - enable later + - ireturn - lll - misspell - nakedret @@ -99,13 +99,14 @@ linters: - nilnil - noctx - nolintlint + - nosprintfhostport - paralleltest - prealloc - predeclared - promlinter - - staticcheck + # - staticcheck - structcheck - - stylecheck + # - stylecheck - tenv - testpackage - thelper diff --git a/build.cue b/build.cue new file mode 100644 index 0000000..c3ff16c --- /dev/null +++ b/build.cue @@ -0,0 +1,31 @@ +package nurse + +import ( + "dagger.io/dagger" + "universe.dagger.io/go" +) + +dagger.#Plan & { + client: { + filesystem: { + "./": read: { + contents: dagger.#FS + exclude: [ + "README.md", + "build.cue", + ] + } + } + network: "unix:///var/run/docker.sock": connect: dagger.#Socket // Docker daemon socket + } + actions: { + test: go.#Test & { + package: "./..." + source: client.filesystem."./".read.contents + mounts: docker: { + dest: "/var/run/docker.sock" + contents: client.network."unix:///var/run/docker.sock".connect + } + } + } +} diff --git a/check/api.go b/check/api.go index bcb5d25..3d712a6 100644 --- a/check/api.go +++ b/check/api.go @@ -11,6 +11,7 @@ import ( var ( ErrNoSuchCheck = errors.New("no such check") ErrConflictingCheck = errors.New("check with same name already registered") + ErrNoSuchValidator = errors.New("no such validator") ) type ( diff --git a/go.mod b/go.mod index cf681cf..b2307bf 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/opencontainers/runc v1.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sirupsen/logrus v1.8.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect go.opencensus.io v0.23.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect diff --git a/go.sum b/go.sum index fd19ac9..12ff49e 100644 --- a/go.sum +++ b/go.sum @@ -707,6 +707,8 @@ github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= diff --git a/main.go b/main.go index 6e4d630..839bfb0 100644 --- a/main.go +++ b/main.go @@ -43,7 +43,7 @@ func main() { logger.Debug("Loaded config", zap.Any("config", nurseInstance)) chkRegistry := check.NewRegistry() - if err := chkRegistry.Register(redis.Module()); err != nil { + if err = chkRegistry.Register(redis.Module()); err != nil { logger.Fatal("Failed to register Redis module", zap.Error(err)) } diff --git a/protocols/http/checks.go b/protocols/http/checks.go new file mode 100644 index 0000000..d306317 --- /dev/null +++ b/protocols/http/checks.go @@ -0,0 +1,27 @@ +package http + +import ( + "net/http" + + "github.com/baez90/nurse/check" +) + +func Module() *check.Module { + m, _ := check.NewModule( + "http", + check.WithCheck("get", check.FactoryFunc(func() check.SystemChecker { + return &GenericCheck{Method: http.MethodGet} + })), + check.WithCheck("post", check.FactoryFunc(func() check.SystemChecker { + return &GenericCheck{Method: http.MethodPost} + })), + check.WithCheck("put", check.FactoryFunc(func() check.SystemChecker { + return &GenericCheck{Method: http.MethodPut} + })), + check.WithCheck("delete", check.FactoryFunc(func() check.SystemChecker { + return &GenericCheck{Method: http.MethodDelete} + })), + ) + + return m +} diff --git a/protocols/http/checks_test.go b/protocols/http/checks_test.go new file mode 100644 index 0000000..73f511e --- /dev/null +++ b/protocols/http/checks_test.go @@ -0,0 +1,144 @@ +package http_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/maxatome/go-testdeep/td" + + "github.com/baez90/nurse/grammar" + httpcheck "github.com/baez90/nurse/protocols/http" +) + +func TestChecks_Execute(t *testing.T) { + t.Parallel() + + httpModule := httpcheck.Module() + + type serverResponse struct { + status int + body io.Reader + err error + } + + tests := []struct { + name string + check string + resp serverResponse + wantErr bool + }{ + { + name: "GET check without validation", + check: `http.GET("%s/api/books")`, + resp: serverResponse{ + status: 200, + }, + wantErr: false, + }, + { + name: "GET check - status validation", + check: `http.GET("%s/api/books") => Status(200)`, + resp: serverResponse{ + status: 200, + }, + wantErr: false, + }, + { + name: "GET check - JSON path validation", + check: `http.GET("%s/api/books") => JSONPath("$.firstName", "Homer")`, + resp: serverResponse{ + status: 200, + body: strings.NewReader(`{"firstName": "Homer"}`), + }, + wantErr: false, + }, + { + name: "GET check - Status and JSON path validation", + check: `http.GET("%s/api/books") => Status(200) -> JSONPath("$.firstName", "Homer")`, + resp: serverResponse{ + status: 200, + body: strings.NewReader(`{"firstName": "Homer"}`), + }, + wantErr: false, + }, + { + name: "POST check without validation", + check: `http.POST("%s/api/books")`, + resp: serverResponse{ + status: 204, + }, + wantErr: false, + }, + { + name: "POST check - Status validation", + check: `http.POST("%s/api/books") => Status(204)`, + resp: serverResponse{ + status: 204, + }, + wantErr: false, + }, + { + name: "PUT check without validation", + check: `http.PUT("%s/api/books/1")`, + resp: serverResponse{ + status: 200, + }, + wantErr: false, + }, + { + name: "DELETE check without validation", + check: `http.DELETE("%s/api/books/1")`, + resp: serverResponse{ + status: 200, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if tt.resp.err != nil { + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte(tt.resp.err.Error())) + return + } + + writer.WriteHeader(tt.resp.status) + if tt.resp.body != nil { + _, _ = io.Copy(writer, tt.resp.body) + } + })) + + t.Cleanup(testServer.Close) + + parser, err := grammar.NewParser[grammar.Check]() + td.CmpNoError(t, err, "grammar.NewParser()") + parsedCheck, err := parser.Parse(fmt.Sprintf(tt.check, testServer.URL)) + td.CmpNoError(t, err, "parser.Parse()") + + chk, err := httpModule.Lookup(*parsedCheck, nil) + td.CmpNoError(t, err, "http.LookupCheck()") + + if clientInjectable, ok := chk.(httpcheck.ClientInjectable); !ok { + t.Fatal("Failed to inject client to check") + } else { + clientInjectable.SetClient(testServer.Client()) + } + + if tt.wantErr { + td.CmpError(t, chk.Execute(context.Background())) + } else { + td.CmpNoError(t, chk.Execute(context.Background())) + } + }) + } +} diff --git a/protocols/http/get.go b/protocols/http/get.go new file mode 100644 index 0000000..9c5030e --- /dev/null +++ b/protocols/http/get.go @@ -0,0 +1,85 @@ +package http + +import ( + "bytes" + "context" + "io" + "net/http" + + "github.com/baez90/nurse/check" + "github.com/baez90/nurse/config" + "github.com/baez90/nurse/grammar" + "github.com/baez90/nurse/validation" +) + +type ClientInjectable interface { + SetClient(client *http.Client) +} + +var ( + _ check.SystemChecker = (*GenericCheck)(nil) + _ ClientInjectable = (*GenericCheck)(nil) +) + +type GenericCheck struct { + *http.Client + validators validation.Validator[*http.Response] + Method string + Body []byte + URL string +} + +func (g *GenericCheck) SetClient(client *http.Client) { + if client == nil { + return + } + + g.Client = client +} + +func (g *GenericCheck) Execute(ctx context.Context) error { + var body io.Reader + if len(g.Body) > 0 { + body = bytes.NewReader(g.Body) + } + req, err := http.NewRequestWithContext(ctx, g.Method, g.URL, body) + if err != nil { + return err + } + + resp, err := g.Do(req) + if err != nil { + return err + } + + defer func() { + _ = resp.Body.Close() + }() + + return g.validators.Validate(resp) +} + +func (g *GenericCheck) UnmarshalCheck(c grammar.Check, _ config.ServerLookup) error { + const urlArgsNumber = 1 + + inst := c.Initiator + + if err := grammar.ValidateParameterCount(inst.Params, urlArgsNumber); err != nil { + return err + } + + if g.Client == nil { + g.Client = http.DefaultClient + } + + var err error + if g.URL, err = inst.Params[0].AsString(); err != nil { + return err + } + + if g.validators, err = registry.ValidatorsForFilters(c.Validators); err != nil { + return err + } + + return nil +} diff --git a/protocols/http/json_validation.go b/protocols/http/json_validation.go new file mode 100644 index 0000000..044b609 --- /dev/null +++ b/protocols/http/json_validation.go @@ -0,0 +1,57 @@ +package http + +import ( + "errors" + "io" + "net/http" + + "github.com/valyala/bytebufferpool" + + "github.com/baez90/nurse/grammar" + "github.com/baez90/nurse/validation" +) + +var _ validation.FromCall[*http.Response] = (*JSONPathValidator)(nil) + +type JSONPathValidator struct { + validator *validation.JSONPathValidator +} + +func (j *JSONPathValidator) UnmarshalCall(c grammar.Call) (err error) { + if err = grammar.ValidateParameterCount(c.Params, 2); err != nil { + return err + } + + var jsonPath string + + if jsonPath, err = c.Params[0].AsString(); err != nil { + return err + } + + switch c.Params[1].Type() { + case grammar.ParamTypeInt: + j.validator, err = validation.JSONPathValidatorFor(jsonPath, *c.Params[1].Int) + case grammar.ParamTypeFloat: + j.validator, err = validation.JSONPathValidatorFor(jsonPath, *c.Params[1].Float) + case grammar.ParamTypeString: + j.validator, err = validation.JSONPathValidatorFor(jsonPath, *c.Params[1].String) + case grammar.ParamTypeUnknown: + fallthrough + default: + return errors.New("param type unknown") + } + + return nil +} + +func (j *JSONPathValidator) Validate(resp *http.Response) error { + buf := bytebufferpool.Get() + defer bytebufferpool.Put(buf) + + readBytes, err := io.Copy(buf, resp.Body) + if err != nil { + return err + } + + return j.validator.Equals(buf.B[:readBytes]) +} diff --git a/protocols/http/status_validation.go b/protocols/http/status_validation.go new file mode 100644 index 0000000..fb7aa0d --- /dev/null +++ b/protocols/http/status_validation.go @@ -0,0 +1,34 @@ +package http + +import ( + "fmt" + "net/http" + + "github.com/baez90/nurse/grammar" + "github.com/baez90/nurse/validation" +) + +var _ validation.FromCall[*http.Response] = (*StatusValidator)(nil) + +type StatusValidator struct { + Want int +} + +func (s *StatusValidator) Validate(resp *http.Response) error { + if resp.StatusCode != s.Want { + return fmt.Errorf("want HTTP status %d but got %d", s.Want, resp.StatusCode) + } + + return nil +} + +func (s *StatusValidator) UnmarshalCall(c grammar.Call) error { + if err := grammar.ValidateParameterCount(c.Params, 1); err != nil { + return err + } + + var err error + s.Want, err = c.Params[0].AsInt() + + return err +} diff --git a/protocols/http/validation.go b/protocols/http/validation.go new file mode 100644 index 0000000..d5e0b95 --- /dev/null +++ b/protocols/http/validation.go @@ -0,0 +1,19 @@ +package http + +import ( + "net/http" + + "github.com/baez90/nurse/validation" +) + +var registry = validation.NewRegistry[*http.Response]() + +func init() { + registry.Register("jsonpath", func() validation.FromCall[*http.Response] { + return new(JSONPathValidator) + }) + + registry.Register("status", func() validation.FromCall[*http.Response] { + return new(StatusValidator) + }) +} diff --git a/protocols/redis/checks_test.go b/protocols/redis/checks_test.go index ff703fa..3376b71 100644 --- a/protocols/redis/checks_test.go +++ b/protocols/redis/checks_test.go @@ -90,9 +90,6 @@ func TestChecks_Execute(t *testing.T) { chk, err := redisModule.Lookup(*parsedCheck, register) td.CmpNoError(t, err, "redis.LookupCheck()") - td.CmpNoError(t, chk.UnmarshalCheck(*parsedCheck, register), "get.UnmarshalCheck()") - td.CmpNoError(t, chk.Execute(context.Background())) - if tt.wantErr { td.CmpError(t, chk.Execute(context.Background())) } else { diff --git a/protocols/redis/get.go b/protocols/redis/get.go index 4741a1c..08e91fd 100644 --- a/protocols/redis/get.go +++ b/protocols/redis/get.go @@ -8,13 +8,14 @@ import ( "github.com/baez90/nurse/check" "github.com/baez90/nurse/config" "github.com/baez90/nurse/grammar" + "github.com/baez90/nurse/validation" ) var _ check.SystemChecker = (*GetCheck)(nil) type GetCheck struct { redis.UniversalClient - validators ValidationChain + validators validation.Validator[redis.Cmder] Key string } @@ -44,7 +45,7 @@ func (g *GetCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup) e return err } - if g.validators, err = ValidatorsForFilters(c.Validators); err != nil { + if g.validators, err = registry.ValidatorsForFilters(c.Validators); err != nil { return err } diff --git a/protocols/redis/ping.go b/protocols/redis/ping.go index 4cccd46..c96b7a1 100644 --- a/protocols/redis/ping.go +++ b/protocols/redis/ping.go @@ -9,13 +9,14 @@ import ( "github.com/baez90/nurse/check" "github.com/baez90/nurse/config" "github.com/baez90/nurse/grammar" + "github.com/baez90/nurse/validation" ) var _ check.SystemChecker = (*PingCheck)(nil) type PingCheck struct { redis.UniversalClient - validators ValidationChain + validators validation.Validator[redis.Cmder] Message string } @@ -39,7 +40,11 @@ func (p *PingCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup) ) val, _ := GenericCommandValidatorFor("PONG") - p.validators = append(p.validators, val) + + validators := validation.Chain[redis.Cmder]{} + validators = append(validators, val) + + p.validators = validators init := c.Initiator switch len(init.Params) { @@ -50,7 +55,7 @@ func (p *PingCheck) UnmarshalCheck(c grammar.Check, lookup config.ServerLookup) return err } else { val, _ := GenericCommandValidatorFor(msg) - p.validators = ValidationChain{val} + p.validators = validation.Chain[redis.Cmder]{val} } fallthrough case serverOnlyArgCount: diff --git a/protocols/redis/validation.go b/protocols/redis/validation.go index dc06ca9..edb7e34 100644 --- a/protocols/redis/validation.go +++ b/protocols/redis/validation.go @@ -2,74 +2,27 @@ package redis import ( "errors" - "fmt" - "strings" "github.com/go-redis/redis/v8" - "github.com/baez90/nurse/check" "github.com/baez90/nurse/grammar" "github.com/baez90/nurse/validation" ) var ( - ErrNoSuchValidator = errors.New("no such validator") - - _ CmdValidator = (ValidationChain)(nil) _ CmdValidator = (*GenericCmdValidator)(nil) - knownValidators = map[string]func() unmarshallableCmdValidator{ - "equals": func() unmarshallableCmdValidator { - return new(GenericCmdValidator) - }, - } + registry = validation.NewRegistry[redis.Cmder]() ) -type ( - CmdValidator interface { - Validate(cmder redis.Cmder) error - } - unmarshallableCmdValidator interface { - CmdValidator - check.CallUnmarshaler - } -) - -func ValidatorsForFilters(filters *grammar.Filters) (ValidationChain, error) { - if filters == nil || filters.Chain == nil { - return ValidationChain{}, nil - } - chain := make(ValidationChain, 0, len(filters.Chain)) - for i := range filters.Chain { - validationCall := filters.Chain[i] - if validatorProvider, ok := knownValidators[strings.ToLower(validationCall.Name)]; !ok { - return nil, fmt.Errorf("%w: %s", ErrNoSuchValidator, validationCall.Name) - } else { - validator := validatorProvider() - if err := validator.UnmarshalCall(validationCall); err != nil { - return nil, err - } - chain = append(chain, validator) - } - } - - return chain, nil +func init() { + registry.Register("equals", func() validation.FromCall[redis.Cmder] { + return new(GenericCmdValidator) + }) } -type ValidationChain []CmdValidator - -func (v ValidationChain) UnmarshalCall(grammar.Call) error { - return errors.New("cannot unmarshal chain") -} - -func (v ValidationChain) Validate(cmder redis.Cmder) error { - for i := range v { - if err := v[i].Validate(cmder); err != nil { - return err - } - } - - return nil +type CmdValidator interface { + Validate(cmder redis.Cmder) error } func GenericCommandValidatorFor[T validation.Value](want T) (*GenericCmdValidator, error) { @@ -92,7 +45,6 @@ func (g *GenericCmdValidator) UnmarshalCall(c grammar.Call) error { } var err error - switch c.Params[0].Type() { case grammar.ParamTypeInt: if g.comparator, err = validation.JSONValueComparatorFor(*c.Params[0].Int); err != nil { @@ -107,13 +59,13 @@ func (g *GenericCmdValidator) UnmarshalCall(c grammar.Call) error { return err } default: - return fmt.Errorf("param type is unkown") + return errors.New("param type is unknown") } return nil } -func (g GenericCmdValidator) Validate(cmder redis.Cmder) error { +func (g *GenericCmdValidator) Validate(cmder redis.Cmder) error { if err := cmder.Err(); err != nil { return err } @@ -124,9 +76,8 @@ func (g GenericCmdValidator) Validate(cmder redis.Cmder) error { if err != nil { return err } - if !g.comparator.Equals(res) { - return fmt.Errorf("got %s - but didn't match expected value", res) - } + + return g.comparator.Equals(res) } return nil diff --git a/validation/comparator.go b/validation/comparator.go index d426d74..ff12de3 100644 --- a/validation/comparator.go +++ b/validation/comparator.go @@ -1,6 +1,9 @@ package validation -import "math" +import ( + "fmt" + "math" +) const equalityThreshold = 0.00000001 @@ -10,7 +13,7 @@ var ( ) type ValueComparator interface { - Equals(got any) bool + Equals(got any) error } type GenericComparator[T int | string] struct { @@ -18,22 +21,30 @@ type GenericComparator[T int | string] struct { Parser func(got any) (T, error) } -func (g GenericComparator[T]) Equals(got any) bool { +func (g GenericComparator[T]) Equals(got any) error { parsed, err := g.Parser(got) if err != nil { - return false + return err } - return parsed == g.Want + if parsed != g.Want { + return fmt.Errorf("want %v but got %v", g.Want, parsed) + } + + return nil } type FloatComparator float64 -func (f FloatComparator) Equals(got any) bool { +func (f FloatComparator) Equals(got any) error { val, err := ParseJSONFloat(got) if err != nil { - return false + return err } - return math.Abs(float64(f)-val) < equalityThreshold + if math.Abs(float64(f)-val) > equalityThreshold { + return fmt.Errorf("want %f but got %f", float64(f), val) + } + + return nil } diff --git a/validation/jsonpath.go b/validation/jsonpath.go index 08a88f5..800b992 100644 --- a/validation/jsonpath.go +++ b/validation/jsonpath.go @@ -26,14 +26,14 @@ type JSONPathValidator struct { Comparator ValueComparator } -func (j JSONPathValidator) Equals(got any) bool { +func (j JSONPathValidator) Equals(got any) error { parsed, err := parse(got) if err != nil { - return false + return err } val, err := jsonpath.Get(j.Path, parsed) if err != nil { - return false + return err } return j.Comparator.Equals(val) diff --git a/validation/jsonpath_test.go b/validation/jsonpath_test.go index 19b2962..7f7e106 100644 --- a/validation/jsonpath_test.go +++ b/validation/jsonpath_test.go @@ -11,13 +11,14 @@ type jsonPathValidator_EqualsTestCase[V validation.Value] struct { expected V jsonPath string json string - want bool + wantErr bool } func (tt jsonPathValidator_EqualsTestCase[V]) name() string { return tt.testName } +//nolint:thelper // is not a helper func (tt jsonPathValidator_EqualsTestCase[V]) run(t *testing.T) { t.Parallel() t.Helper() @@ -26,8 +27,10 @@ func (tt jsonPathValidator_EqualsTestCase[V]) run(t *testing.T) { t.Fatalf("JSONPathValidatorFor() err = %v", err) } - if validator.Equals(tt.json) != tt.want { - t.Errorf("Failed to equal value in %s to %v", tt.json, tt.expected) + if err := validator.Equals(tt.json); err != nil { + if !tt.wantErr { + t.Errorf("Failed to equal value in %s to %v: %v", tt.json, tt.expected, err) + } } } @@ -39,42 +42,42 @@ func TestJSONPathValidator_Equals(t *testing.T) { expected: "hello", jsonPath: "$.greeting", json: `{"greeting": "hello"}`, - want: true, + wantErr: false, }, jsonPathValidator_EqualsTestCase[string]{ testName: "Simple object navigation - number as string", expected: "42", jsonPath: "$.number", json: `{"number": 42}`, - want: true, + wantErr: false, }, jsonPathValidator_EqualsTestCase[string]{ testName: "Simple array navigation", expected: "world", jsonPath: "$[1]", json: `["hello", "world"]`, - want: true, + wantErr: false, }, jsonPathValidator_EqualsTestCase[int]{ testName: "Simple array navigation - string to int", expected: 37, jsonPath: "$[1]", json: `["13", "37"]`, - want: true, + wantErr: false, }, jsonPathValidator_EqualsTestCase[int]{ testName: "Simple array navigation - string to int - wrong value", expected: 42, jsonPath: "$[1]", json: `["13", "37"]`, - want: false, + wantErr: true, }, jsonPathValidator_EqualsTestCase[string]{ testName: "Simple array navigation - int to string", expected: "37", jsonPath: "$[1]", json: `[13, 37]`, - want: true, + wantErr: false, }, } //nolint:paralleltest diff --git a/validation/jsonval.go b/validation/jsonval.go index e3595e9..56d5635 100644 --- a/validation/jsonval.go +++ b/validation/jsonval.go @@ -47,6 +47,6 @@ type JSONValueComparator struct { Comparator ValueComparator } -func (j JSONValueComparator) Equals(got any) bool { +func (j JSONValueComparator) Equals(got any) error { return j.Comparator.Equals(got) } diff --git a/validation/jsonval_test.go b/validation/jsonval_test.go index 5872cfb..2c0902c 100644 --- a/validation/jsonval_test.go +++ b/validation/jsonval_test.go @@ -15,9 +15,10 @@ type jsonValueComparator_EqualsTestCase[V validation.Value] struct { testName string expected V got any - want bool + wantErr bool } +//nolint:thelper // is not a helper func (tt jsonValueComparator_EqualsTestCase[V]) run(t *testing.T) { t.Parallel() t.Helper() @@ -26,8 +27,10 @@ func (tt jsonValueComparator_EqualsTestCase[V]) run(t *testing.T) { t.Fatalf("validation.JSONValueComparatorFor() err = %v", err) } - if got := comparator.Equals(tt.got); got != tt.want { - t.Errorf("Equals() = %v, want %v", got, tt.want) + if err := comparator.Equals(tt.got); err != nil { + if !tt.wantErr { + t.Errorf("Equals() = %v, want %v", err, tt.wantErr) + } } } @@ -42,133 +45,133 @@ func TestJSONValueComparator_Equals(t *testing.T) { testName: "Test int equality", expected: 42, got: 42, - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[int]{ testName: "Test int equality - wrong value", expected: 42, got: 43, - want: false, + wantErr: true, }, jsonValueComparator_EqualsTestCase[int]{ testName: "Test int equality - string value", expected: 42, got: "42", - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[int]{ testName: "Test int equality - []byte value", expected: 42, got: []byte("42"), - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[int]{ testName: "Test int equality - float value", expected: 42, got: 42.0, - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[int8]{ testName: "Test int8 equality", expected: 42, got: 42, - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[int8]{ testName: "Test int8 equality - wrong value", expected: 42, got: 43, - want: false, + wantErr: true, }, jsonValueComparator_EqualsTestCase[int8]{ testName: "Test int8 equality - int16 value", expected: 42, got: int16(42), - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[int8]{ testName: "Test int8 equality - uint16 value", expected: 42, got: uint16(42), - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[float32]{ testName: "Test float32 equality - float value", expected: 42.0, got: 42.0, - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[float32]{ testName: "Test float32 equality - float value", expected: 42.0, got: float64(42.0), - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[float64]{ testName: "Test float64 equality - float value", expected: 42.0, got: 42.0, - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[float64]{ testName: "Test float64 equality - int value", expected: 42.0, got: 42, - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[float64]{ testName: "Test float64 equality - []byte value", expected: 42.0, got: []byte("42"), - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[float64]{ testName: "Test float64 equality - float32 value", expected: 42.0, got: float32(42.0), - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[float64]{ testName: "Test float64 equality - string value", expected: 42.0, got: "42.0", - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[float64]{ testName: "Test float64 equality - string value without dot", expected: 42.0, got: "42", - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[string]{ testName: "Test string equality", expected: "hello", got: "hello", - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[string]{ testName: "Test string equality - []byte value", expected: "hello", got: []byte("hello"), - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[string]{ testName: "Test string equality - int value", expected: "1337", got: 1337, - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[string]{ testName: "Test string equality - float value", expected: "13.37", got: 13.37, - want: true, + wantErr: false, }, jsonValueComparator_EqualsTestCase[string]{ testName: "Test string equality - wrong case", expected: "hello", got: "HELLO", - want: false, + wantErr: true, }, } diff --git a/validation/registry.go b/validation/registry.go new file mode 100644 index 0000000..c078adf --- /dev/null +++ b/validation/registry.go @@ -0,0 +1,75 @@ +package validation + +import ( + "fmt" + "strings" + "sync" + + "github.com/baez90/nurse/check" + "github.com/baez90/nurse/grammar" +) + +type ( + Validator[T any] interface { + Validate(in T) error + } + + FromCall[T any] interface { + Validator[T] + check.CallUnmarshaler + } + + Chain[T any] []Validator[T] +) + +func (c Chain[T]) Validate(in T) error { + for i := range c { + if err := c[i].Validate(in); err != nil { + return err + } + } + + return nil +} + +func NewRegistry[R any]() *Registry[R] { + return &Registry[R]{ + validators: make(map[string]func() FromCall[R]), + } +} + +type Registry[R any] struct { + lock sync.Mutex + validators map[string]func() FromCall[R] +} + +func (r *Registry[R]) Register(name string, factory func() FromCall[R]) { + r.lock.Lock() + defer r.lock.Unlock() + + r.validators[strings.ToLower(name)] = factory +} + +func (r *Registry[R]) ValidatorsForFilters(filters *grammar.Filters) (Chain[R], error) { + r.lock.Lock() + defer r.lock.Unlock() + + if filters == nil || filters.Chain == nil { + return Chain[R]{}, nil + } + chain := make(Chain[R], 0, len(filters.Chain)) + for i := range filters.Chain { + validationCall := filters.Chain[i] + if validatorProvider, ok := r.validators[strings.ToLower(validationCall.Name)]; !ok { + return nil, fmt.Errorf("%w: %s", check.ErrNoSuchValidator, validationCall.Name) + } else { + validator := validatorProvider() + if err := validator.UnmarshalCall(validationCall); err != nil { + return nil, err + } + chain = append(chain, validator) + } + } + + return chain, nil +}