├── .ci └── docker.sh ├── .dockerignore ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api ├── client │ ├── client.go │ ├── client_test.go │ ├── evaluator.go │ ├── evaluator_test.go │ ├── rulesets.go │ └── rulesets_test.go ├── server │ ├── api.go │ ├── api_test.go │ ├── handler.go │ ├── param.go │ ├── param_test.go │ ├── server.go │ └── store_test.go └── types.go ├── cmd └── regula │ ├── cli │ └── cli.go │ └── main.go ├── doc.go ├── docker-compose.yml ├── docs ├── before_after.png ├── cinematic.png ├── clients │ └── go.md └── index.md ├── engine.go ├── engine_test.go ├── errors.go ├── example_test.go ├── go.mod ├── go.sum ├── mkdocs.yml ├── param.go ├── param_test.go ├── rule ├── example_test.go ├── expr.go ├── expr_internal_test.go ├── expr_test.go ├── json.go ├── json_test.go ├── rule.go └── rule_test.go ├── ruleset.go ├── ruleset_test.go ├── store ├── etcd │ ├── rulesets.go │ ├── rulesets_internal_test.go │ └── rulesets_test.go └── service.go └── version └── version.go /.ci/docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | docker login -u="$QUAY_USERNAME" -p="$QUAY_PASSWORD" quay.io 6 | 7 | tag=${TRAVIS_TAG#"v"} 8 | 9 | docker build -t heetch/regula . 10 | docker tag heetch/regula quay.io/heetch/regula:latest 11 | docker tag heetch/regula quay.io/heetch/regula:$tag 12 | docker push quay.io/heetch/regula:$tag 13 | docker push quay.io/heetch/regula:latest 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | vendor/ 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: go 4 | 5 | services: 6 | - docker 7 | 8 | before_install: 9 | - docker run -d -p 2379:2379 quay.io/coreos/etcd /usr/local/bin/etcd -advertise-client-urls http://0.0.0.0:2379 -listen-client-urls http://0.0.0.0:2379 10 | 11 | go: 12 | - '1.11.x' 13 | - '1.12.x' 14 | - tip 15 | 16 | script: 17 | - GO111MODULE=on go test -v -race -cover -timeout=1m ./... 18 | 19 | deploy: 20 | provider: script 21 | skip_cleanup: true 22 | script: .ci/docker.sh 23 | on: 24 | tags: true 25 | go: '1.12.x' 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13-alpine as builder 2 | 3 | WORKDIR /src/regula 4 | COPY . . 5 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o /regula ./cmd/regula 6 | RUN chmod +x /regula 7 | CMD ["/regula"] 8 | 9 | FROM alpine:latest 10 | RUN apk --no-cache add ca-certificates 11 | COPY --from=builder /regula . 12 | EXPOSE 5331/tcp 13 | CMD ["./regula"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Heetch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := regula 2 | 3 | .PHONY: all $(NAME) test testrace run build 4 | 5 | all: $(NAME) 6 | 7 | build: $(NAME) 8 | 9 | $(NAME): 10 | go install ./cmd/$@ 11 | 12 | test: 13 | go test -v -cover -timeout=1m ./... 14 | 15 | testrace: 16 | go test -v -race -cover -timeout=2m ./... 17 | 18 | run: build 19 | regula -etcd-namespace regula-local 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️ regula library has been archived and is no longer under active maintenance. 2 | 3 | # Regula 4 | 5 | [![Build Status](https://travis-ci.org/heetch/regula.svg?branch=master)](https://travis-ci.org/heetch/regula) 6 | [![ReadTheDocs](https://readthedocs.org/projects/regula/badge/?version=latest&style=flat)](https://regula.readthedocs.io/en/latest/) 7 | [![GoDoc](https://godoc.org/github.com/heetch/regula?status.svg)](https://godoc.org/github.com/heetch/regula) 8 | 9 | Regula is an open source Business Rules Engine solution. 10 | 11 | :warning: *Please note that Regula is an experiment and that the API is currently considered unstable.* 12 | 13 | ## Documentation 14 | 15 | Comprehensive documentation is viewable on Read the Docs: 16 | 17 | https://regula.readthedocs.io/en/latest/ 18 | -------------------------------------------------------------------------------- /api/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "io/ioutil" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "strings" 14 | "time" 15 | 16 | "github.com/heetch/regula/api" 17 | "github.com/heetch/regula/version" 18 | "github.com/rs/zerolog" 19 | "golang.org/x/net/context/ctxhttp" 20 | ) 21 | 22 | const ( 23 | userAgent = "Regula/" + version.Version + " Go" 24 | watchDelay = 1 * time.Second 25 | retryDelay = 250 * time.Millisecond 26 | retries = 3 27 | ) 28 | 29 | // A Client manages communication with the Rules Engine API using HTTP. 30 | type Client struct { 31 | Logger zerolog.Logger 32 | WatchRetryDelay time.Duration // Time between failed watch requests. Defaults to 1s. 33 | RetryDelay time.Duration // Time between failed requests retries. Defaults to 1s. 34 | Retries int // Number of retries on retriable errors. 35 | baseURL *url.URL 36 | httpClient *http.Client 37 | 38 | Headers map[string]string 39 | Rulesets *RulesetService 40 | } 41 | 42 | // New creates an HTTP client that uses a base url to communicate with the api server. 43 | func New(baseURL string, opts ...Option) (*Client, error) { 44 | var c Client 45 | var err error 46 | 47 | c.Headers = make(map[string]string) 48 | 49 | c.baseURL, err = url.Parse(baseURL) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | if !strings.HasSuffix(c.baseURL.Path, "/") { 55 | c.baseURL.Path += "/" 56 | } 57 | 58 | for _, opt := range opts { 59 | opt(&c) 60 | } 61 | 62 | if c.httpClient == nil { 63 | c.httpClient = http.DefaultClient 64 | } 65 | 66 | c.Logger = zerolog.New(os.Stderr).With().Timestamp().Logger() 67 | c.WatchRetryDelay = watchDelay 68 | c.RetryDelay = retryDelay 69 | c.Retries = retries 70 | 71 | c.Rulesets = &RulesetService{ 72 | client: &c, 73 | } 74 | 75 | return &c, nil 76 | } 77 | 78 | func (c *Client) newRequest(method, path string, body interface{}) (*http.Request, error) { 79 | rel := url.URL{Path: path} 80 | u := c.baseURL.ResolveReference(&rel) 81 | 82 | var r io.Reader 83 | 84 | if body != nil { 85 | var buf bytes.Buffer 86 | 87 | err := json.NewEncoder(&buf).Encode(body) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | r = &buf 93 | } 94 | 95 | req, err := http.NewRequest(method, u.String(), r) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | for k, v := range c.Headers { 101 | req.Header.Set(k, v) 102 | } 103 | 104 | // If no User-Agent header is set, then we default it. 105 | if _, ok := req.Header["User-Agent"]; !ok { 106 | req.Header.Set("User-Agent", userAgent) 107 | } 108 | 109 | if body != nil { 110 | req.Header.Set("Content-Type", "application/json") 111 | } 112 | 113 | req.Header.Set("Accept", "application/json") 114 | 115 | return req, nil 116 | } 117 | 118 | func isRetriableError(err error) bool { 119 | if err == nil { 120 | return false 121 | } 122 | 123 | if _, ok := err.(net.Error); ok { 124 | return true 125 | } 126 | 127 | if aerr, ok := err.(*api.Error); ok { 128 | return aerr.Response.StatusCode == http.StatusInternalServerError || 129 | aerr.Response.StatusCode == http.StatusRequestTimeout 130 | } 131 | 132 | return false 133 | } 134 | 135 | func (c *Client) try(ctx context.Context, req *http.Request, v interface{}) (*http.Response, error) { 136 | return c.tryN(ctx, req, v, c.Retries) 137 | } 138 | 139 | func (c *Client) tryN(ctx context.Context, req *http.Request, v interface{}, times int) (resp *http.Response, err error) { 140 | var ( 141 | i int 142 | reqBody *bytes.Reader 143 | ) 144 | 145 | if req.Body != nil { 146 | body, err := ioutil.ReadAll(req.Body) 147 | if err != nil { 148 | return nil, err 149 | } 150 | err = req.Body.Close() 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | reqBody = bytes.NewReader(body) 156 | } 157 | 158 | for { 159 | if reqBody != nil { 160 | _, err = reqBody.Seek(0, io.SeekStart) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | req.Body = ioutil.NopCloser(reqBody) 166 | } 167 | 168 | resp, err = c.do(ctx, req, v) 169 | if err == nil || !isRetriableError(err) { 170 | break 171 | } 172 | 173 | i++ 174 | if i >= times { 175 | break 176 | } 177 | 178 | c.Logger.Debug().Err(err).Msgf("Request failed %d times, retrying in %s...", i, c.RetryDelay) 179 | time.Sleep(c.RetryDelay) 180 | } 181 | 182 | return resp, err 183 | } 184 | 185 | func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) (*http.Response, error) { 186 | resp, err := ctxhttp.Do(ctx, c.httpClient, req) 187 | if err != nil { 188 | return nil, err 189 | } 190 | defer resp.Body.Close() 191 | 192 | c.Logger.Debug().Str("url", req.URL.String()).Int("status", resp.StatusCode).Msg("request sent") 193 | 194 | dec := json.NewDecoder(resp.Body) 195 | 196 | if resp.StatusCode < 200 || resp.StatusCode >= 400 { 197 | var apiErr api.Error 198 | 199 | _ = dec.Decode(&apiErr) 200 | 201 | apiErr.Response = resp 202 | 203 | return resp, &apiErr 204 | } 205 | 206 | err = dec.Decode(v) 207 | if err != nil && err != io.EOF { 208 | return nil, err 209 | } 210 | 211 | return resp, nil 212 | } 213 | 214 | // Option allows Client customization. 215 | type Option func(*Client) error 216 | 217 | // HTTPClient specifies the http client to be used. 218 | func HTTPClient(httpClient *http.Client) Option { 219 | return func(c *Client) error { 220 | c.httpClient = httpClient 221 | return nil 222 | } 223 | } 224 | 225 | // Header adds a key value pair to the headers sent on each request. 226 | func Header(k, v string) Option { 227 | return func(c *Client) error { 228 | c.Headers[k] = v 229 | return nil 230 | } 231 | } 232 | 233 | // ListOptions contains pagination options. 234 | type ListOptions struct { 235 | Limit int 236 | Continue string 237 | } 238 | -------------------------------------------------------------------------------- /api/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | // When no User-Agent header is provided, newRequest causes the default value to be used. 10 | func TestNewRequestDefaultsUserAgentWhenNoneIsSpecified(t *testing.T) { 11 | client, err := New("http://www.example.com") 12 | require.NoError(t, err) 13 | req, err := client.newRequest("my-method", "/api/test", nil) 14 | require.NoError(t, err) 15 | ua := req.Header.Get("User-Agent") 16 | require.Equal(t, userAgent, ua) 17 | } 18 | 19 | // When we provide a User-Agent header, newRequest uses this value instead of the default. 20 | func TestNewRequestPrefersSpecifiedUserAgentHeaderToDefault(t *testing.T) { 21 | expUA := "Regula/Test Go" 22 | hO := Header("User-Agent", expUA) 23 | client, err := New("http://www.example.com", hO) 24 | require.NoError(t, err) 25 | req, err := client.newRequest("my-method", "/api/test", nil) 26 | require.NoError(t, err) 27 | ua := req.Header.Get("User-Agent") 28 | require.Equal(t, expUA, ua) 29 | } 30 | -------------------------------------------------------------------------------- /api/client/evaluator.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/heetch/regula" 8 | "github.com/heetch/regula/api" 9 | ) 10 | 11 | // Evaluator can cache rulesets in memory and can be passed to a regula.Engine to evaluate rulesets without 12 | // network round trips. If required, it can watch the server for changes and update its local cache. 13 | type Evaluator struct { 14 | *regula.RulesetBuffer 15 | cancel func() 16 | wg sync.WaitGroup 17 | } 18 | 19 | // NewEvaluator uses the given client to fetch a list of rulesets starting with the given prefix 20 | // and returns an evaluator that holds the results in memory. 21 | // If watch is true, the evaluator will watch for changes on the server and automatically update 22 | // the underlying RulesetBuffer. 23 | // If watch is set to true, the Close method must always be called to gracefully close the watcher. 24 | func NewEvaluator(ctx context.Context, client *Client, prefix string, watch bool) (*Evaluator, error) { 25 | ls, err := client.Rulesets.List(ctx, prefix, &ListOptions{ 26 | Limit: 100, // TODO(asdine): make it configurable in future releases 27 | }) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | buf := regula.NewRulesetBuffer() 33 | 34 | for _, re := range ls.Rulesets { 35 | buf.Add(re.Path, re.Version, re.Ruleset) 36 | } 37 | 38 | for ls.Continue != "" { 39 | ls, err = client.Rulesets.List(ctx, prefix, &ListOptions{ 40 | Limit: 100, // TODO(asdine): make it configurable in future releases 41 | Continue: ls.Continue, 42 | }) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | for _, re := range ls.Rulesets { 48 | buf.Add(re.Path, re.Version, re.Ruleset) 49 | } 50 | } 51 | 52 | ev := Evaluator{ 53 | RulesetBuffer: buf, 54 | } 55 | 56 | if watch { 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | ev.cancel = cancel 59 | ev.wg.Add(1) 60 | go func() { 61 | defer ev.wg.Done() 62 | 63 | ch := client.Rulesets.Watch(ctx, prefix, ls.Revision) 64 | 65 | for wr := range ch { 66 | if wr.Err != nil { 67 | client.Logger.Error().Err(err).Msg("Watching failed") 68 | } 69 | 70 | for _, ev := range wr.Events.Events { 71 | switch ev.Type { 72 | case api.PutEvent: 73 | buf.Add(ev.Path, ev.Version, ev.Ruleset) 74 | } 75 | } 76 | } 77 | }() 78 | } 79 | 80 | return &ev, nil 81 | } 82 | 83 | // Close stops the watcher if running. 84 | func (e *Evaluator) Close() error { 85 | if e.cancel != nil { 86 | e.cancel() 87 | e.wg.Wait() 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /api/client/evaluator_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/heetch/regula" 13 | "github.com/heetch/regula/api/client" 14 | "github.com/rs/zerolog" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | func TestEvaluator(t *testing.T) { 20 | t.Run("Watch disabled", func(t *testing.T) { 21 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | if _, ok := r.URL.Query()["list"]; ok { 23 | if continueToken := r.URL.Query().Get("continue"); continueToken != "" { 24 | assert.Equal(t, "some-token", continueToken) 25 | fmt.Fprintf(w, `{"revision": "revB", "rulesets": [{"path": "a", "version":"2"}]}`) 26 | return 27 | } 28 | 29 | fmt.Fprintf(w, `{"revision": "revA", "rulesets": [{"path": "a", "version":"1"}], "continue": "some-token"}`) 30 | return 31 | } 32 | 33 | t.Error("shouldn't reach this part") 34 | })) 35 | defer ts.Close() 36 | 37 | ctx, cancel := context.WithCancel(context.Background()) 38 | defer cancel() 39 | 40 | cli, err := client.New(ts.URL) 41 | require.NoError(t, err) 42 | cli.Logger = zerolog.New(ioutil.Discard) 43 | 44 | ev, err := client.NewEvaluator(ctx, cli, "a", false) 45 | require.NoError(t, err) 46 | 47 | err = ev.Close() 48 | require.NoError(t, err) 49 | 50 | _, version, err := ev.Latest("a") 51 | require.NoError(t, err) 52 | require.Equal(t, "2", version) 53 | }) 54 | 55 | t.Run("Watch enabled", func(t *testing.T) { 56 | watchCount := 0 57 | didWatch := make(chan struct{}) 58 | 59 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 | if _, ok := r.URL.Query()["list"]; ok { 61 | fmt.Fprintf(w, `{"revision": "revA", "rulesets": [{"path": "a", "version":"1"}]}`) 62 | return 63 | } 64 | 65 | watchCount++ 66 | 67 | if watchCount > 1 { 68 | close(didWatch) 69 | return 70 | } 71 | 72 | fmt.Fprintf(w, `{"events": [{"type": "PUT", "path": "a", "version": "2"}], "revision": "revB"}`) 73 | })) 74 | defer ts.Close() 75 | 76 | ctx, cancel := context.WithCancel(context.Background()) 77 | defer cancel() 78 | 79 | cli, err := client.New(ts.URL) 80 | require.NoError(t, err) 81 | cli.Logger = zerolog.New(ioutil.Discard) 82 | 83 | ev, err := client.NewEvaluator(ctx, cli, "a", true) 84 | require.NoError(t, err) 85 | 86 | <-didWatch 87 | err = ev.Close() 88 | require.NoError(t, err) 89 | 90 | _, version, err := ev.Latest("a") 91 | require.NoError(t, err) 92 | require.Equal(t, "2", version) 93 | }) 94 | } 95 | 96 | var ( 97 | addr string 98 | ) 99 | 100 | func ExampleEvaluator_withoutWatch() { 101 | cli, err := client.New(addr) 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | 106 | ev, err := client.NewEvaluator(context.Background(), cli, "prefix", false) 107 | if err != nil { 108 | log.Fatal(err) 109 | } 110 | 111 | ng := regula.NewEngine(ev) 112 | str, res, err := ng.GetString(context.Background(), "some/path", regula.Params{ 113 | "id": "123", 114 | }) 115 | if err != nil { 116 | log.Fatal(err) 117 | } 118 | fmt.Println(str) 119 | fmt.Println(res.Version) 120 | } 121 | 122 | func ExampleEvaluator_withWatch() { 123 | cli, err := client.New(addr) 124 | if err != nil { 125 | log.Fatal(err) 126 | } 127 | 128 | ev, err := client.NewEvaluator(context.Background(), cli, "prefix", true) 129 | if err != nil { 130 | log.Fatal(err) 131 | } 132 | // stopping the watcher 133 | defer ev.Close() 134 | 135 | ng := regula.NewEngine(ev) 136 | str, res, err := ng.GetString(context.Background(), "some/path", regula.Params{ 137 | "id": "123", 138 | }) 139 | if err != nil { 140 | log.Fatal(err) 141 | } 142 | fmt.Println(str) 143 | fmt.Println(res.Version) 144 | } 145 | -------------------------------------------------------------------------------- /api/client/rulesets.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ppath "path" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/heetch/regula" 11 | "github.com/heetch/regula/api" 12 | "github.com/heetch/regula/rule" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | // RulesetService handles communication with the ruleset related 17 | // methods of the Regula API. 18 | type RulesetService struct { 19 | client *Client 20 | } 21 | 22 | func (s *RulesetService) joinPath(path string) string { 23 | path = "./" + ppath.Join("rulesets/", path) 24 | if path == "./rulesets" { 25 | return path + "/" 26 | } 27 | 28 | return path 29 | } 30 | 31 | // List fetches all the rulesets starting with the given prefix. 32 | func (s *RulesetService) List(ctx context.Context, prefix string, opt *ListOptions) (*api.Rulesets, error) { 33 | req, err := s.client.newRequest("GET", s.joinPath(prefix), nil) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | q := req.URL.Query() 39 | q.Add("list", "") 40 | 41 | if opt != nil { 42 | if opt.Limit != 0 { 43 | q.Add("limit", strconv.Itoa(opt.Limit)) 44 | } 45 | 46 | if opt.Continue != "" { 47 | q.Add("continue", opt.Continue) 48 | } 49 | } 50 | 51 | req.URL.RawQuery = q.Encode() 52 | 53 | var rl api.Rulesets 54 | 55 | _, err = s.client.try(ctx, req, &rl) 56 | return &rl, err 57 | } 58 | 59 | // Eval evaluates the given ruleset with the given params. 60 | // It implements the regula.Evaluator interface and thus can be passed to the regula.Engine. 61 | func (s *RulesetService) Eval(ctx context.Context, path string, params rule.Params) (*regula.EvalResult, error) { 62 | return s.EvalVersion(ctx, path, "", params) 63 | } 64 | 65 | // EvalVersion evaluates the given ruleset version with the given params. 66 | // It implements the regula.Evaluator interface and thus can be passed to the regula.Engine. 67 | func (s *RulesetService) EvalVersion(ctx context.Context, path, version string, params rule.Params) (*regula.EvalResult, error) { 68 | req, err := s.client.newRequest("GET", s.joinPath(path), nil) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | q := req.URL.Query() 74 | q.Add("eval", "") 75 | for _, k := range params.Keys() { 76 | v, err := params.EncodeValue(k) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | q.Add(k, v) 82 | } 83 | if version != "" { 84 | q.Add("version", version) 85 | } 86 | req.URL.RawQuery = q.Encode() 87 | 88 | var resp api.EvalResult 89 | 90 | _, err = s.client.try(ctx, req, &resp) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | return ®ula.EvalResult{ 96 | Value: resp.Value, 97 | Version: resp.Version, 98 | }, nil 99 | } 100 | 101 | // Put creates a ruleset version on the given path. 102 | func (s *RulesetService) Put(ctx context.Context, path string, rs *regula.Ruleset) (*api.Ruleset, error) { 103 | req, err := s.client.newRequest("PUT", s.joinPath(path), rs) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | var resp api.Ruleset 109 | 110 | _, err = s.client.try(ctx, req, &resp) 111 | return &resp, err 112 | } 113 | 114 | // WatchResponse contains a list of events occured on a group of rulesets. 115 | // If an error occurs during the watching, the Err field will be populated. 116 | type WatchResponse struct { 117 | Events *api.Events 118 | Err error 119 | } 120 | 121 | // Watch watchs the given path for changes and sends the events in the returned channel. 122 | // If revision is empty it will start to watch for changes occuring from the moment the request is performed, 123 | // otherwise it will watch for any changes occured from the given revision. 124 | // The given context must be used to stop the watcher. 125 | func (s *RulesetService) Watch(ctx context.Context, prefix string, revision string) <-chan WatchResponse { 126 | ch := make(chan WatchResponse) 127 | 128 | go func() { 129 | defer close(ch) 130 | 131 | for { 132 | select { 133 | case <-ctx.Done(): 134 | return 135 | default: 136 | } 137 | 138 | req, err := s.client.newRequest("GET", s.joinPath(prefix), nil) 139 | if err != nil { 140 | ch <- WatchResponse{Err: errors.Wrap(err, "failed to create watch request")} 141 | return 142 | } 143 | 144 | q := req.URL.Query() 145 | q.Add("watch", "") 146 | if revision != "" { 147 | q.Add("revision", revision) 148 | } 149 | req.URL.RawQuery = q.Encode() 150 | 151 | var events api.Events 152 | _, err = s.client.do(ctx, req, &events) 153 | if err != nil { 154 | if e, ok := err.(*api.Error); ok { 155 | switch e.Response.StatusCode { 156 | case http.StatusNotFound: 157 | ch <- WatchResponse{Err: err} 158 | return 159 | case http.StatusInternalServerError: 160 | s.client.Logger.Debug().Err(err).Msg("watch request failed: internal server error") 161 | default: 162 | s.client.Logger.Error().Err(err).Int("status", e.Response.StatusCode).Msg("watch request returned unexpected status") 163 | } 164 | } else { 165 | switch err { 166 | case context.Canceled: 167 | fallthrough 168 | case context.DeadlineExceeded: 169 | s.client.Logger.Debug().Msg("watch context done") 170 | return 171 | default: 172 | s.client.Logger.Error().Err(err).Msg("watch request failed") 173 | } 174 | } 175 | 176 | // avoid too many requests on errors. 177 | time.Sleep(s.client.WatchRetryDelay) 178 | continue 179 | } 180 | 181 | if events.Timeout { 182 | s.client.Logger.Debug().Msg("watch request timed out") 183 | time.Sleep(s.client.WatchRetryDelay) 184 | continue 185 | } 186 | 187 | ch <- WatchResponse{Events: &events} 188 | revision = events.Revision 189 | } 190 | }() 191 | 192 | return ch 193 | } 194 | -------------------------------------------------------------------------------- /api/client/rulesets_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net" 10 | "net/http" 11 | "net/http/httptest" 12 | "sync/atomic" 13 | "testing" 14 | "time" 15 | 16 | "github.com/heetch/regula" 17 | "github.com/heetch/regula/api" 18 | "github.com/heetch/regula/api/client" 19 | "github.com/heetch/regula/rule" 20 | "github.com/rs/zerolog" 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | var ev regula.Evaluator = new(client.RulesetService) 26 | 27 | func ExampleRulesetService_List() { 28 | c, err := client.New("http://127.0.0.1:5331") 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | list, err := c.Rulesets.List(context.Background(), "prefix", nil) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | 38 | for _, e := range list.Rulesets { 39 | e.Ruleset.Eval(nil) 40 | } 41 | } 42 | 43 | func ExampleRulesetService_List_withPagination() { 44 | c, err := client.New("http://127.0.0.1:5331") 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | list, err := c.Rulesets.List(context.Background(), "prefix", &client.ListOptions{ 50 | Limit: 20, 51 | }) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | 56 | for _, e := range list.Rulesets { 57 | e.Ruleset.Eval(nil) 58 | } 59 | 60 | for list.Continue != "" { 61 | list, err = c.Rulesets.List(context.Background(), "prefix", &client.ListOptions{ 62 | Limit: 20, 63 | Continue: list.Continue, 64 | }) 65 | if err != nil { 66 | log.Fatal(err) 67 | } 68 | 69 | for _, e := range list.Rulesets { 70 | e.Ruleset.Eval(nil) 71 | } 72 | } 73 | } 74 | 75 | func ExampleRulesetService_Eval() { 76 | c, err := client.New("http://127.0.0.1:5331") 77 | if err != nil { 78 | log.Fatal(err) 79 | } 80 | 81 | resp, err := c.Rulesets.Eval(context.Background(), "path/to/ruleset", regula.Params{ 82 | "foo": "bar", 83 | "baz": int64(42), 84 | }) 85 | if err != nil { 86 | log.Fatal(err) 87 | } 88 | 89 | fmt.Println(resp.Value.Data) 90 | fmt.Println(resp.Value.Type) 91 | fmt.Println(resp.Version) 92 | } 93 | 94 | func ExampleRulesetService_EvalVersion() { 95 | c, err := client.New("http://127.0.0.1:5331") 96 | if err != nil { 97 | log.Fatal(err) 98 | } 99 | 100 | resp, err := c.Rulesets.EvalVersion(context.Background(), "path/to/ruleset", "xyzabc", regula.Params{ 101 | "foo": "bar", 102 | "baz": int64(42), 103 | }) 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | 108 | fmt.Println(resp.Value.Data) 109 | fmt.Println(resp.Value.Type) 110 | fmt.Println(resp.Version) 111 | } 112 | 113 | func TestRulesetService(t *testing.T) { 114 | t.Run("Error", func(t *testing.T) { 115 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 116 | w.WriteHeader(http.StatusBadRequest) 117 | fmt.Fprintf(w, `{"error": "some err"}`) 118 | })) 119 | defer ts.Close() 120 | 121 | cli, err := client.New(ts.URL) 122 | require.NoError(t, err) 123 | cli.Logger = zerolog.New(ioutil.Discard) 124 | 125 | _, err = cli.Rulesets.List(context.Background(), "", nil) 126 | aerr := err.(*api.Error) 127 | require.Equal(t, "some err", aerr.Err) 128 | }) 129 | 130 | t.Run("Retries/Success", func(t *testing.T) { 131 | i := 0 132 | 133 | rs, err := regula.NewInt64Ruleset(rule.New(rule.True(), rule.Int64Value(1))) 134 | require.NoError(t, err) 135 | 136 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 137 | i++ 138 | 139 | var body regula.Ruleset 140 | err := json.NewDecoder(r.Body).Decode(&body) 141 | assert.NoError(t, err) 142 | assert.Equal(t, rs, &body) 143 | 144 | if i < 2 { 145 | w.WriteHeader(http.StatusInternalServerError) 146 | return 147 | } 148 | 149 | w.WriteHeader(http.StatusBadRequest) 150 | fmt.Fprintf(w, `{"error": "some err"}`) 151 | })) 152 | defer ts.Close() 153 | 154 | cli, err := client.New(ts.URL) 155 | require.NoError(t, err) 156 | cli.Logger = zerolog.New(ioutil.Discard) 157 | cli.RetryDelay = 10 * time.Millisecond 158 | 159 | _, err = cli.Rulesets.Put(context.Background(), "path", rs) 160 | aerr := err.(*api.Error) 161 | require.Equal(t, "some err", aerr.Err) 162 | }) 163 | 164 | t.Run("Retries/Failure", func(t *testing.T) { 165 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 166 | w.WriteHeader(http.StatusInternalServerError) 167 | })) 168 | defer ts.Close() 169 | 170 | cli, err := client.New(ts.URL) 171 | require.NoError(t, err) 172 | cli.Logger = zerolog.New(ioutil.Discard) 173 | cli.RetryDelay = 10 * time.Millisecond 174 | 175 | rs, err := regula.NewInt64Ruleset(rule.New(rule.True(), rule.Int64Value(1))) 176 | require.NoError(t, err) 177 | 178 | _, err = cli.Rulesets.Put(context.Background(), "path", rs) 179 | aerr := err.(*api.Error) 180 | require.Equal(t, http.StatusInternalServerError, aerr.Response.StatusCode) 181 | }) 182 | 183 | t.Run("Retries/NetworkError", func(t *testing.T) { 184 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 185 | w.WriteHeader(http.StatusOK) 186 | })) 187 | // close immediately 188 | ts.Close() 189 | 190 | cli, err := client.New(ts.URL) 191 | require.NoError(t, err) 192 | cli.Logger = zerolog.New(ioutil.Discard) 193 | cli.RetryDelay = 10 * time.Millisecond 194 | 195 | rs, err := regula.NewInt64Ruleset(rule.New(rule.True(), rule.Int64Value(1))) 196 | require.NoError(t, err) 197 | 198 | _, err = cli.Rulesets.Put(context.Background(), "path", rs) 199 | _, ok := err.(net.Error) 200 | require.True(t, ok) 201 | }) 202 | 203 | t.Run("ListRulesets", func(t *testing.T) { 204 | tl := []struct { 205 | path string 206 | url string 207 | }{ 208 | {"", "/subpath/rulesets/"}, 209 | {"/", "/subpath/rulesets/"}, 210 | {"a/b", "/subpath/rulesets/a/b"}, 211 | {"/a/b", "/subpath/rulesets/a/b"}, 212 | } 213 | 214 | for _, tc := range tl { 215 | t.Run(tc.path, func(t *testing.T) { 216 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 217 | assert.NotEmpty(t, r.Header.Get("User-Agent")) 218 | assert.Equal(t, "application/json", r.Header.Get("Accept")) 219 | assert.Equal(t, "hv1", r.Header.Get("hk1")) 220 | assert.Equal(t, "hv2", r.Header.Get("hk2")) 221 | assert.Contains(t, r.URL.Query(), "list") 222 | assert.Equal(t, "some-token", r.URL.Query().Get("continue")) 223 | assert.Equal(t, "10", r.URL.Query().Get("limit")) 224 | assert.Equal(t, tc.url, r.URL.Path) 225 | fmt.Fprintf(w, `{"revision": "rev", "rulesets": [{"path": "a"}]}`) 226 | })) 227 | defer ts.Close() 228 | 229 | cli, err := client.New(ts.URL+"/subpath", client.Header("hk1", "hv1")) 230 | require.NoError(t, err) 231 | cli.Logger = zerolog.New(ioutil.Discard) 232 | cli.Headers["hk2"] = "hv2" 233 | 234 | rs, err := cli.Rulesets.List(context.Background(), tc.path, &client.ListOptions{ 235 | Limit: 10, 236 | Continue: "some-token", 237 | }) 238 | require.NoError(t, err) 239 | require.Len(t, rs.Rulesets, 1) 240 | }) 241 | } 242 | }) 243 | 244 | t.Run("EvalRuleset", func(t *testing.T) { 245 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 246 | assert.NotEmpty(t, r.Header.Get("User-Agent")) 247 | assert.Equal(t, "application/json", r.Header.Get("Accept")) 248 | assert.Contains(t, r.URL.Query(), "eval") 249 | assert.Contains(t, r.URL.Query(), "foo") 250 | assert.Equal(t, "/rulesets/path/to/ruleset", r.URL.Path) 251 | fmt.Fprintf(w, `{"value": {"data": "baz", "type": "string", "kind": "value"}, "version": "1234"}`) 252 | })) 253 | defer ts.Close() 254 | 255 | cli, err := client.New(ts.URL) 256 | require.NoError(t, err) 257 | cli.Logger = zerolog.New(ioutil.Discard) 258 | 259 | exp := regula.EvalResult{Value: rule.StringValue("baz"), Version: "1234"} 260 | 261 | resp, err := cli.Rulesets.Eval(context.Background(), "path/to/ruleset", regula.Params{ 262 | "foo": "bar", 263 | }) 264 | require.NoError(t, err) 265 | require.Equal(t, &exp, resp) 266 | }) 267 | 268 | t.Run("PutRuleset", func(t *testing.T) { 269 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 270 | assert.NotEmpty(t, r.Header.Get("User-Agent")) 271 | assert.Equal(t, "application/json", r.Header.Get("Accept")) 272 | assert.Equal(t, "application/json", r.Header.Get("Content-Type")) 273 | assert.Equal(t, "/rulesets/a", r.URL.Path) 274 | fmt.Fprintf(w, `{"path": "a", "version": "v"}`) 275 | })) 276 | defer ts.Close() 277 | 278 | cli, err := client.New(ts.URL) 279 | require.NoError(t, err) 280 | cli.Logger = zerolog.New(ioutil.Discard) 281 | 282 | rs, err := regula.NewInt64Ruleset(rule.New(rule.True(), rule.Int64Value(1))) 283 | require.NoError(t, err) 284 | 285 | ars, err := cli.Rulesets.Put(context.Background(), "a", rs) 286 | require.NoError(t, err) 287 | require.Equal(t, "a", ars.Path) 288 | require.Equal(t, "v", ars.Version) 289 | }) 290 | 291 | t.Run("WatchRuleset", func(t *testing.T) { 292 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 293 | assert.NotEmpty(t, r.Header.Get("User-Agent")) 294 | assert.Equal(t, "application/json", r.Header.Get("Accept")) 295 | assert.Equal(t, "/rulesets/a", r.URL.Path) 296 | fmt.Fprintf(w, `{"events": [{"type": "PUT", "path": "a"}], "revision": "rev"}`) 297 | })) 298 | defer ts.Close() 299 | 300 | ctx, cancel := context.WithCancel(context.Background()) 301 | defer cancel() 302 | 303 | cli, err := client.New(ts.URL) 304 | require.NoError(t, err) 305 | cli.Logger = zerolog.New(ioutil.Discard) 306 | 307 | ch := cli.Rulesets.Watch(ctx, "a", "") 308 | evs := <-ch 309 | require.NoError(t, evs.Err) 310 | }) 311 | 312 | t.Run("WatchRuleset/Revision", func(t *testing.T) { 313 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 314 | assert.NotEmpty(t, r.Header.Get("User-Agent")) 315 | assert.Equal(t, "application/json", r.Header.Get("Accept")) 316 | assert.Equal(t, "rev", r.URL.Query().Get("revision")) 317 | assert.Equal(t, "/rulesets/a", r.URL.Path) 318 | fmt.Fprintf(w, `{"events": [{"type": "PUT", "path": "a"}], "revision": "rev"}`) 319 | })) 320 | defer ts.Close() 321 | 322 | ctx, cancel := context.WithCancel(context.Background()) 323 | defer cancel() 324 | 325 | cli, err := client.New(ts.URL) 326 | require.NoError(t, err) 327 | cli.Logger = zerolog.New(ioutil.Discard) 328 | 329 | ch := cli.Rulesets.Watch(ctx, "a", "rev") 330 | evs := <-ch 331 | require.NoError(t, evs.Err) 332 | }) 333 | 334 | t.Run("WatchRuleset/Timeout", func(t *testing.T) { 335 | var i int32 336 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 337 | if atomic.AddInt32(&i, 1) > 3 { 338 | fmt.Fprintf(w, `{"events": [{"type": "PUT", "path": "a"}], "revision": "rev"}`) 339 | return 340 | } 341 | 342 | fmt.Fprintf(w, `{"timeout": true}`) 343 | })) 344 | defer ts.Close() 345 | 346 | ctx, cancel := context.WithCancel(context.Background()) 347 | defer cancel() 348 | 349 | cli, err := client.New(ts.URL) 350 | require.NoError(t, err) 351 | cli.Logger = zerolog.New(ioutil.Discard) 352 | cli.WatchRetryDelay = 1 * time.Millisecond 353 | 354 | ch := cli.Rulesets.Watch(ctx, "a", "") 355 | evs := <-ch 356 | require.NoError(t, evs.Err) 357 | require.EqualValues(t, 4, i) 358 | }) 359 | 360 | t.Run("WatchRuleset/NotFound", func(t *testing.T) { 361 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 362 | w.WriteHeader(http.StatusNotFound) 363 | })) 364 | defer ts.Close() 365 | 366 | ctx, cancel := context.WithCancel(context.Background()) 367 | defer cancel() 368 | 369 | cli, err := client.New(ts.URL) 370 | require.NoError(t, err) 371 | cli.Logger = zerolog.New(ioutil.Discard) 372 | 373 | ch := cli.Rulesets.Watch(ctx, "a", "") 374 | evs := <-ch 375 | require.Error(t, evs.Err) 376 | }) 377 | 378 | t.Run("WatchRuleset/Errors", func(t *testing.T) { 379 | statuses := []int{ 380 | http.StatusRequestTimeout, 381 | http.StatusInternalServerError, 382 | http.StatusBadRequest, 383 | } 384 | 385 | for _, status := range statuses { 386 | var i int32 387 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 388 | if atomic.AddInt32(&i, 1) > 3 { 389 | w.WriteHeader(http.StatusOK) 390 | return 391 | } 392 | 393 | w.WriteHeader(status) 394 | })) 395 | defer ts.Close() 396 | 397 | ctx, cancel := context.WithCancel(context.Background()) 398 | defer cancel() 399 | 400 | cli, err := client.New(ts.URL) 401 | require.NoError(t, err) 402 | cli.Logger = zerolog.New(ioutil.Discard) 403 | cli.WatchRetryDelay = 1 * time.Millisecond 404 | 405 | ch := cli.Rulesets.Watch(ctx, "a", "") 406 | evs := <-ch 407 | require.NoError(t, evs.Err) 408 | } 409 | }) 410 | 411 | t.Run("WatchRuleset/Cancel", func(t *testing.T) { 412 | ctx, cancel := context.WithCancel(context.Background()) 413 | defer cancel() 414 | 415 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 416 | select { 417 | case <-ctx.Done(): 418 | return 419 | } 420 | })) 421 | defer ts.Close() 422 | 423 | cli, err := client.New(ts.URL) 424 | require.NoError(t, err) 425 | cli.Logger = zerolog.New(ioutil.Discard) 426 | 427 | ch := cli.Rulesets.Watch(ctx, "a", "") 428 | cancel() 429 | evs := <-ch 430 | require.Zero(t, evs) 431 | }) 432 | } 433 | -------------------------------------------------------------------------------- /api/server/api.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/heetch/regula" 13 | "github.com/heetch/regula/api" 14 | "github.com/heetch/regula/rule" 15 | "github.com/heetch/regula/store" 16 | "github.com/pkg/errors" 17 | ) 18 | 19 | type rulesetService struct { 20 | *service 21 | 22 | timeout time.Duration 23 | watchTimeout time.Duration 24 | } 25 | 26 | func (s *rulesetService) ServeHTTP(w http.ResponseWriter, r *http.Request) { 27 | path := strings.TrimPrefix(r.URL.Path, "/rulesets") 28 | path = strings.TrimPrefix(path, "/") 29 | 30 | if _, ok := r.URL.Query()["watch"]; ok && r.Method == "GET" { 31 | ctx, cancel := context.WithTimeout(r.Context(), s.watchTimeout) 32 | defer cancel() 33 | s.watch(w, r.WithContext(ctx), path) 34 | return 35 | } 36 | 37 | ctx, cancel := context.WithTimeout(r.Context(), s.timeout) 38 | defer cancel() 39 | r = r.WithContext(ctx) 40 | 41 | switch r.Method { 42 | case "GET": 43 | if _, ok := r.URL.Query()["list"]; ok { 44 | s.list(w, r, path) 45 | return 46 | } 47 | if _, ok := r.URL.Query()["eval"]; ok { 48 | s.eval(w, r, path) 49 | return 50 | } 51 | case "PUT": 52 | if path != "" { 53 | s.put(w, r, path) 54 | return 55 | } 56 | } 57 | 58 | w.WriteHeader(http.StatusNotFound) 59 | } 60 | 61 | // list fetches all the rulesets from the store and writes them to the http response. 62 | func (s *rulesetService) list(w http.ResponseWriter, r *http.Request, prefix string) { 63 | var ( 64 | err error 65 | limit int 66 | ) 67 | 68 | if l := r.URL.Query().Get("limit"); l != "" { 69 | limit, err = strconv.Atoi(l) 70 | if err != nil { 71 | s.writeError(w, r, errors.New("invalid limit"), http.StatusBadRequest) 72 | return 73 | } 74 | } 75 | 76 | continueToken := r.URL.Query().Get("continue") 77 | entries, err := s.rulesets.List(r.Context(), prefix, limit, continueToken) 78 | if err != nil { 79 | if err == store.ErrNotFound { 80 | s.writeError(w, r, err, http.StatusNotFound) 81 | return 82 | } 83 | 84 | if err == store.ErrInvalidContinueToken { 85 | s.writeError(w, r, err, http.StatusBadRequest) 86 | return 87 | } 88 | 89 | s.writeError(w, r, err, http.StatusInternalServerError) 90 | return 91 | } 92 | 93 | var rl api.Rulesets 94 | 95 | rl.Rulesets = make([]api.Ruleset, len(entries.Entries)) 96 | for i := range entries.Entries { 97 | rl.Rulesets[i] = api.Ruleset(entries.Entries[i]) 98 | } 99 | rl.Revision = entries.Revision 100 | rl.Continue = entries.Continue 101 | 102 | s.encodeJSON(w, r, &rl, http.StatusOK) 103 | } 104 | 105 | func (s *rulesetService) eval(w http.ResponseWriter, r *http.Request, path string) { 106 | var err error 107 | var res *regula.EvalResult 108 | 109 | params := make(params) 110 | for k, v := range r.URL.Query() { 111 | params[k] = v[0] 112 | } 113 | 114 | if v, ok := r.URL.Query()["version"]; ok { 115 | res, err = s.rulesets.EvalVersion(r.Context(), path, v[0], params) 116 | } else { 117 | res, err = s.rulesets.Eval(r.Context(), path, params) 118 | } 119 | 120 | if err != nil { 121 | if err == regula.ErrRulesetNotFound { 122 | s.writeError(w, r, fmt.Errorf("the path '%s' doesn't exist", path), http.StatusNotFound) 123 | return 124 | } 125 | 126 | if err == rule.ErrParamNotFound || 127 | err == rule.ErrParamTypeMismatch || 128 | err == rule.ErrNoMatch { 129 | s.writeError(w, r, err, http.StatusBadRequest) 130 | return 131 | } 132 | 133 | s.writeError(w, r, err, http.StatusInternalServerError) 134 | return 135 | } 136 | 137 | s.encodeJSON(w, r, (*api.EvalResult)(res), http.StatusOK) 138 | } 139 | 140 | // watch watches a prefix for change and returns anything newer. 141 | func (s *rulesetService) watch(w http.ResponseWriter, r *http.Request, prefix string) { 142 | var ae api.Events 143 | 144 | events, err := s.rulesets.Watch(r.Context(), prefix, r.URL.Query().Get("revision")) 145 | if err != nil { 146 | switch err { 147 | case context.DeadlineExceeded: 148 | ae.Timeout = true 149 | case store.ErrNotFound: 150 | w.WriteHeader(http.StatusNotFound) 151 | return 152 | default: 153 | s.writeError(w, r, err, http.StatusInternalServerError) 154 | return 155 | } 156 | } 157 | 158 | if events != nil { 159 | ae.Events = make([]api.Event, len(events.Events)) 160 | ae.Revision = events.Revision 161 | 162 | for i := range events.Events { 163 | ae.Events[i] = api.Event(events.Events[i]) 164 | } 165 | } 166 | 167 | s.encodeJSON(w, r, ae, http.StatusOK) 168 | } 169 | 170 | // put creates a new version of a ruleset. 171 | func (s *rulesetService) put(w http.ResponseWriter, r *http.Request, path string) { 172 | var rs regula.Ruleset 173 | 174 | err := json.NewDecoder(r.Body).Decode(&rs) 175 | if err != nil { 176 | s.writeError(w, r, err, http.StatusBadRequest) 177 | return 178 | } 179 | 180 | entry, err := s.rulesets.Put(r.Context(), path, &rs) 181 | if err != nil && err != store.ErrNotModified { 182 | if store.IsValidationError(err) { 183 | s.writeError(w, r, err, http.StatusBadRequest) 184 | return 185 | } 186 | 187 | s.writeError(w, r, err, http.StatusInternalServerError) 188 | return 189 | } 190 | 191 | s.encodeJSON(w, r, (*api.Ruleset)(entry), http.StatusOK) 192 | } 193 | -------------------------------------------------------------------------------- /api/server/api_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strconv" 12 | "testing" 13 | "time" 14 | 15 | "github.com/heetch/regula" 16 | "github.com/heetch/regula/api" 17 | "github.com/heetch/regula/rule" 18 | "github.com/heetch/regula/store" 19 | "github.com/pkg/errors" 20 | "github.com/rs/zerolog" 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | func TestAPI(t *testing.T) { 26 | s := new(mockRulesetService) 27 | log := zerolog.New(ioutil.Discard) 28 | h := NewHandler(context.Background(), s, Config{ 29 | WatchTimeout: 1 * time.Second, 30 | Logger: &log, 31 | }) 32 | 33 | t.Run("Root", func(t *testing.T) { 34 | w := httptest.NewRecorder() 35 | r := httptest.NewRequest("GET", "/", nil) 36 | h.ServeHTTP(w, r) 37 | require.Equal(t, http.StatusNotFound, w.Code) 38 | }) 39 | 40 | t.Run("List", func(t *testing.T) { 41 | r1, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 42 | r2, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 43 | l := store.RulesetEntries{ 44 | Entries: []store.RulesetEntry{ 45 | {Path: "aa", Ruleset: r1}, 46 | {Path: "bb", Ruleset: r2}, 47 | }, 48 | Revision: "somerev", 49 | Continue: "sometoken", 50 | } 51 | 52 | call := func(t *testing.T, u string, code int, l *store.RulesetEntries, err error) { 53 | t.Helper() 54 | 55 | uu, uerr := url.Parse(u) 56 | require.NoError(t, uerr) 57 | limit := uu.Query().Get("limit") 58 | if limit == "" { 59 | limit = "0" 60 | } 61 | token := uu.Query().Get("continue") 62 | 63 | s.ListFn = func(ctx context.Context, prefix string, lm int, tk string) (*store.RulesetEntries, error) { 64 | assert.Equal(t, limit, strconv.Itoa(lm)) 65 | assert.Equal(t, token, tk) 66 | return l, err 67 | } 68 | defer func() { s.ListFn = nil }() 69 | 70 | w := httptest.NewRecorder() 71 | r := httptest.NewRequest("GET", u, nil) 72 | h.ServeHTTP(w, r) 73 | 74 | require.Equal(t, code, w.Code) 75 | 76 | if code == http.StatusOK { 77 | var res api.Rulesets 78 | err := json.NewDecoder(w.Body).Decode(&res) 79 | require.NoError(t, err) 80 | require.Equal(t, len(l.Entries), len(res.Rulesets)) 81 | for i := range l.Entries { 82 | require.EqualValues(t, l.Entries[i], res.Rulesets[i]) 83 | } 84 | if len(l.Entries) > 0 { 85 | require.Equal(t, "sometoken", res.Continue) 86 | } 87 | } 88 | } 89 | 90 | t.Run("Root", func(t *testing.T) { 91 | call(t, "/rulesets/?list", http.StatusOK, &l, nil) 92 | }) 93 | 94 | t.Run("WithPrefix", func(t *testing.T) { 95 | call(t, "/rulesets/a?list", http.StatusOK, &l, nil) 96 | }) 97 | 98 | t.Run("NoResultOnRoot", func(t *testing.T) { 99 | call(t, "/rulesets/?list", http.StatusOK, new(store.RulesetEntries), nil) 100 | }) 101 | 102 | t.Run("NoResultOnPrefix", func(t *testing.T) { 103 | call(t, "/rulesets/someprefix?list", http.StatusNotFound, new(store.RulesetEntries), store.ErrNotFound) 104 | }) 105 | 106 | t.Run("InvalidToken", func(t *testing.T) { 107 | call(t, "/rulesets/someprefix?list", http.StatusBadRequest, new(store.RulesetEntries), store.ErrInvalidContinueToken) 108 | }) 109 | 110 | t.Run("UnexpectedError", func(t *testing.T) { 111 | call(t, "/rulesets/someprefix?list", http.StatusInternalServerError, new(store.RulesetEntries), errors.New("unexpected error")) 112 | }) 113 | 114 | t.Run("InvalidLimit", func(t *testing.T) { 115 | call(t, "/rulesets/someprefix?list&limit=badlimit", http.StatusBadRequest, nil, nil) 116 | }) 117 | }) 118 | 119 | t.Run("Eval", func(t *testing.T) { 120 | call := func(t *testing.T, url string, code int, result *api.EvalResult, testParamsFn func(params rule.Params)) { 121 | t.Helper() 122 | resetStore(s) 123 | 124 | s.EvalFn = func(ctx context.Context, path string, params rule.Params) (*regula.EvalResult, error) { 125 | testParamsFn(params) 126 | return (*regula.EvalResult)(result), nil 127 | } 128 | 129 | s.EvalVersionFn = func(ctx context.Context, path, version string, params rule.Params) (*regula.EvalResult, error) { 130 | return (*regula.EvalResult)(result), nil 131 | } 132 | 133 | w := httptest.NewRecorder() 134 | r := httptest.NewRequest("GET", url, nil) 135 | h.ServeHTTP(w, r) 136 | 137 | require.Equal(t, code, w.Code) 138 | 139 | if code == http.StatusOK { 140 | var res api.EvalResult 141 | err := json.NewDecoder(w.Body).Decode(&res) 142 | require.NoError(t, err) 143 | require.EqualValues(t, result, &res) 144 | } 145 | } 146 | 147 | t.Run("OK", func(t *testing.T) { 148 | exp := api.EvalResult{ 149 | Value: rule.StringValue("success"), 150 | } 151 | 152 | call(t, "/rulesets/path/to/my/ruleset?eval&str=str&nb=10&boolean=true", http.StatusOK, &exp, func(params rule.Params) { 153 | s, err := params.GetString("str") 154 | require.NoError(t, err) 155 | require.Equal(t, "str", s) 156 | i, err := params.GetInt64("nb") 157 | require.NoError(t, err) 158 | require.Equal(t, int64(10), i) 159 | b, err := params.GetBool("boolean") 160 | require.NoError(t, err) 161 | require.True(t, b) 162 | }) 163 | require.Equal(t, 1, s.EvalCount) 164 | }) 165 | 166 | t.Run("OK With version", func(t *testing.T) { 167 | exp := api.EvalResult{ 168 | Value: rule.StringValue("success"), 169 | Version: "123", 170 | } 171 | 172 | call(t, "/rulesets/path/to/my/ruleset?eval&version=123&str=str&nb=10&boolean=true", http.StatusOK, &exp, func(params rule.Params) { 173 | s, err := params.GetString("str") 174 | require.NoError(t, err) 175 | require.Equal(t, "str", s) 176 | }) 177 | require.Equal(t, 1, s.EvalVersionCount) 178 | }) 179 | 180 | t.Run("NOK - Ruleset not found", func(t *testing.T) { 181 | s.EvalFn = func(ctx context.Context, path string, params rule.Params) (*regula.EvalResult, error) { 182 | return nil, regula.ErrRulesetNotFound 183 | } 184 | 185 | w := httptest.NewRecorder() 186 | r := httptest.NewRequest("GET", "/rulesets/path/to/my/ruleset?eval&foo=10", nil) 187 | h.ServeHTTP(w, r) 188 | 189 | exp := api.Error{ 190 | Err: "the path 'path/to/my/ruleset' doesn't exist", 191 | } 192 | 193 | var resp api.Error 194 | err := json.Unmarshal(w.Body.Bytes(), &resp) 195 | require.NoError(t, err) 196 | require.Equal(t, http.StatusNotFound, w.Code) 197 | require.Equal(t, exp, resp) 198 | }) 199 | 200 | t.Run("NOK - errors", func(t *testing.T) { 201 | errs := []error{ 202 | rule.ErrParamNotFound, 203 | rule.ErrParamTypeMismatch, 204 | rule.ErrNoMatch, 205 | } 206 | 207 | for _, e := range errs { 208 | s.EvalFn = func(ctx context.Context, path string, params rule.Params) (*regula.EvalResult, error) { 209 | return nil, e 210 | } 211 | 212 | w := httptest.NewRecorder() 213 | r := httptest.NewRequest("GET", "/rulesets/path/to/my/ruleset?eval&foo=10", nil) 214 | h.ServeHTTP(w, r) 215 | 216 | require.Equal(t, http.StatusBadRequest, w.Code) 217 | } 218 | }) 219 | }) 220 | 221 | t.Run("Watch", func(t *testing.T) { 222 | r1, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 223 | r2, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 224 | l := store.RulesetEvents{ 225 | Events: []store.RulesetEvent{ 226 | {Type: store.RulesetPutEvent, Path: "a", Ruleset: r1}, 227 | {Type: store.RulesetPutEvent, Path: "b", Ruleset: r2}, 228 | {Type: store.RulesetPutEvent, Path: "a", Ruleset: r2}, 229 | }, 230 | Revision: "rev", 231 | } 232 | 233 | call := func(t *testing.T, url string, code int, es *store.RulesetEvents, err error) { 234 | t.Helper() 235 | 236 | s.WatchFn = func(context.Context, string, string) (*store.RulesetEvents, error) { 237 | return es, err 238 | } 239 | defer func() { s.WatchFn = nil }() 240 | 241 | w := httptest.NewRecorder() 242 | r := httptest.NewRequest("GET", url, nil) 243 | h.ServeHTTP(w, r) 244 | 245 | require.Equal(t, code, w.Code) 246 | 247 | if code == http.StatusOK { 248 | var res store.RulesetEvents 249 | err := json.NewDecoder(w.Body).Decode(&res) 250 | require.NoError(t, err) 251 | if es != nil { 252 | require.Equal(t, len(es.Events), len(res.Events)) 253 | for i := range l.Events { 254 | require.Equal(t, l.Events[i], res.Events[i]) 255 | } 256 | } 257 | } 258 | } 259 | 260 | t.Run("Root", func(t *testing.T) { 261 | call(t, "/rulesets/?watch", http.StatusOK, &l, nil) 262 | }) 263 | 264 | t.Run("WithPrefix", func(t *testing.T) { 265 | call(t, "/rulesets/a?watch", http.StatusOK, &l, nil) 266 | }) 267 | 268 | t.Run("WithRevision", func(t *testing.T) { 269 | t.Helper() 270 | 271 | s.WatchFn = func(ctx context.Context, prefix string, revision string) (*store.RulesetEvents, error) { 272 | require.Equal(t, "a", prefix) 273 | require.Equal(t, "somerev", revision) 274 | return &l, nil 275 | } 276 | defer func() { s.WatchFn = nil }() 277 | 278 | w := httptest.NewRecorder() 279 | r := httptest.NewRequest("GET", "/rulesets/a?watch&revision=somerev", nil) 280 | h.ServeHTTP(w, r) 281 | 282 | require.Equal(t, http.StatusOK, w.Code) 283 | 284 | var res store.RulesetEvents 285 | err := json.NewDecoder(w.Body).Decode(&res) 286 | require.NoError(t, err) 287 | require.Equal(t, len(l.Events), len(res.Events)) 288 | for i := range l.Events { 289 | require.Equal(t, l.Events[i], res.Events[i]) 290 | } 291 | }) 292 | 293 | t.Run("Timeout", func(t *testing.T) { 294 | call(t, "/rulesets/?watch", http.StatusOK, nil, context.DeadlineExceeded) 295 | }) 296 | }) 297 | 298 | t.Run("Put", func(t *testing.T) { 299 | r1, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 300 | e1 := store.RulesetEntry{ 301 | Path: "a", 302 | Version: "version", 303 | Ruleset: r1, 304 | } 305 | 306 | call := func(t *testing.T, url string, code int, e *store.RulesetEntry, putErr error) { 307 | t.Helper() 308 | 309 | s.PutFn = func(context.Context, string) (*store.RulesetEntry, error) { 310 | return e, putErr 311 | } 312 | defer func() { s.PutFn = nil }() 313 | 314 | var buf bytes.Buffer 315 | err := json.NewEncoder(&buf).Encode(r1) 316 | require.NoError(t, err) 317 | 318 | w := httptest.NewRecorder() 319 | r := httptest.NewRequest("PUT", url, &buf) 320 | h.ServeHTTP(w, r) 321 | 322 | require.Equal(t, code, w.Code) 323 | 324 | if code == http.StatusOK { 325 | var rs api.Ruleset 326 | err := json.NewDecoder(w.Body).Decode(&rs) 327 | require.NoError(t, err) 328 | require.EqualValues(t, *e, rs) 329 | } 330 | } 331 | 332 | t.Run("OK", func(t *testing.T) { 333 | call(t, "/rulesets/a", http.StatusOK, &e1, nil) 334 | }) 335 | 336 | t.Run("NotModified", func(t *testing.T) { 337 | call(t, "/rulesets/a", http.StatusOK, &e1, store.ErrNotModified) 338 | }) 339 | 340 | t.Run("EmptyPath", func(t *testing.T) { 341 | call(t, "/rulesets/", http.StatusNotFound, &e1, nil) 342 | }) 343 | 344 | t.Run("StoreError", func(t *testing.T) { 345 | call(t, "/rulesets/a", http.StatusInternalServerError, nil, errors.New("some error")) 346 | }) 347 | 348 | t.Run("Bad ruleset name", func(t *testing.T) { 349 | call(t, "/rulesets/a", http.StatusBadRequest, nil, new(store.ValidationError)) 350 | }) 351 | 352 | t.Run("Bad param name", func(t *testing.T) { 353 | call(t, "/rulesets/a", http.StatusBadRequest, nil, new(store.ValidationError)) 354 | }) 355 | }) 356 | } 357 | 358 | func resetStore(s *mockRulesetService) { 359 | s.ListCount = 0 360 | s.LatestCount = 0 361 | s.OneByVersionCount = 0 362 | s.WatchCount = 0 363 | s.PutCount = 0 364 | s.EvalCount = 0 365 | s.EvalVersionCount = 0 366 | s.ListFn = nil 367 | s.LatestFn = nil 368 | s.OneByVersionFn = nil 369 | s.WatchFn = nil 370 | s.PutFn = nil 371 | s.EvalFn = nil 372 | s.EvalVersionFn = nil 373 | } 374 | -------------------------------------------------------------------------------- /api/server/handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/heetch/regula/api" 11 | "github.com/heetch/regula/store" 12 | "github.com/pkg/errors" 13 | "github.com/rs/zerolog" 14 | "github.com/rs/zerolog/hlog" 15 | ) 16 | 17 | // HTTP errors 18 | var ( 19 | errInternal = errors.New("internal_error") 20 | ) 21 | 22 | // Config contains the API configuration. 23 | type Config struct { 24 | Logger *zerolog.Logger 25 | Timeout time.Duration 26 | WatchTimeout time.Duration 27 | } 28 | 29 | // NewHandler creates an http handler to serve the rules engine API. 30 | func NewHandler(ctx context.Context, rsService store.RulesetService, cfg Config) http.Handler { 31 | s := service{ 32 | rulesets: rsService, 33 | } 34 | 35 | var logger zerolog.Logger 36 | 37 | if cfg.Logger != nil { 38 | logger = *cfg.Logger 39 | } else { 40 | logger = zerolog.New(os.Stderr).With().Timestamp().Logger() 41 | } 42 | 43 | if cfg.Timeout == 0 { 44 | cfg.Timeout = 5 * time.Second 45 | } 46 | 47 | if cfg.WatchTimeout == 0 { 48 | cfg.WatchTimeout = 30 * time.Second 49 | } 50 | 51 | rs := rulesetService{ 52 | service: &s, 53 | timeout: cfg.Timeout, 54 | watchTimeout: cfg.WatchTimeout, 55 | } 56 | 57 | // router 58 | mux := http.NewServeMux() 59 | mux.Handle("/rulesets/", &rs) 60 | 61 | // middlewares 62 | chain := []func(http.Handler) http.Handler{ 63 | hlog.NewHandler(logger), 64 | hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) { 65 | hlog.FromRequest(r).Info(). 66 | Str("method", r.Method). 67 | Str("url", r.URL.String()). 68 | Int("status", status). 69 | Int("size", size). 70 | Dur("duration", duration). 71 | Msg("request received") 72 | }), 73 | hlog.RemoteAddrHandler("ip"), 74 | hlog.UserAgentHandler("user_agent"), 75 | hlog.RefererHandler("referer"), 76 | func(http.Handler) http.Handler { 77 | return mux 78 | }, 79 | } 80 | 81 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 | // playing the middleware chain 83 | var cur http.Handler 84 | for i := len(chain) - 1; i >= 0; i-- { 85 | cur = chain[i](cur) 86 | } 87 | 88 | // serving the request 89 | cur.ServeHTTP(w, r.WithContext(ctx)) 90 | }) 91 | } 92 | 93 | type service struct { 94 | rulesets store.RulesetService 95 | } 96 | 97 | // encodeJSON encodes v to w in JSON format. 98 | func (s *service) encodeJSON(w http.ResponseWriter, r *http.Request, v interface{}, status int) { 99 | w.Header().Set("Content-Type", "application/json") 100 | w.WriteHeader(status) 101 | 102 | if err := json.NewEncoder(w).Encode(v); err != nil { 103 | loggerFromRequest(r).Error().Err(err).Interface("value", v).Msg("failed to encode value to http response") 104 | } 105 | } 106 | 107 | func loggerFromRequest(r *http.Request) *zerolog.Logger { 108 | logger := hlog.FromRequest(r).With(). 109 | Str("method", r.Method). 110 | Str("url", r.URL.String()). 111 | Logger() 112 | return &logger 113 | } 114 | 115 | // writeError writes an error to the http response in JSON format. 116 | func (s *service) writeError(w http.ResponseWriter, r *http.Request, err error, code int) { 117 | // Prepare log. 118 | logger := loggerFromRequest(r).With(). 119 | Err(err). 120 | Int("status", code). 121 | Logger() 122 | 123 | // Hide error from client if it's internal. 124 | if code == http.StatusInternalServerError { 125 | logger.Error().Msg("unexpected http error") 126 | err = errInternal 127 | } else { 128 | logger.Debug().Msg("http error") 129 | } 130 | 131 | s.encodeJSON(w, r, &api.Error{Err: err.Error()}, code) 132 | } 133 | -------------------------------------------------------------------------------- /api/server/param.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/heetch/regula/rule" 7 | ) 8 | 9 | // params represents the parameters computed from the query string. 10 | // It implements the rule.Params interface. 11 | type params map[string]string 12 | 13 | // GetString extracts a string parameter which corresponds to the given key. 14 | func (p params) GetString(key string) (string, error) { 15 | s, ok := p[key] 16 | if !ok { 17 | return "", rule.ErrParamNotFound 18 | } 19 | 20 | return s, nil 21 | } 22 | 23 | // GetBool extracts a bool parameter which corresponds to the given key. 24 | func (p params) GetBool(key string) (bool, error) { 25 | v, ok := p[key] 26 | if !ok { 27 | return false, rule.ErrParamNotFound 28 | } 29 | 30 | b, err := strconv.ParseBool(v) 31 | if err != nil { 32 | return false, rule.ErrParamTypeMismatch 33 | } 34 | 35 | return b, nil 36 | } 37 | 38 | // GetInt64 extracts an int64 parameter which corresponds to the given key. 39 | func (p params) GetInt64(key string) (int64, error) { 40 | v, ok := p[key] 41 | if !ok { 42 | return 0, rule.ErrParamNotFound 43 | } 44 | 45 | i, err := strconv.ParseInt(v, 10, 64) 46 | if err != nil { 47 | return 0, rule.ErrParamTypeMismatch 48 | } 49 | 50 | return i, nil 51 | } 52 | 53 | // GetFloat64 extracts a float64 parameter which corresponds to the given key. 54 | func (p params) GetFloat64(key string) (float64, error) { 55 | v, ok := p[key] 56 | if !ok { 57 | return 0, rule.ErrParamNotFound 58 | } 59 | 60 | f, err := strconv.ParseFloat(v, 64) 61 | if err != nil { 62 | return 0, rule.ErrParamTypeMismatch 63 | } 64 | 65 | return f, err 66 | } 67 | 68 | // Keys returns the list of all the keys. 69 | func (p params) Keys() []string { 70 | keys := make([]string, 0, len(p)) 71 | for k := range p { 72 | keys = append(keys, k) 73 | } 74 | 75 | return keys 76 | } 77 | 78 | // EncodeValue returns the string representation of a value. 79 | func (p params) EncodeValue(key string) (string, error) { 80 | v, ok := p[key] 81 | if !ok { 82 | return "", rule.ErrParamNotFound 83 | } 84 | 85 | return v, nil 86 | } 87 | -------------------------------------------------------------------------------- /api/server/param_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/heetch/regula/rule" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetString(t *testing.T) { 11 | p := params{ 12 | "string": "string", 13 | "bool": "true", 14 | } 15 | 16 | t.Run("GetString - OK", func(t *testing.T) { 17 | v, err := p.GetString("string") 18 | require.NoError(t, err) 19 | require.Equal(t, "string", v) 20 | }) 21 | 22 | t.Run("GetString - NOK - ErrParamNotFound", func(t *testing.T) { 23 | _, err := p.GetString("badkey") 24 | require.Error(t, err) 25 | require.Equal(t, err, rule.ErrParamNotFound) 26 | }) 27 | } 28 | 29 | func TestGetBool(t *testing.T) { 30 | p := params{ 31 | "bool": "true", 32 | "string": "foo", 33 | } 34 | 35 | t.Run("GetBool - OK", func(t *testing.T) { 36 | v, err := p.GetBool("bool") 37 | require.NoError(t, err) 38 | require.Equal(t, true, v) 39 | }) 40 | 41 | t.Run("GetBool - NOK - ErrParamNotFound", func(t *testing.T) { 42 | _, err := p.GetBool("badkey") 43 | require.Error(t, err) 44 | require.Equal(t, err, rule.ErrParamNotFound) 45 | }) 46 | 47 | t.Run("GetBool - NOK - ErrParamTypeMismatch", func(t *testing.T) { 48 | _, err := p.GetBool("string") 49 | require.Error(t, err) 50 | require.Equal(t, err, rule.ErrParamTypeMismatch) 51 | }) 52 | } 53 | 54 | func TestGetInt64(t *testing.T) { 55 | p := params{ 56 | "int64": "42", 57 | "string": "foo", 58 | } 59 | 60 | t.Run("GetInt64 - OK", func(t *testing.T) { 61 | v, err := p.GetInt64("int64") 62 | require.NoError(t, err) 63 | require.Equal(t, int64(42), v) 64 | }) 65 | 66 | t.Run("GetInt64 - NOK - ErrParamNotFound", func(t *testing.T) { 67 | _, err := p.GetInt64("badkey") 68 | require.Error(t, err) 69 | require.Equal(t, err, rule.ErrParamNotFound) 70 | }) 71 | 72 | t.Run("GetInt64 - NOK - ErrParamTypeMismatch", func(t *testing.T) { 73 | _, err := p.GetInt64("string") 74 | require.Error(t, err) 75 | require.Equal(t, err, rule.ErrParamTypeMismatch) 76 | }) 77 | } 78 | 79 | func TestGetFloat64(t *testing.T) { 80 | p := params{ 81 | "float64": "42.42", 82 | "string": "foo", 83 | } 84 | 85 | t.Run("GetFloat64 - OK", func(t *testing.T) { 86 | v, err := p.GetFloat64("float64") 87 | require.NoError(t, err) 88 | require.Equal(t, 42.42, v) 89 | }) 90 | 91 | t.Run("GetFloat64 - NOK - ErrParamNotFound", func(t *testing.T) { 92 | _, err := p.GetFloat64("badkey") 93 | require.Error(t, err) 94 | require.Equal(t, err, rule.ErrParamNotFound) 95 | }) 96 | 97 | t.Run("GetFloat64 - NOK - ErrParamTypeMismatch", func(t *testing.T) { 98 | _, err := p.GetFloat64("string") 99 | require.Error(t, err) 100 | require.Equal(t, err, rule.ErrParamTypeMismatch) 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /api/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/heetch/regula/store" 12 | "github.com/rs/zerolog" 13 | ) 14 | 15 | // Server is an HTTP server serving the Regula API. 16 | type Server struct { 17 | Mux *http.ServeMux // Can be used to add handlers to the server. 18 | logger zerolog.Logger 19 | server *http.Server 20 | } 21 | 22 | // New creates a Server instance. 23 | func New(service store.RulesetService, cfg Config) *Server { 24 | srv := Server{ 25 | Mux: http.NewServeMux(), 26 | } 27 | 28 | if cfg.Logger == nil { 29 | lg := zerolog.New(os.Stderr).With().Timestamp().Logger() 30 | cfg.Logger = &lg 31 | } 32 | 33 | srv.logger = *cfg.Logger 34 | 35 | srv.server = new(http.Server) 36 | 37 | ctx, cancel := context.WithCancel(context.Background()) 38 | // cancel context on shutdown to stop long running operations like watches. 39 | srv.server.RegisterOnShutdown(cancel) 40 | 41 | srv.Mux.Handle("/", NewHandler(ctx, service, cfg)) 42 | 43 | return &srv 44 | } 45 | 46 | // Run runs the server on the chosen address. The given context must be used to 47 | // gracefully stop the server. 48 | func (s *Server) Run(ctx context.Context, addr string) error { 49 | s.server.Handler = s.Mux 50 | 51 | l, err := net.Listen("tcp", addr) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | go func() { 57 | s.logger.Info().Msg("Listening on " + l.Addr().String()) 58 | err := s.server.Serve(l) 59 | if err != nil && err != http.ErrServerClosed { 60 | log.Fatal(err) 61 | } 62 | }() 63 | 64 | <-ctx.Done() 65 | 66 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 67 | defer cancel() 68 | 69 | err = s.server.Shutdown(ctx) 70 | if err != nil { 71 | s.logger.Error().Err(err).Msg("failed to shutdown server gracefully") 72 | } 73 | 74 | return err 75 | } 76 | -------------------------------------------------------------------------------- /api/server/store_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/heetch/regula/rule" 7 | 8 | "github.com/heetch/regula" 9 | "github.com/heetch/regula/store" 10 | ) 11 | 12 | var _ store.RulesetService = new(mockRulesetService) 13 | 14 | type mockRulesetService struct { 15 | ListCount int 16 | ListFn func(context.Context, string, int, string) (*store.RulesetEntries, error) 17 | LatestCount int 18 | LatestFn func(context.Context, string) (*store.RulesetEntry, error) 19 | OneByVersionCount int 20 | OneByVersionFn func(context.Context, string, string) (*store.RulesetEntry, error) 21 | WatchCount int 22 | WatchFn func(context.Context, string, string) (*store.RulesetEvents, error) 23 | PutCount int 24 | PutFn func(context.Context, string) (*store.RulesetEntry, error) 25 | EvalCount int 26 | EvalFn func(ctx context.Context, path string, params rule.Params) (*regula.EvalResult, error) 27 | EvalVersionCount int 28 | EvalVersionFn func(ctx context.Context, path, version string, params rule.Params) (*regula.EvalResult, error) 29 | } 30 | 31 | func (s *mockRulesetService) List(ctx context.Context, prefix string, limit int, token string) (*store.RulesetEntries, error) { 32 | s.ListCount++ 33 | 34 | if s.ListFn != nil { 35 | return s.ListFn(ctx, prefix, limit, token) 36 | } 37 | 38 | return nil, nil 39 | } 40 | 41 | func (s *mockRulesetService) Latest(ctx context.Context, path string) (*store.RulesetEntry, error) { 42 | s.LatestCount++ 43 | 44 | if s.LatestFn != nil { 45 | return s.LatestFn(ctx, path) 46 | } 47 | return nil, nil 48 | } 49 | 50 | func (s *mockRulesetService) OneByVersion(ctx context.Context, path, version string) (*store.RulesetEntry, error) { 51 | s.OneByVersionCount++ 52 | 53 | if s.OneByVersionFn != nil { 54 | return s.OneByVersionFn(ctx, path, version) 55 | } 56 | return nil, nil 57 | } 58 | 59 | func (s *mockRulesetService) Watch(ctx context.Context, prefix, revision string) (*store.RulesetEvents, error) { 60 | s.WatchCount++ 61 | 62 | if s.WatchFn != nil { 63 | return s.WatchFn(ctx, prefix, revision) 64 | } 65 | 66 | return nil, nil 67 | } 68 | 69 | func (s *mockRulesetService) Put(ctx context.Context, path string, ruleset *regula.Ruleset) (*store.RulesetEntry, error) { 70 | s.PutCount++ 71 | 72 | if s.PutFn != nil { 73 | return s.PutFn(ctx, path) 74 | } 75 | return nil, nil 76 | } 77 | 78 | func (s *mockRulesetService) Eval(ctx context.Context, path string, params rule.Params) (*regula.EvalResult, error) { 79 | s.EvalCount++ 80 | 81 | if s.EvalFn != nil { 82 | return s.EvalFn(ctx, path, params) 83 | } 84 | return nil, nil 85 | } 86 | 87 | func (s *mockRulesetService) EvalVersion(ctx context.Context, path, version string, params rule.Params) (*regula.EvalResult, error) { 88 | s.EvalVersionCount++ 89 | 90 | if s.EvalVersionFn != nil { 91 | return s.EvalVersionFn(ctx, path, version, params) 92 | } 93 | return nil, nil 94 | } 95 | -------------------------------------------------------------------------------- /api/types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/heetch/regula" 8 | "github.com/heetch/regula/rule" 9 | ) 10 | 11 | // EvalResult is the response sent to the client after an eval. 12 | type EvalResult struct { 13 | Value *rule.Value `json:"value"` 14 | Version string `json:"version"` 15 | } 16 | 17 | // Error is a generic error response. 18 | type Error struct { 19 | Err string `json:"error"` 20 | Response *http.Response `json:"-"` // Used by clients to return the original server response 21 | } 22 | 23 | func (e Error) Error() string { 24 | if e.Response == nil { 25 | return e.Err 26 | } 27 | return fmt.Sprintf("%v %v: %d %v", 28 | e.Response.Request.Method, 29 | e.Response.Request.URL, 30 | e.Response.StatusCode, 31 | e.Err) 32 | } 33 | 34 | // Ruleset holds a ruleset and its metadata. 35 | type Ruleset struct { 36 | Path string `json:"path"` 37 | Version string `json:"version"` 38 | Ruleset *regula.Ruleset `json:"ruleset"` 39 | } 40 | 41 | // Rulesets holds a list of rulesets. 42 | type Rulesets struct { 43 | Rulesets []Ruleset `json:"rulesets"` 44 | Revision string `json:"revision"` 45 | Continue string `json:"continue,omitempty"` 46 | } 47 | 48 | // List of possible events executed against a ruleset. 49 | const ( 50 | PutEvent = "PUT" 51 | ) 52 | 53 | // Event describes an event occured on a ruleset. 54 | type Event struct { 55 | Type string `json:"type"` 56 | Path string `json:"path"` 57 | Version string `json:"version"` 58 | Ruleset *regula.Ruleset `json:"ruleset"` 59 | } 60 | 61 | // Events holds a list of events occured on a group of rulesets. 62 | type Events struct { 63 | Events []Event `json:"events,omitempty"` 64 | Revision string `json:"revision,omitempty"` 65 | Timeout bool `json:"timeout,omitempty"` 66 | } 67 | -------------------------------------------------------------------------------- /cmd/regula/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | stdflag "flag" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/heetch/confita" 16 | "github.com/heetch/confita/backend/env" 17 | "github.com/heetch/regula/api/server" 18 | isatty "github.com/mattn/go-isatty" 19 | "github.com/rs/zerolog" 20 | ) 21 | 22 | // Config holds the server configuration. 23 | type Config struct { 24 | Etcd struct { 25 | Endpoints []string `config:"etcd-endpoints"` 26 | Namespace string `config:"etcd-namespace"` 27 | } 28 | Server struct { 29 | Address string `config:"addr"` 30 | Timeout time.Duration `config:"server-timeout"` 31 | WatchTimeout time.Duration `config:"server-watch-timeout"` 32 | } 33 | LogLevel string `config:"log-level"` 34 | } 35 | 36 | // LoadConfig loads the configuration from the environment or command line flags. 37 | // The args hold the command line arguments, as found in os.Argv. 38 | // It returns flag.ErrHelp if the -help flag is specified on the command line. 39 | func LoadConfig(args []string) (*Config, error) { 40 | var cfg Config 41 | flag := stdflag.NewFlagSet("", stdflag.ContinueOnError) 42 | flag.StringVar(&cfg.Etcd.Namespace, "etcd-namespace", "", "etcd namespace to use") 43 | flag.StringVar(&cfg.LogLevel, "log-level", zerolog.DebugLevel.String(), "debug level") 44 | cfg.Etcd.Endpoints = []string{"127.0.0.1:2379"} 45 | flag.Var(commaSeparatedFlag{&cfg.Etcd.Endpoints}, "etcd-endpoints", "comma separated etcd endpoints") 46 | flag.StringVar(&cfg.Server.Address, "addr", "0.0.0.0:5331", "server address to listen on") 47 | flag.DurationVar(&cfg.Server.Timeout, "server-timeout", 5*time.Second, "server timeout (TODO)") 48 | flag.DurationVar(&cfg.Server.WatchTimeout, "server-watch-timeout", 30*time.Second, "server watch timeout (TODO)") 49 | 50 | err := confita.NewLoader(env.NewBackend()).Load(context.Background(), &cfg) 51 | if err != nil { 52 | return nil, err 53 | } 54 | if err := flag.Parse(args[1:]); err != nil { 55 | return nil, err 56 | } 57 | if cfg.Etcd.Namespace == "" { 58 | return nil, fmt.Errorf("etcdnamespace is required (use the -etc-namespace flag to set it)") 59 | } 60 | return &cfg, nil 61 | } 62 | 63 | type commaSeparatedFlag struct { 64 | parts *[]string 65 | } 66 | 67 | func (f commaSeparatedFlag) Set(s string) error { 68 | *f.parts = strings.Split(s, ",") 69 | return nil 70 | } 71 | 72 | func (f commaSeparatedFlag) String() string { 73 | if f.parts == nil { 74 | // Note: the flag package can make a new zero value 75 | // which is how it's possible for parts to be nil. 76 | return "" 77 | } 78 | return strings.Join(*f.parts, ",") 79 | } 80 | 81 | // CreateLogger returns a configured logger. 82 | func CreateLogger(level string, w io.Writer) zerolog.Logger { 83 | logger := zerolog.New(w).With().Timestamp().Logger() 84 | 85 | lvl, err := zerolog.ParseLevel(level) 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | logger = logger.Level(lvl) 91 | 92 | // pretty print during development 93 | if f, ok := w.(*os.File); ok { 94 | if isatty.IsTerminal(f.Fd()) { 95 | logger = logger.Output(zerolog.ConsoleWriter{Out: f}) 96 | } 97 | } 98 | 99 | // replace standard logger with zerolog 100 | log.SetFlags(0) 101 | log.SetOutput(logger) 102 | 103 | return logger 104 | } 105 | 106 | // RunServer runs the server and listens to SIGINT and SIGTERM 107 | // to stop the server gracefully. 108 | func RunServer(srv *server.Server, addr string) error { 109 | ctx, cancel := context.WithCancel(context.Background()) 110 | defer cancel() 111 | 112 | go func() { 113 | quit := make(chan os.Signal, 1) 114 | signal.Notify(quit, os.Interrupt, syscall.SIGTERM) 115 | 116 | <-quit 117 | cancel() 118 | }() 119 | 120 | return srv.Run(ctx, addr) 121 | } 122 | -------------------------------------------------------------------------------- /cmd/regula/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/coreos/etcd/clientv3" 10 | "github.com/heetch/regula/api/server" 11 | "github.com/heetch/regula/cmd/regula/cli" 12 | "github.com/heetch/regula/store/etcd" 13 | ) 14 | 15 | func main() { 16 | cfg, err := cli.LoadConfig(os.Args) 17 | if err != nil { 18 | if err == flag.ErrHelp { 19 | os.Exit(0) 20 | } 21 | fmt.Fprintf(os.Stderr, "regula: %v\n", err) 22 | os.Exit(2) 23 | } 24 | 25 | logger := cli.CreateLogger(cfg.LogLevel, os.Stderr) 26 | 27 | etcdCli, err := clientv3.New(clientv3.Config{ 28 | Endpoints: cfg.Etcd.Endpoints, 29 | DialTimeout: 5 * time.Second, 30 | }) 31 | if err != nil { 32 | logger.Fatal().Err(err).Msg("Failed to connect to etcd cluster") 33 | } 34 | defer etcdCli.Close() 35 | 36 | service := etcd.RulesetService{ 37 | Client: etcdCli, 38 | Namespace: cfg.Etcd.Namespace, 39 | Logger: logger.With().Str("service", "etcd").Logger(), 40 | } 41 | 42 | srv := server.New(&service, server.Config{ 43 | Logger: &logger, 44 | Timeout: cfg.Server.Timeout, 45 | WatchTimeout: cfg.Server.WatchTimeout, 46 | }) 47 | 48 | cli.RunServer(srv, cfg.Server.Address) 49 | } 50 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package regula provides a rules engine implementation. 3 | 4 | Usage of this package revolves around the concept of rulesets. 5 | 6 | A ruleset can be represented as a list of rules that can be evaluated against a set of parameters given by a caller. 7 | Each rule is evaluated in order and if one matches with the given parameters it returns a result and the evaluation stops. 8 | All the rules of a ruleset always return the same type. 9 | 10 | rs, err := regula.NewStringRuleset( 11 | rule.New( 12 | rule.Eq( 13 | rule.StringParam("group"), 14 | rule.StringValue("admin"), 15 | ), 16 | rule.StringValue("first rule matched"), 17 | ), 18 | rule.New( 19 | rule.In( 20 | rule.Int64Param("score"), 21 | rule.Int64Value(10), 22 | rule.Int64Value(20), 23 | rule.Int64Value(30), 24 | ), 25 | rule.StringValue("second rule matched"), 26 | ), 27 | rule.New( 28 | rule.True(), 29 | rule.StringValue("default rule matched"), 30 | ), 31 | ) 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | ret, err := rs.Eval(regula.Params{ 37 | "group": "staff", 38 | "score": int64(20), 39 | }) 40 | 41 | To query and evaluate rulesets with a set of parameters, the engine must be used. 42 | An engine takes an evaluator which is responsible of evaluating rulesets on demand and return a value, the engine then parses the value into a type safe result 43 | and return it to the caller. 44 | 45 | While the evaluator is stateful and can hold rulesets in-memory, fetch them over the network or read them from a file, 46 | the engine is stateless and simply deleguates the evaluation to the evaluator. 47 | 48 | engine := regula.NewEngine(evaluator) 49 | 50 | s, res, err := engine.GetString("path/to/string/ruleset/key", regula.Params{ 51 | "user-id": 123, 52 | "email": "example@provider.com", 53 | }) 54 | 55 | i, res, err := engine.GetInt64("path/to/int/ruleset/key", regula.Params{ 56 | "user-id": 123, 57 | "email": "example@provider.com", 58 | }) 59 | */ 60 | package regula 61 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | etcd: 5 | image: "quay.io/coreos/etcd:latest" 6 | ports: 7 | - "2379:2379" 8 | command: /usr/local/bin/etcd -advertise-client-urls http://0.0.0.0:2379 -listen-client-urls http://0.0.0.0:2379 9 | -------------------------------------------------------------------------------- /docs/before_after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heetch/regula/d96255e279a83cfe8c04cd50015ee0ab411a5414/docs/before_after.png -------------------------------------------------------------------------------- /docs/cinematic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heetch/regula/d96255e279a83cfe8c04cd50015ee0ab411a5414/docs/cinematic.png -------------------------------------------------------------------------------- /docs/clients/go.md: -------------------------------------------------------------------------------- 1 | # Golang library quickstart 2 | 3 | The Regula Golang library provides programmatic access to the full API and features of Regula. 4 | 5 | ## Install 6 | 7 | ``` 8 | go get -u github.com/heetch/regula 9 | ``` 10 | 11 | ## API documentation 12 | 13 | The API documentation can be found on [godoc](https://godoc.org/github.com/heetch/regula). 14 | 15 | ## Engine 16 | 17 | The **engine** is a unified API which allows the evaluation of rulesets. Whether a ruleset is stored on a remote Regula cluster, or in memory, the evaluation API remains the same. The **engine** delegates the evaluation to an **evaluator** which as its name suggests is able to evaluate rulesets. The role of the **engine** is to make sure the **result** of the evaluation corresponds to the expected type, decodes it and return it to the caller. 18 | This separation allows the decoupling of the evaluation logic from the exploitation of its result by the user. 19 | 20 | ### Creating an evaluator and an engine 21 | 22 | Before being able to use an engine, an evaluator must be instantiated. 23 | Regula provides three ways to do so. 24 | 25 | #### Server-side evaluation 26 | 27 | One of the simplest methods is to delegate the evaluation of rulesets to the Regula server. 28 | In order to do that, you must use the Regula Client Ruleset API which implements the `Evaluator` interface. 29 | 30 | ```go 31 | package main 32 | 33 | import ( 34 | "log" 35 | 36 | "github.com/heetch/regula" 37 | "github.com/heetch/regula/api/client" 38 | ) 39 | 40 | func main() { 41 | // Create a client. 42 | cli, err := client.New("http://localhost:5331/") 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | 47 | // Create an engine and pass the client.Rulesets field which instantiates the regula.Evaluator interface. 48 | ng := regula.NewEngine(cli.Rulesets) 49 | 50 | // Every call to the engine methods will send a request to the server. 51 | str, res, err := ng.GetString(context.Background(), "/a/b/c", regula.Params{ 52 | "product-id": "1234", 53 | "user-id": "5678", 54 | }) 55 | } 56 | ``` 57 | 58 | With this evaluator, every call to `GetString` and other methods of the engine object will result in a call to the Regula server. 59 | 60 | #### Client side evaluation 61 | 62 | Regula also provides client side evaluation to avoid network round-trips when necessary. 63 | At startup, the evaluator loads all the requested rulesets and saves them in a local cache. 64 | An optional mechanism watches the server for changes and automatically updates the local cache. 65 | 66 | ```go 67 | package main 68 | 69 | import ( 70 | "context" 71 | "log" 72 | 73 | "github.com/heetch/regula" 74 | "github.com/heetch/regula/api/client" 75 | ) 76 | 77 | func main() { 78 | // Create a client. 79 | cli, err := client.New("http://localhost:5331/") 80 | if err != nil { 81 | log.Fatal(err) 82 | } 83 | 84 | // create a cancelable context to cancel the watching. 85 | ctx, cancel := context.WithCancel(context.Background()) 86 | defer cancel() 87 | 88 | // Fetch all the rulesets that start with the given prefix and store 89 | // them in a local cache. 90 | // The last parameter tells the evaluator to watch the server for changes 91 | // and to update the local cache. 92 | // If not necessary (or on mobile), set this to false. 93 | ev, err := client.NewEvaluator(ctx, cli, "prefix", true) 94 | if err != nil { 95 | log.Fatal(err) 96 | } 97 | defer ev.Close() 98 | 99 | // Create the engine. 100 | ng := regula.NewEngine(ev) 101 | 102 | // Every call to the engine methods will evaluate rulesets in memory with no network round trip. 103 | str, res, err := ng.GetString(context.Background(), "/a/b/c", regula.Params{ 104 | "product-id": "1234", 105 | "user-id": "5678", 106 | }) 107 | } 108 | ``` 109 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Regula 2 | 3 | ## Overview 4 | 5 | The "Regula" project addresses the problems caused by the accumulation of simple logic that changes frequently in line with business requirements. This introduces strains on the development cycle by reducing the velocity. In concrete terms, developers can find themselves always chasing the business rather than focusing on core logic which defines the business. 6 | 7 | A solution to these kind of problems is a "Business Rules engine" or "Production Rule System". This provides a way to extract logic by creating "rules" that dictates a result under precise conditions. 8 | 9 | Such rules are ordered and composed of a condition and an action - simplistically, it's a bunch of ordered if-then statements. [Learn more about Rule Engines](https://martinfowler.com/bliki/RulesEngine.html). 10 | 11 | What really makes this approach shine is that stakeholders can change the rules themselves, without having to wait for developers to implement it. This enables much faster development cycles. 12 | 13 | Example: Let's say there's a ride-sharing company that needs to adjust the radius in which it can match drivers and passengers. This changes a lot, depending on various real life events: 14 | 15 | ``` 16 | Given I want to know "What radius should I match driver and passenger with" in my code 17 | And I don't want to hardcode the logic 18 | When I query the ruleset named "/ride-marketplace/radius" 19 | And that I submit "product", "driver-status" and "city" 20 | Then I get a radius value based on rules that have been defined by product owners 21 | 22 | --- 23 | 24 | radius = 25 | // Product people can update these rules on their own 26 | // Codebases using Regula will receive those updates live 27 | if city == "france-paris" AND driver-status == "gold" then 3.0 28 | if city == "france-paris" then 2.0 29 | if city == "italy-milan" then 1.5 30 | // catch all clause, acts as a default 31 | if true then 1.0 32 | ``` 33 | 34 | ### How does it help productivity 35 | 36 | Once developers and stakeholders have agreed on what simple logic will be extracted, it is possible to split the ownership in two: 37 | 38 | - **"when and how it's being used"**: developers own the context. Developers are responsible on how that logic is being used inside the codebase. 39 | - **"how is it computed "**: stakeholders own the behaviour. Stakeholders can update the logic themselves with total autonomy. 40 | 41 | 42 | The following diagram highlight this split, with a **ruleset** that computes a radius. 43 | 44 | ![Before/After schema](./before_after.png) 45 | 46 | 47 | | Before | After | 48 | |-------------------------------------------------------------------|-------| 49 | | 1⃣ Developers and stakeholders agree on a given solution | 1⃣ Same | 50 | | 2⃣ Stakeholders write specifications | 2⃣ Stakeholders and developers agree on a **signature**: which parameters the ruleset should take as well as its return type: **Developers create the ruleset on Regula.** 51 | | 3⃣ Developers implement the solution | 3⃣ Using Regula client, developpers evaluate the ruleset in their code by passing the correct parameters. **(Much shorter!)**| 52 | | 4⃣ Stakeholders update the specifications when the situation changes | 4⃣ **Stakeholders directly update the ruleset when needed** | 53 | | 5⃣ Developers update the code accordingly | ✨ **Developers are not involved at all** | 54 | 55 | The key point to remember here is that once a **ruleset** has been created, stakeholders can never break the contract of taking specific paremeter and returning a specific type. They can only change how the result is computed. It's that specific constraint that allows to split ownership. 56 | 57 | Regula takes an opinionated approach in implementing this solution in order to provide strong predictability and reliability. Those are detailed in the [design principles](#design-principles) section below. 58 | 59 | 60 | ### What Regula provides 61 | 62 | Regula is a solution available to backend and mobile developers as well as on the front-end. 63 | 64 | Currently, these are the supported environments: 65 | 66 | - Go 67 | - Elixir 68 | - Mobile Apps, through Go-mobile 69 | 70 | ## Terminology 71 | 72 | A **rule** is a condition that takes parameters and if matched, returns a **result** of a certain type. 73 | 74 | It can be illustrated as follows in pseudo-code: 75 | 76 | ``` 77 | # string city and string driver-status are parameters 78 | if city == "france-paris" AND driver-status == "gold" then 3.0 79 | ``` 80 | 81 | A **ruleset** is an ordered list of **rules** which return results of the same type. It is named which allows to identify it. Rules are evaluated from top to bottom until one matches. 82 | 83 | This is a **ruleset** named `/marketplace/radius` 84 | ``` 85 | if city == "france-paris" AND driver-status == "gold" then 3.0 86 | if city == "france-paris" then 2.0 87 | if city == "italy-milan" then 1.5 88 | if true then 1.0 89 | ``` 90 | 91 | A **signature** is the combination of a ruleset name, its parameters and their types and finally the result type. Once defined, it's set in stone and cannot be changed (to do so, create another ruleset). 92 | 93 | It can be illustrated as following (still in pseudo-code): 94 | 95 | ``` 96 | float64 /marketplace/radius(string city, string driver-status) 97 | ``` 98 | 99 | 100 | A **version** is an identifier that refers to a specific version of a ruleset. Each time a ruleset has at least one of its rules updated, a new **version** is created to identify it. 101 | 102 | **Ruleset version: ** `4` 103 | ``` 104 | # ruleset: /marketplace/radius 105 | if city == "france-paris" AND driver-status == "gold" then 3.0 106 | if city == "france-paris" then 2.0 107 | if city == "italy-milan" then 1.5 108 | if true then 1.0 109 | ``` 110 | 111 | Let's update the results on certain rules: 112 | 113 | **Ruleset version: ** `5` 114 | ``` 115 | # ruleset: /marketplace/radius 116 | if city == "france-paris" AND driver-status == "gold" then 6.0 117 | # a rule can updated in both its conditions and result 118 | if city == "france-paris" OR city == "france-lyon" then 4.0 119 | if city == "italy-milan" then 3.0 120 | if true then 1.0 121 | ``` 122 | 123 | ## Creating and updating rulesets 124 | 125 | Stakeholders can agree with developers and they will create a ruleset. Once created, stakeholders can edit rulesets with full autonomy, with the guarantee of not breaking the code. Updating a ruleset means, in concrete terms to modify one or many rules, by changing their conditions or their result. 126 | 127 | The decision to use Regula for a given business problem should be made by answering the following questions: 128 | 129 | - the behaviour often changes or is supposed to evolve a lot 130 | - the behaviour is relying entirely on business facts 131 | 132 | If the answer is yes for both of them, then creating a ruleset is a good idea. 133 | 134 | 135 | In the current release of Regula (`0.5.0`) stakeholders can only create or edit rules through scripts which require some engineering knowledge. Later version will include a proper UI for stakeholders to interact with Regula in a simple manner. 136 | 137 | 138 | ## Components overview 139 | 140 | > ⚠ TODO: we should discuss what we want to bring in here, this can get quite complex really quick. 141 | 142 | ![Cinematic](./cinematic.png) 143 | 144 | - Regula clients query Regula server 145 | - they live in the source code of a given application 146 | - when high performance is required, i.e. ruleset evaluations should be as fast as possible, there are caching mechanism available in some clients. See more about this in the technical documentation. 147 | 148 | - Regula servers answer requests from the clients. 149 | - Ruleset storage is where the rulesets and their versions are kept. It is not supposed to be interacted with directly. 150 | 151 | ## Design principles 152 | 153 | Rule engines are delicate beasts. It is important to remember that it's about extracting behaviour from the source code and relocating it outside in a more "accessible" place that is outside of any continuous integration mechanism. 154 | 155 | Regula takes an opinionated approach to lower the risks and make it harder to break from expected behaviours. Regula's team objective is to focus on a curated feature set, putting reliability and predictability at a top priority. 156 | 157 | ## Predictability 158 | 159 | Regula needs to be predictable because it splits the ownership of given functions of the software that uses it. Behaviour being owned by whoever edits the rules, it is mandatory that a given ruleset is not able to break the software that uses it. 160 | 161 | On the developers side: 162 | 163 | Regula is "typed" and that is why the concept of **signature** exists, illustrated by: 164 | 165 | ``` 166 | float64 /marketplace/radius(string city, string driver-status) 167 | ``` 168 | 169 | By doing so, it allows developers of both typed and dynamic languages to be certain that they always get a value of a given type. Changing the signature implies the creation of a new rule and such a break will logically requires modifying the source code of the application using Regula to reflect the change. 170 | 171 | On the stakeholders side: 172 | 173 | Regula rulesets are "versioned", meaning every update to rulesets are tracked. By doing so, it is possible for stakeholders to observe how the behaviour is evolving. 174 | 175 | Moreover, versioning allows stakeholders to take versions into consideration when designing a product. A given business object could be stored with the version of rulesets it is intended to use throughout its life. For example, if a given promotion coupon had been applied to a product being sold, by storing the version used, it can easily be computed again against that version, allowing to get the same exact result. 176 | 177 | By doing so, it enables stakeholders to know which path was taken to compute a given value, whether it's in the application logs or stored within a business object. 178 | 179 | ## Reliability 180 | 181 | Regula depends on two facts to be reliable: rulesets storage should be reliable, Regula itself should be reliable. 182 | 183 | Therefore: 184 | 185 | - All data is stored in an etcd store, which **should be distributed properly** (at least three instances in production, ideally on multiple avaibility zones). 186 | 187 | - There should be multiple instances of Regula, **which should be distributed properly** (at least two instances in production, ideally on multiple availability zones, so there is no single point of failure). 188 | -------------------------------------------------------------------------------- /engine.go: -------------------------------------------------------------------------------- 1 | package regula 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "sync" 7 | 8 | "github.com/heetch/confita" 9 | "github.com/heetch/confita/backend" 10 | "github.com/heetch/regula/rule" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // Engine is used to evaluate a ruleset against a group of parameters. 15 | // It provides a list of type safe methods to evaluate a ruleset and always returns the expected type to the caller. 16 | // The engine is stateless and relies on the given evaluator to evaluate a ruleset. 17 | // It is safe for concurrent use. 18 | type Engine struct { 19 | evaluator Evaluator 20 | } 21 | 22 | // NewEngine creates an Engine using the given evaluator. 23 | func NewEngine(evaluator Evaluator) *Engine { 24 | return &Engine{ 25 | evaluator: evaluator, 26 | } 27 | } 28 | 29 | // Get evaluates a ruleset and returns the result. 30 | func (e *Engine) get(ctx context.Context, typ, path string, params rule.Params, opts ...Option) (*EvalResult, error) { 31 | var cfg engineConfig 32 | for _, opt := range opts { 33 | opt(&cfg) 34 | } 35 | 36 | var ( 37 | result *EvalResult 38 | err error 39 | ) 40 | 41 | if cfg.Version != "" { 42 | result, err = e.evaluator.EvalVersion(ctx, path, cfg.Version, params) 43 | } else { 44 | result, err = e.evaluator.Eval(ctx, path, params) 45 | } 46 | if err != nil { 47 | if err == ErrRulesetNotFound || err == rule.ErrNoMatch { 48 | return nil, err 49 | } 50 | return nil, errors.Wrap(err, "failed to evaluate ruleset") 51 | } 52 | 53 | if result.Value.Type != typ { 54 | return nil, ErrTypeMismatch 55 | } 56 | 57 | return result, nil 58 | } 59 | 60 | // GetString evaluates a ruleset and returns the result as a string. 61 | func (e *Engine) GetString(ctx context.Context, path string, params rule.Params, opts ...Option) (string, *EvalResult, error) { 62 | res, err := e.get(ctx, "string", path, params, opts...) 63 | if err != nil { 64 | return "", nil, err 65 | } 66 | 67 | return res.Value.Data, res, nil 68 | } 69 | 70 | // GetBool evaluates a ruleset and returns the result as a bool. 71 | func (e *Engine) GetBool(ctx context.Context, path string, params rule.Params, opts ...Option) (bool, *EvalResult, error) { 72 | res, err := e.get(ctx, "bool", path, params, opts...) 73 | if err != nil { 74 | return false, nil, err 75 | } 76 | 77 | b, err := strconv.ParseBool(res.Value.Data) 78 | return b, res, err 79 | } 80 | 81 | // GetInt64 evaluates a ruleset and returns the result as an int64. 82 | func (e *Engine) GetInt64(ctx context.Context, path string, params rule.Params, opts ...Option) (int64, *EvalResult, error) { 83 | res, err := e.get(ctx, "int64", path, params, opts...) 84 | if err != nil { 85 | return 0, nil, err 86 | } 87 | 88 | i, err := strconv.ParseInt(res.Value.Data, 10, 64) 89 | return i, res, err 90 | } 91 | 92 | // GetFloat64 evaluates a ruleset and returns the result as a float64. 93 | func (e *Engine) GetFloat64(ctx context.Context, path string, params rule.Params, opts ...Option) (float64, *EvalResult, error) { 94 | res, err := e.get(ctx, "float64", path, params, opts...) 95 | if err != nil { 96 | return 0, nil, err 97 | } 98 | 99 | f, err := strconv.ParseFloat(res.Value.Data, 64) 100 | return f, res, err 101 | } 102 | 103 | // LoadStruct takes a pointer to struct and params and loads rulesets into fields 104 | // tagged with the "ruleset" struct tag. 105 | func (e *Engine) LoadStruct(ctx context.Context, to interface{}, params rule.Params) error { 106 | b := backend.Func("regula", func(ctx context.Context, path string) ([]byte, error) { 107 | res, err := e.evaluator.Eval(ctx, path, params) 108 | if err != nil { 109 | if err == ErrRulesetNotFound { 110 | return nil, backend.ErrNotFound 111 | } 112 | 113 | return nil, err 114 | } 115 | 116 | return []byte(res.Value.Data), nil 117 | }) 118 | 119 | l := confita.NewLoader(b) 120 | l.Tag = "ruleset" 121 | 122 | return l.Load(ctx, to) 123 | } 124 | 125 | type engineConfig struct { 126 | Version string 127 | } 128 | 129 | // Option is used to customize the engine behaviour. 130 | type Option func(cfg *engineConfig) 131 | 132 | // Version is an option used to describe which ruleset version the engine should return. 133 | func Version(version string) Option { 134 | return func(cfg *engineConfig) { 135 | cfg.Version = version 136 | } 137 | } 138 | 139 | // An Evaluator provides methods to evaluate rulesets from any location. 140 | // Long running implementations must listen to the given context for timeout and cancelation. 141 | type Evaluator interface { 142 | // Eval evaluates a ruleset using the given params. 143 | // If no ruleset is found for a given path, the implementation must return ErrRulesetNotFound. 144 | Eval(ctx context.Context, path string, params rule.Params) (*EvalResult, error) 145 | // EvalVersion evaluates a specific version of a ruleset using the given params. 146 | // If no ruleset is found for a given path, the implementation must return ErrRulesetNotFound. 147 | EvalVersion(ctx context.Context, path string, version string, params rule.Params) (*EvalResult, error) 148 | } 149 | 150 | // EvalResult is the product of an evaluation. It contains the value generated as long as some metadata. 151 | type EvalResult struct { 152 | // Result of the evaluation 153 | Value *rule.Value 154 | // Version of the ruleset that generated this value 155 | Version string 156 | } 157 | 158 | // RulesetBuffer can hold a group of rulesets in memory and can be used as an evaluator. 159 | // It is safe for concurrent use. 160 | type RulesetBuffer struct { 161 | rw sync.RWMutex 162 | rulesets map[string][]*rulesetInfo 163 | } 164 | 165 | // NewRulesetBuffer creates a ready to use RulesetBuffer. 166 | func NewRulesetBuffer() *RulesetBuffer { 167 | return &RulesetBuffer{ 168 | rulesets: make(map[string][]*rulesetInfo), 169 | } 170 | } 171 | 172 | type rulesetInfo struct { 173 | path, version string 174 | r *Ruleset 175 | } 176 | 177 | // Add adds the given ruleset version to a list for a specific path. 178 | // The last added ruleset is treated as the latest version. 179 | func (b *RulesetBuffer) Add(path, version string, r *Ruleset) { 180 | b.rw.Lock() 181 | b.rulesets[path] = append(b.rulesets[path], &rulesetInfo{path, version, r}) 182 | b.rw.Unlock() 183 | } 184 | 185 | // Latest returns the latest version of a ruleset. 186 | func (b *RulesetBuffer) Latest(path string) (*Ruleset, string, error) { 187 | b.rw.RLock() 188 | defer b.rw.RUnlock() 189 | 190 | l, ok := b.rulesets[path] 191 | if !ok || len(l) == 0 { 192 | return nil, "", ErrRulesetNotFound 193 | } 194 | 195 | return l[len(l)-1].r, l[len(l)-1].version, nil 196 | } 197 | 198 | // GetVersion returns a ruleset associated with the given path and version. 199 | func (b *RulesetBuffer) GetVersion(path, version string) (*Ruleset, error) { 200 | b.rw.RLock() 201 | defer b.rw.RUnlock() 202 | 203 | ri, err := b.getVersion(path, version) 204 | if err != nil { 205 | return nil, err 206 | } 207 | 208 | return ri.r, nil 209 | } 210 | 211 | // Eval evaluates the latest added ruleset or returns ErrRulesetNotFound if not found. 212 | func (b *RulesetBuffer) Eval(ctx context.Context, path string, params rule.Params) (*EvalResult, error) { 213 | b.rw.RLock() 214 | defer b.rw.RUnlock() 215 | 216 | l, ok := b.rulesets[path] 217 | if !ok || len(l) == 0 { 218 | return nil, ErrRulesetNotFound 219 | } 220 | 221 | ri := l[len(l)-1] 222 | v, err := ri.r.Eval(params) 223 | if err != nil { 224 | return nil, err 225 | } 226 | 227 | return &EvalResult{ 228 | Value: v, 229 | Version: ri.version, 230 | }, nil 231 | } 232 | 233 | func (b *RulesetBuffer) getVersion(path, version string) (*rulesetInfo, error) { 234 | l, ok := b.rulesets[path] 235 | if !ok || len(l) == 0 { 236 | return nil, ErrRulesetNotFound 237 | } 238 | 239 | for _, ri := range l { 240 | if ri.version == version { 241 | return ri, nil 242 | } 243 | } 244 | 245 | return nil, ErrRulesetNotFound 246 | } 247 | 248 | // EvalVersion evaluates the selected ruleset version or returns ErrRulesetNotFound if not found. 249 | func (b *RulesetBuffer) EvalVersion(ctx context.Context, path, version string, params rule.Params) (*EvalResult, error) { 250 | b.rw.RLock() 251 | defer b.rw.RUnlock() 252 | 253 | ri, err := b.getVersion(path, version) 254 | if err != nil { 255 | return nil, err 256 | } 257 | 258 | v, err := ri.r.Eval(params) 259 | if err != nil { 260 | return nil, err 261 | } 262 | 263 | return &EvalResult{ 264 | Value: v, 265 | Version: ri.version, 266 | }, nil 267 | } 268 | -------------------------------------------------------------------------------- /engine_test.go: -------------------------------------------------------------------------------- 1 | package regula_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/heetch/regula" 9 | "github.com/heetch/regula/rule" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestEngine(t *testing.T) { 14 | ctx := context.Background() 15 | 16 | buf := regula.NewRulesetBuffer() 17 | 18 | buf.Add("match-string-a", "1", ®ula.Ruleset{ 19 | Type: "string", 20 | Rules: []*rule.Rule{ 21 | rule.New(rule.Eq(rule.StringParam("foo"), rule.StringValue("bar")), rule.StringValue("matched a v1")), 22 | }, 23 | }) 24 | buf.Add("match-string-a", "2", ®ula.Ruleset{ 25 | Type: "string", 26 | Rules: []*rule.Rule{ 27 | rule.New(rule.Eq(rule.StringParam("foo"), rule.StringValue("bar")), rule.StringValue("matched a v2")), 28 | }, 29 | }) 30 | buf.Add("match-string-b", "1", ®ula.Ruleset{ 31 | Type: "string", 32 | Rules: []*rule.Rule{ 33 | rule.New(rule.True(), rule.StringValue("matched b")), 34 | }, 35 | }) 36 | buf.Add("type-mismatch", "1", ®ula.Ruleset{ 37 | Type: "string", 38 | Rules: []*rule.Rule{ 39 | rule.New(rule.True(), &rule.Value{Type: "int", Data: "5"}), 40 | }, 41 | }) 42 | buf.Add("no-match", "1", ®ula.Ruleset{ 43 | Type: "string", 44 | Rules: []*rule.Rule{ 45 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("bar")), rule.StringValue("matched d")), 46 | }, 47 | }) 48 | buf.Add("match-bool", "1", ®ula.Ruleset{ 49 | Type: "bool", 50 | Rules: []*rule.Rule{ 51 | rule.New(rule.True(), &rule.Value{Type: "bool", Data: "true"}), 52 | }, 53 | }) 54 | buf.Add("match-int64", "1", ®ula.Ruleset{ 55 | Type: "int64", 56 | Rules: []*rule.Rule{ 57 | rule.New(rule.True(), &rule.Value{Type: "int64", Data: "-10"}), 58 | }, 59 | }) 60 | buf.Add("match-float64", "1", ®ula.Ruleset{ 61 | Type: "float64", 62 | Rules: []*rule.Rule{ 63 | rule.New(rule.True(), &rule.Value{Type: "float64", Data: "-3.14"}), 64 | }, 65 | }) 66 | buf.Add("match-duration", "1", ®ula.Ruleset{ 67 | Type: "string", 68 | Rules: []*rule.Rule{ 69 | rule.New(rule.True(), rule.StringValue("3s")), 70 | }, 71 | }) 72 | 73 | e := regula.NewEngine(buf) 74 | 75 | t.Run("LowLevel", func(t *testing.T) { 76 | str, res, err := e.GetString(ctx, "match-string-a", regula.Params{ 77 | "foo": "bar", 78 | }) 79 | require.NoError(t, err) 80 | require.Equal(t, "matched a v2", str) 81 | require.Equal(t, "2", res.Version) 82 | 83 | str, res, err = e.GetString(ctx, "match-string-a", regula.Params{ 84 | "foo": "bar", 85 | }, regula.Version("1")) 86 | require.NoError(t, err) 87 | require.Equal(t, "matched a v1", str) 88 | require.Equal(t, "1", res.Version) 89 | 90 | str, _, err = e.GetString(ctx, "match-string-b", nil) 91 | require.NoError(t, err) 92 | require.Equal(t, "matched b", str) 93 | 94 | b, _, err := e.GetBool(ctx, "match-bool", nil) 95 | require.NoError(t, err) 96 | require.True(t, b) 97 | 98 | i, _, err := e.GetInt64(ctx, "match-int64", nil) 99 | require.NoError(t, err) 100 | require.Equal(t, int64(-10), i) 101 | 102 | f, _, err := e.GetFloat64(ctx, "match-float64", nil) 103 | require.NoError(t, err) 104 | require.Equal(t, -3.14, f) 105 | 106 | _, _, err = e.GetString(ctx, "match-bool", nil) 107 | require.Equal(t, regula.ErrTypeMismatch, err) 108 | 109 | _, _, err = e.GetString(ctx, "type-mismatch", nil) 110 | require.Equal(t, regula.ErrTypeMismatch, err) 111 | 112 | _, _, err = e.GetString(ctx, "no-match", nil) 113 | require.Equal(t, rule.ErrNoMatch, err) 114 | 115 | _, _, err = e.GetString(ctx, "not-found", nil) 116 | require.Equal(t, regula.ErrRulesetNotFound, err) 117 | }) 118 | 119 | t.Run("StructLoading", func(t *testing.T) { 120 | to := struct { 121 | StringA string `ruleset:"match-string-a"` 122 | Bool bool `ruleset:"match-bool"` 123 | Int64 int64 `ruleset:"match-int64"` 124 | Float64 float64 `ruleset:"match-float64"` 125 | Duration time.Duration `ruleset:"match-duration"` 126 | }{} 127 | 128 | err := e.LoadStruct(ctx, &to, regula.Params{ 129 | "foo": "bar", 130 | }) 131 | 132 | require.NoError(t, err) 133 | require.Equal(t, "matched a v2", to.StringA) 134 | require.Equal(t, true, to.Bool) 135 | require.Equal(t, int64(-10), to.Int64) 136 | require.Equal(t, -3.14, to.Float64) 137 | require.Equal(t, 3*time.Second, to.Duration) 138 | }) 139 | 140 | t.Run("StructLoadingWrongKey", func(t *testing.T) { 141 | to := struct { 142 | StringA string `ruleset:"match-string-a,required"` 143 | Wrong string `ruleset:"no-exists,required"` 144 | }{} 145 | 146 | err := e.LoadStruct(ctx, &to, regula.Params{ 147 | "foo": "bar", 148 | }) 149 | 150 | require.Error(t, err) 151 | }) 152 | 153 | t.Run("StructLoadingMissingParam", func(t *testing.T) { 154 | to := struct { 155 | StringA string `ruleset:"match-string-a"` 156 | }{} 157 | 158 | err := e.LoadStruct(ctx, &to, nil) 159 | 160 | require.Error(t, err) 161 | }) 162 | } 163 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package regula 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrTypeMismatch is returned when the evaluated rule doesn't return the expected result type. 7 | ErrTypeMismatch = errors.New("type returned by rule doesn't match") 8 | 9 | // ErrRulesetNotFound must be returned when no ruleset is found for a given key. 10 | ErrRulesetNotFound = errors.New("ruleset not found") 11 | 12 | // ErrRulesetIncoherentType is returned when a ruleset contains rules of different types. 13 | ErrRulesetIncoherentType = errors.New("types in ruleset are incoherent") 14 | ) 15 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package regula_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/heetch/regula/rule" 10 | 11 | "github.com/heetch/regula" 12 | ) 13 | 14 | func ExampleRuleset() { 15 | rs, err := regula.NewStringRuleset( 16 | rule.New( 17 | rule.Eq( 18 | rule.StringParam("group"), 19 | rule.StringValue("admin"), 20 | ), 21 | rule.StringValue("first rule matched"), 22 | ), 23 | rule.New( 24 | rule.In( 25 | rule.Int64Param("score"), 26 | rule.Int64Value(10), 27 | rule.Int64Value(20), 28 | rule.Int64Value(30), 29 | ), 30 | rule.StringValue("second rule matched"), 31 | ), 32 | rule.New( 33 | rule.True(), 34 | rule.StringValue("default rule matched"), 35 | ), 36 | ) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | ret, err := rs.Eval(regula.Params{ 42 | "group": "staff", 43 | "score": int64(20), 44 | }) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | fmt.Println(ret.Data) 50 | // Output 51 | // second rule matched 52 | } 53 | 54 | var ev regula.Evaluator 55 | 56 | func init() { 57 | buf := regula.NewRulesetBuffer() 58 | ev = buf 59 | 60 | buf.Add("/a/b/c", "5b4cbdf307bb5346a6c42ac3", ®ula.Ruleset{ 61 | Type: "string", 62 | Rules: []*rule.Rule{ 63 | rule.New(rule.True(), rule.StringValue("some-string")), 64 | }, 65 | }) 66 | 67 | buf.Add("/path/to/string/key", "5b4cbdf307bb5346a6c42ac3", ®ula.Ruleset{ 68 | Type: "string", 69 | Rules: []*rule.Rule{ 70 | rule.New(rule.True(), rule.StringValue("some-string")), 71 | }, 72 | }) 73 | 74 | buf.Add("/path/to/int64/key", "5b4cbdf307bb5346a6c42ac3", ®ula.Ruleset{ 75 | Type: "int64", 76 | Rules: []*rule.Rule{ 77 | rule.New(rule.True(), rule.Int64Value(10)), 78 | }, 79 | }) 80 | 81 | buf.Add("/path/to/float64/key", "5b4cbdf307bb5346a6c42ac3", ®ula.Ruleset{ 82 | Type: "float64", 83 | Rules: []*rule.Rule{ 84 | rule.New(rule.True(), rule.Float64Value(3.14)), 85 | }, 86 | }) 87 | 88 | buf.Add("/path/to/bool/key", "5b4cbdf307bb5346a6c42ac3", ®ula.Ruleset{ 89 | Type: "bool", 90 | Rules: []*rule.Rule{ 91 | rule.New(rule.True(), rule.BoolValue(true)), 92 | }, 93 | }) 94 | 95 | buf.Add("/path/to/duration/key", "5b4cbdf307bb5346a6c42ac3", ®ula.Ruleset{ 96 | Type: "string", 97 | Rules: []*rule.Rule{ 98 | rule.New(rule.True(), rule.StringValue("3s")), 99 | }, 100 | }) 101 | } 102 | 103 | func ExampleEngine() { 104 | engine := regula.NewEngine(ev) 105 | 106 | str, res, err := engine.GetString(context.Background(), "/a/b/c", regula.Params{ 107 | "product-id": "1234", 108 | "user-id": "5678", 109 | }) 110 | 111 | if err != nil { 112 | switch err { 113 | case regula.ErrRulesetNotFound: 114 | // when the ruleset doesn't exist 115 | case regula.ErrTypeMismatch: 116 | // when the ruleset returns the bad type 117 | case rule.ErrNoMatch: 118 | // when the ruleset doesn't match 119 | default: 120 | // something unexpected happened 121 | } 122 | } 123 | 124 | fmt.Println(str) 125 | fmt.Println(res.Version) 126 | // Output 127 | // some-string 128 | // 5b4cbdf307bb5346a6c42ac3 129 | } 130 | 131 | func ExampleEngine_GetBool() { 132 | engine := regula.NewEngine(ev) 133 | 134 | b, res, err := engine.GetBool(context.Background(), "/path/to/bool/key", regula.Params{ 135 | "product-id": "1234", 136 | "user-id": "5678", 137 | }) 138 | 139 | if err != nil { 140 | log.Fatal(err) 141 | } 142 | 143 | fmt.Println(b) 144 | fmt.Println(res.Version) 145 | // Output 146 | // true 147 | // 5b4cbdf307bb5346a6c42ac3 148 | } 149 | 150 | func ExampleEngine_GetString() { 151 | engine := regula.NewEngine(ev) 152 | 153 | s, res, err := engine.GetString(context.Background(), "/path/to/string/key", regula.Params{ 154 | "product-id": "1234", 155 | "user-id": "5678", 156 | }) 157 | 158 | if err != nil { 159 | log.Fatal(err) 160 | } 161 | 162 | fmt.Println(s) 163 | fmt.Println(res.Version) 164 | // Output 165 | // some-string 166 | // 5b4cbdf307bb5346a6c42ac3 167 | } 168 | 169 | func ExampleEngine_GetInt64() { 170 | engine := regula.NewEngine(ev) 171 | 172 | i, res, err := engine.GetInt64(context.Background(), "/path/to/int64/key", regula.Params{ 173 | "product-id": "1234", 174 | "user-id": "5678", 175 | }) 176 | 177 | if err != nil { 178 | log.Fatal(err) 179 | } 180 | 181 | fmt.Println(i) 182 | fmt.Println(res.Version) 183 | // Output 184 | // 10 185 | // 5b4cbdf307bb5346a6c42ac3 186 | } 187 | 188 | func ExampleEngine_GetFloat64() { 189 | engine := regula.NewEngine(ev) 190 | 191 | f, res, err := engine.GetFloat64(context.Background(), "/path/to/float64/key", regula.Params{ 192 | "product-id": "1234", 193 | "user-id": "5678", 194 | }) 195 | 196 | if err != nil { 197 | log.Fatal(err) 198 | } 199 | 200 | fmt.Println(f) 201 | fmt.Println(res.Version) 202 | // Output 203 | // 3.14 204 | // 5b4cbdf307bb5346a6c42ac3 205 | } 206 | 207 | func ExampleEngine_LoadStruct() { 208 | type Values struct { 209 | A string `ruleset:"/path/to/string/key"` 210 | B int64 `ruleset:"/path/to/int64/key,required"` 211 | C time.Duration `ruleset:"/path/to/duration/key"` 212 | } 213 | 214 | var v Values 215 | 216 | engine := regula.NewEngine(ev) 217 | 218 | err := engine.LoadStruct(context.Background(), &v, regula.Params{ 219 | "product-id": "1234", 220 | "user-id": "5678", 221 | }) 222 | 223 | if err != nil { 224 | log.Fatal(err) 225 | } 226 | 227 | fmt.Println(v.A) 228 | fmt.Println(v.B) 229 | fmt.Println(v.C) 230 | // Output: 231 | // some-string 232 | // 10 233 | // 3s 234 | } 235 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/heetch/regula 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/coreos/bbolt v1.3.2 // indirect 7 | github.com/coreos/etcd v3.3.9+incompatible 8 | github.com/coreos/go-semver v0.3.0 // indirect 9 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e // indirect 10 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect 11 | github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect 12 | github.com/gogo/protobuf v1.1.1 // indirect 13 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef // indirect 14 | github.com/google/btree v1.0.0 // indirect 15 | github.com/gorilla/websocket v1.4.0 // indirect 16 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect 17 | github.com/grpc-ecosystem/grpc-gateway v1.9.0 // indirect 18 | github.com/heetch/confita v0.5.1 19 | github.com/jonboulle/clockwork v0.1.0 // indirect 20 | github.com/mattn/go-isatty v0.0.3 21 | github.com/pkg/errors v0.8.0 22 | github.com/prometheus/client_golang v0.9.2 // indirect 23 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 // indirect 24 | github.com/rs/xid v1.2.0 // indirect 25 | github.com/rs/zerolog v1.8.0 26 | github.com/segmentio/ksuid v1.0.1 27 | github.com/sirupsen/logrus v1.4.1 // indirect 28 | github.com/soheilhy/cmux v0.1.4 // indirect 29 | github.com/stretchr/testify v1.2.2 30 | github.com/tidwall/gjson v1.1.3 31 | github.com/tidwall/match v1.0.0 // indirect 32 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 // indirect 33 | github.com/ugorji/go v1.1.4 // indirect 34 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect 35 | github.com/zenazn/goji v0.0.0-20160507202103-64eb34159fe5 // indirect 36 | go.etcd.io/bbolt v1.3.2 // indirect 37 | golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f // indirect 38 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 39 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect 40 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= 4 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 5 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 6 | github.com/coreos/bbolt v1.3.2 h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s= 7 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 8 | github.com/coreos/etcd v3.3.9+incompatible h1:iKSVPXGNGqroBx4+RmUXv8emeU7y+ucRZSzTYgzLZwM= 9 | github.com/coreos/etcd v3.3.9+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 10 | github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= 11 | github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 12 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8= 13 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 14 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg= 15 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 19 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 20 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 21 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 22 | github.com/gogo/protobuf v1.1.1 h1:72R+M5VuhED/KujmZVcIquuo8mBgX4oVda//DQb3PXo= 23 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 24 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= 25 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 26 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= 27 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 28 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 29 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 30 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 31 | github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= 32 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 33 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 34 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 35 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= 36 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 37 | github.com/grpc-ecosystem/grpc-gateway v1.9.0 h1:bM6ZAFZmc/wPFaRDi0d5L7hGEZEx/2u+Tmr2evNHDiI= 38 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 39 | github.com/heetch/confita v0.5.1 h1:EiE32j+Ze0sI0YBeJDSdqTZ32uKz2XCTQIzSgwgfnvk= 40 | github.com/heetch/confita v0.5.1/go.mod h1:S8Em4kuK8pR5vfTiaNkFLfNDMlGF/EtQUaCxDhXRpCs= 41 | github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= 42 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 43 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 44 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 45 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 46 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 47 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 48 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 49 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 50 | github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= 51 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 52 | github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 53 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 54 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 55 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 56 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 57 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 | github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= 59 | github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= 60 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 61 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= 62 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 63 | github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8= 64 | github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 65 | github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE= 66 | github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 67 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 68 | github.com/rs/xid v1.2.0 h1:qRPemPiF/Pl06j+Pp5kjRpgRmUJCsfdPcFo/LZlsobA= 69 | github.com/rs/xid v1.2.0/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 70 | github.com/rs/zerolog v1.8.0 h1:Oglcb4i6h42uWacEjomB2MI8gfkwCwTMFaDY3+Vgj5k= 71 | github.com/rs/zerolog v1.8.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= 72 | github.com/segmentio/ksuid v1.0.1 h1:O/0HN9qcXwqemHNVUT0L24al4IQLjwOFw5mWUy5wunE= 73 | github.com/segmentio/ksuid v1.0.1/go.mod h1:BXuJDr2byAiHuQaQtSKoXh1J0YmUDurywOXgB2w+OSU= 74 | github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= 75 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 76 | github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E= 77 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 78 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 79 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 80 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 81 | github.com/tidwall/gjson v1.1.3 h1:u4mspaByxY+Qk4U1QYYVzGFI8qxN/3jtEV0ZDb2vRic= 82 | github.com/tidwall/gjson v1.1.3/go.mod h1:c/nTNbUr0E0OrXEhq1pwa8iEgc2DOt4ZZqAt1HtCkPA= 83 | github.com/tidwall/match v1.0.0 h1:Ym1EcFkp+UQ4ptxfWlW+iMdq5cPH5nEuGzdf/Pb7VmI= 84 | github.com/tidwall/match v1.0.0/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= 85 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 h1:LnC5Kc/wtumK+WB441p7ynQJzVuNRJiqddSIE3IlSEQ= 86 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 87 | github.com/ugorji/go v1.1.4 h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw= 88 | github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= 89 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= 90 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 91 | github.com/zenazn/goji v0.0.0-20160507202103-64eb34159fe5 h1:u8oGm2Ef+uUdJIbBXJvdPqKeo1u8NPGMtWH521eW2xA= 92 | github.com/zenazn/goji v0.0.0-20160507202103-64eb34159fe5/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 93 | go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk= 94 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 95 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 96 | golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f h1:R423Cnkcp5JABoeemiGEPlt9tHXFfw5kvc0yqlxRPWo= 97 | golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 98 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 99 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 100 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 101 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 102 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= 103 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 104 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 105 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 106 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 107 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= 108 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 109 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 110 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 111 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 112 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 113 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 114 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 115 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 116 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 117 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= 118 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 119 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 120 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 121 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= 122 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 123 | google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8= 124 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 125 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 126 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 127 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 128 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7 h1:+t9dhfO+GNOIGJof6kPOAenx7YgrZMTdRPV+EsnPabk= 129 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 130 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 131 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Regula 2 | 3 | repo_url: https://github.com/heetch/regula/ 4 | 5 | pages: 6 | - Introduction: index.md 7 | - "Libraries & SDKs": 8 | - 'Golang library quickstart': 'clients/go.md' -------------------------------------------------------------------------------- /param.go: -------------------------------------------------------------------------------- 1 | package regula 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/heetch/regula/rule" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // Params is a map based rule.Params implementation. 11 | type Params map[string]interface{} 12 | 13 | // GetString extracts a string parameter corresponding to the given key. 14 | func (p Params) GetString(key string) (string, error) { 15 | v, ok := p[key] 16 | if !ok { 17 | return "", rule.ErrParamNotFound 18 | } 19 | 20 | s, ok := v.(string) 21 | if !ok { 22 | return "", rule.ErrParamTypeMismatch 23 | } 24 | 25 | return s, nil 26 | } 27 | 28 | // GetBool extracts a bool parameter corresponding to the given key. 29 | func (p Params) GetBool(key string) (bool, error) { 30 | v, ok := p[key] 31 | if !ok { 32 | return false, rule.ErrParamNotFound 33 | } 34 | 35 | b, ok := v.(bool) 36 | if !ok { 37 | return false, rule.ErrParamTypeMismatch 38 | } 39 | 40 | return b, nil 41 | } 42 | 43 | // GetInt64 extracts an int64 parameter corresponding to the given key. 44 | func (p Params) GetInt64(key string) (int64, error) { 45 | v, ok := p[key] 46 | if !ok { 47 | return 0, rule.ErrParamNotFound 48 | } 49 | 50 | i, ok := v.(int64) 51 | if !ok { 52 | return 0, rule.ErrParamTypeMismatch 53 | } 54 | 55 | return i, nil 56 | } 57 | 58 | // GetFloat64 extracts a float64 parameter corresponding to the given key. 59 | func (p Params) GetFloat64(key string) (float64, error) { 60 | v, ok := p[key] 61 | if !ok { 62 | return 0, rule.ErrParamNotFound 63 | } 64 | 65 | f, ok := v.(float64) 66 | if !ok { 67 | return 0, rule.ErrParamTypeMismatch 68 | } 69 | 70 | return f, nil 71 | } 72 | 73 | // Keys returns the list of all the keys. 74 | func (p Params) Keys() []string { 75 | keys := make([]string, 0, len(p)) 76 | for k := range p { 77 | keys = append(keys, k) 78 | } 79 | 80 | return keys 81 | } 82 | 83 | // EncodeValue returns the string representation of the selected value. 84 | func (p Params) EncodeValue(key string) (string, error) { 85 | v, ok := p[key] 86 | if !ok { 87 | return "", rule.ErrParamNotFound 88 | } 89 | 90 | switch t := v.(type) { 91 | case string: 92 | return t, nil 93 | case int64: 94 | return strconv.FormatInt(t, 10), nil 95 | case float64: 96 | return strconv.FormatFloat(t, 'f', 6, 64), nil 97 | case bool: 98 | return strconv.FormatBool(t), nil 99 | default: 100 | return "", errors.Errorf("type %t is not supported", t) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /param_test.go: -------------------------------------------------------------------------------- 1 | package regula 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/heetch/regula/rule" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetString(t *testing.T) { 11 | p := Params{ 12 | "string": "string", 13 | "bool": true, 14 | } 15 | 16 | t.Run("GetString - OK", func(t *testing.T) { 17 | v, err := p.GetString("string") 18 | require.NoError(t, err) 19 | require.Equal(t, "string", v) 20 | }) 21 | 22 | t.Run("GetString - NOK - ErrParamNotFound", func(t *testing.T) { 23 | _, err := p.GetString("badkey") 24 | require.Error(t, err) 25 | require.Equal(t, err, rule.ErrParamNotFound) 26 | }) 27 | 28 | t.Run("GetString - NOK - ErrParamTypeMismatch", func(t *testing.T) { 29 | _, err := p.GetString("bool") 30 | require.Error(t, err) 31 | require.Equal(t, err, rule.ErrParamTypeMismatch) 32 | }) 33 | } 34 | 35 | func TestGetBool(t *testing.T) { 36 | p := Params{ 37 | "bool": true, 38 | "string": "string", 39 | } 40 | 41 | t.Run("GetBool - OK", func(t *testing.T) { 42 | v, err := p.GetBool("bool") 43 | require.NoError(t, err) 44 | require.Equal(t, true, v) 45 | }) 46 | 47 | t.Run("GetBool - NOK - ErrParamNotFound", func(t *testing.T) { 48 | _, err := p.GetBool("badkey") 49 | require.Error(t, err) 50 | require.Equal(t, err, rule.ErrParamNotFound) 51 | }) 52 | 53 | t.Run("GetBool - NOK - ErrParamTypeMismatch", func(t *testing.T) { 54 | _, err := p.GetBool("string") 55 | require.Error(t, err) 56 | require.Equal(t, err, rule.ErrParamTypeMismatch) 57 | }) 58 | } 59 | 60 | func TestGetInt64(t *testing.T) { 61 | p := Params{ 62 | "int64": int64(42), 63 | "string": "string", 64 | } 65 | 66 | t.Run("GetInt64 - OK", func(t *testing.T) { 67 | v, err := p.GetInt64("int64") 68 | require.NoError(t, err) 69 | require.Equal(t, int64(42), v) 70 | }) 71 | 72 | t.Run("GetInt64 - NOK - ErrParamNotFound", func(t *testing.T) { 73 | _, err := p.GetInt64("badkey") 74 | require.Error(t, err) 75 | require.Equal(t, err, rule.ErrParamNotFound) 76 | }) 77 | 78 | t.Run("GetInt64 - NOK - ErrParamTypeMismatch", func(t *testing.T) { 79 | _, err := p.GetInt64("string") 80 | require.Error(t, err) 81 | require.Equal(t, err, rule.ErrParamTypeMismatch) 82 | }) 83 | } 84 | 85 | func TestGetFloat64(t *testing.T) { 86 | p := Params{ 87 | "float64": 42.42, 88 | "string": "string", 89 | } 90 | 91 | t.Run("GetFloat64 - OK", func(t *testing.T) { 92 | v, err := p.GetFloat64("float64") 93 | require.NoError(t, err) 94 | require.Equal(t, 42.42, v) 95 | }) 96 | 97 | t.Run("GetFloat64 - NOK - ErrParamNotFound", func(t *testing.T) { 98 | _, err := p.GetFloat64("badkey") 99 | require.Error(t, err) 100 | require.Equal(t, err, rule.ErrParamNotFound) 101 | }) 102 | 103 | t.Run("GetFloat64 - NOK - ErrParamTypeMismatch", func(t *testing.T) { 104 | _, err := p.GetFloat64("string") 105 | require.Error(t, err) 106 | require.Equal(t, err, rule.ErrParamTypeMismatch) 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /rule/example_test.go: -------------------------------------------------------------------------------- 1 | package rule_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/heetch/regula" 8 | "github.com/heetch/regula/rule" 9 | ) 10 | 11 | func ExampleRule() { 12 | r := rule.New( 13 | rule.Eq( 14 | rule.StringValue("foo"), 15 | rule.StringParam("bar"), 16 | ), 17 | rule.StringValue("matched"), 18 | ) 19 | 20 | ret, err := r.Eval(regula.Params{ 21 | "bar": "foo", 22 | }) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | fmt.Println(ret.Data) 28 | // Output 29 | // matched 30 | } 31 | 32 | func ExampleAnd() { 33 | tree := rule.And( 34 | rule.Eq( 35 | rule.Int64Value(10), 36 | rule.Int64Param("foo"), 37 | ), 38 | rule.Not( 39 | rule.Eq( 40 | rule.Float64Value(1.5), 41 | rule.Float64Param("bar"), 42 | ), 43 | ), 44 | ) 45 | 46 | val, err := tree.Eval(regula.Params{ 47 | "foo": int64(10), 48 | "bar": 1.6, 49 | }) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | 54 | fmt.Println(val.Data) 55 | // Output: true 56 | } 57 | 58 | func ExampleOr() { 59 | tree := rule.Or( 60 | rule.Eq( 61 | rule.Float64Value(1.2), 62 | rule.Float64Param("foo"), 63 | ), 64 | rule.Eq( 65 | rule.Float64Value(3.14), 66 | rule.Float64Param("foo"), 67 | ), 68 | ) 69 | 70 | val, err := tree.Eval(regula.Params{ 71 | "foo": 3.14, 72 | }) 73 | if err != nil { 74 | log.Fatal(err) 75 | } 76 | 77 | fmt.Println(val.Data) 78 | // Output: true 79 | } 80 | 81 | func ExampleEq_string() { 82 | tree := rule.Eq( 83 | rule.StringValue("bar"), 84 | rule.StringValue("bar"), 85 | rule.StringParam("foo"), 86 | ) 87 | 88 | val, err := tree.Eval(regula.Params{ 89 | "foo": "bar", 90 | }) 91 | if err != nil { 92 | log.Fatal(err) 93 | } 94 | 95 | fmt.Println(val.Data) 96 | // Output: true 97 | } 98 | 99 | func ExampleEq_bool() { 100 | tree := rule.Eq( 101 | rule.BoolValue(false), 102 | rule.Not(rule.BoolValue(true)), 103 | rule.Eq( 104 | rule.StringValue("bar"), 105 | rule.StringValue("baz"), 106 | ), 107 | ) 108 | 109 | val, err := tree.Eval(regula.Params{ 110 | "foo": "bar", 111 | }) 112 | if err != nil { 113 | log.Fatal(err) 114 | } 115 | 116 | fmt.Println(val.Data) 117 | // Output: true 118 | } 119 | 120 | func ExampleEq_int64() { 121 | tree := rule.Eq( 122 | rule.Int64Value(10), 123 | rule.Int64Param("foo"), 124 | ) 125 | 126 | val, err := tree.Eval(regula.Params{ 127 | "foo": int64(10), 128 | }) 129 | if err != nil { 130 | log.Fatal(err) 131 | } 132 | 133 | fmt.Println(val.Data) 134 | // Output: true 135 | } 136 | 137 | func ExampleEq_float64() { 138 | tree := rule.Eq( 139 | rule.Float64Value(3.14), 140 | rule.Float64Param("foo"), 141 | ) 142 | 143 | val, err := tree.Eval(regula.Params{ 144 | "foo": 3.14, 145 | }) 146 | if err != nil { 147 | log.Fatal(err) 148 | } 149 | 150 | fmt.Println(val.Data) 151 | // Output: true 152 | } 153 | 154 | func ExampleIn() { 155 | tree := rule.In( 156 | rule.StringValue("c"), 157 | rule.StringValue("a"), 158 | rule.StringValue("b"), 159 | rule.StringValue("c"), 160 | rule.StringValue("d"), 161 | ) 162 | 163 | val, err := tree.Eval(nil) 164 | if err != nil { 165 | log.Fatal(err) 166 | } 167 | 168 | fmt.Println(val.Data) 169 | // Output: true 170 | } 171 | 172 | func ExampleNot() { 173 | tree := rule.Not(rule.BoolValue(false)) 174 | 175 | val, err := tree.Eval(nil) 176 | if err != nil { 177 | log.Fatal(err) 178 | } 179 | 180 | fmt.Println(val.Data) 181 | // Output: true 182 | } 183 | 184 | func ExampleStringParam() { 185 | tree := rule.StringParam("foo") 186 | 187 | val, err := tree.Eval(regula.Params{ 188 | "foo": "bar", 189 | }) 190 | if err != nil { 191 | log.Fatal(err) 192 | } 193 | 194 | fmt.Println(val.Data) 195 | // Output: bar 196 | } 197 | 198 | func ExampleTrue() { 199 | tree := rule.True() 200 | 201 | val, err := tree.Eval(nil) 202 | if err != nil { 203 | log.Fatal(err) 204 | } 205 | 206 | fmt.Println(val.Data) 207 | // Output: true 208 | } 209 | -------------------------------------------------------------------------------- /rule/expr_internal_test.go: -------------------------------------------------------------------------------- 1 | package rule 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestParseBoolValues(t *testing.T) { 10 | t.Run("OK", func(t *testing.T) { 11 | v1 := newValue("bool", "true") 12 | v2 := newValue("bool", "false") 13 | 14 | b1, b2, err := parseBoolValues(v1, v2) 15 | require.NoError(t, err) 16 | require.True(t, b1) 17 | require.False(t, b2) 18 | }) 19 | 20 | t.Run("Fail 1st Value", func(t *testing.T) { 21 | v1 := newValue("bool", "foo") 22 | v2 := newValue("bool", "false") 23 | _, _, err := parseBoolValues(v1, v2) 24 | require.Error(t, err) 25 | require.Equal(t, err.Error(), `strconv.ParseBool: parsing "foo": invalid syntax`) 26 | }) 27 | 28 | t.Run("Fail 2nd Value", func(t *testing.T) { 29 | v1 := newValue("bool", "true") 30 | v2 := newValue("bool", "bar") 31 | _, _, err := parseBoolValues(v1, v2) 32 | require.Error(t, err) 33 | require.Equal(t, err.Error(), `strconv.ParseBool: parsing "bar": invalid syntax`) 34 | }) 35 | } 36 | 37 | func TestParseInt64Values(t *testing.T) { 38 | t.Run("OK", func(t *testing.T) { 39 | v1 := newValue("int64", "123") 40 | v2 := newValue("int64", "456") 41 | 42 | i1, i2, err := parseInt64Values(v1, v2) 43 | require.NoError(t, err) 44 | require.Equal(t, int64(123), i1) 45 | require.Equal(t, int64(456), i2) 46 | }) 47 | 48 | t.Run("Fail 1st Value", func(t *testing.T) { 49 | v1 := newValue("int64", "foo") 50 | v2 := newValue("int64", "456") 51 | 52 | _, _, err := parseInt64Values(v1, v2) 53 | require.Error(t, err) 54 | require.Equal(t, err.Error(), `strconv.ParseInt: parsing "foo": invalid syntax`) 55 | }) 56 | 57 | t.Run("Fail 2nd Value", func(t *testing.T) { 58 | v1 := newValue("int64", "123") 59 | v2 := newValue("int64", "bar") 60 | 61 | _, _, err := parseInt64Values(v1, v2) 62 | require.Error(t, err) 63 | require.Equal(t, err.Error(), `strconv.ParseInt: parsing "bar": invalid syntax`) 64 | }) 65 | } 66 | 67 | func TestParseFloat64Values(t *testing.T) { 68 | t.Run("OK", func(t *testing.T) { 69 | v1 := newValue("float64", "12.3") 70 | v2 := newValue("float64", "45.6") 71 | 72 | f1, f2, err := parseFloat64Values(v1, v2) 73 | require.NoError(t, err) 74 | require.Equal(t, float64(12.3), f1) 75 | require.Equal(t, float64(45.6), f2) 76 | }) 77 | 78 | t.Run("Fail 1st Value", func(t *testing.T) { 79 | v1 := newValue("float64", "foo") 80 | v2 := newValue("float64", "45.6") 81 | 82 | _, _, err := parseFloat64Values(v1, v2) 83 | require.Error(t, err) 84 | require.Equal(t, err.Error(), `strconv.ParseFloat: parsing "foo": invalid syntax`) 85 | }) 86 | 87 | t.Run("Fail 2nd Value", func(t *testing.T) { 88 | v1 := newValue("float64", "12.3") 89 | v2 := newValue("float64", "bar") 90 | 91 | _, _, err := parseFloat64Values(v1, v2) 92 | require.Error(t, err) 93 | require.Equal(t, err.Error(), `strconv.ParseFloat: parsing "bar": invalid syntax`) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /rule/expr_test.go: -------------------------------------------------------------------------------- 1 | package rule_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/heetch/regula" 7 | "github.com/heetch/regula/rule" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type mockExpr struct { 12 | val *rule.Value 13 | err error 14 | evalFn func(params rule.Params) (*rule.Value, error) 15 | evalCount int 16 | lastParams rule.Params 17 | } 18 | 19 | func (m *mockExpr) Eval(params rule.Params) (*rule.Value, error) { 20 | m.evalCount++ 21 | m.lastParams = params 22 | 23 | if m.evalFn != nil { 24 | return m.evalFn(params) 25 | } 26 | 27 | return m.val, m.err 28 | } 29 | 30 | func (m *mockExpr) MarshalJSON() ([]byte, error) { 31 | return []byte(`{"kind": "mock"}`), nil 32 | } 33 | 34 | func TestNot(t *testing.T) { 35 | t.Run("Eval/true", func(t *testing.T) { 36 | m1 := mockExpr{val: rule.BoolValue(true)} 37 | not := rule.Not(&m1) 38 | val, err := not.Eval(nil) 39 | require.NoError(t, err) 40 | require.Equal(t, rule.BoolValue(false), val) 41 | require.Equal(t, 1, m1.evalCount) 42 | }) 43 | 44 | t.Run("Eval/false", func(t *testing.T) { 45 | m1 := mockExpr{val: rule.BoolValue(false)} 46 | not := rule.Not(&m1) 47 | val, err := not.Eval(nil) 48 | require.NoError(t, err) 49 | require.Equal(t, rule.BoolValue(true), val) 50 | require.Equal(t, 1, m1.evalCount) 51 | }) 52 | 53 | t.Run("Eval/error", func(t *testing.T) { 54 | m1 := mockExpr{val: rule.StringValue("foobar")} 55 | not := rule.Not(&m1) 56 | _, err := not.Eval(nil) 57 | require.Error(t, err) 58 | require.Equal(t, 1, m1.evalCount) 59 | }) 60 | } 61 | 62 | func TestAnd(t *testing.T) { 63 | t.Run("Eval/true", func(t *testing.T) { 64 | m1 := mockExpr{val: rule.BoolValue(true)} 65 | m2 := mockExpr{val: rule.BoolValue(true)} 66 | and := rule.And(&m1, &m2) 67 | val, err := and.Eval(nil) 68 | require.NoError(t, err) 69 | require.Equal(t, rule.BoolValue(true), val) 70 | require.Equal(t, 1, m1.evalCount) 71 | require.Equal(t, 1, m2.evalCount) 72 | }) 73 | 74 | t.Run("Eval/short-circuit", func(t *testing.T) { 75 | m1 := mockExpr{val: rule.BoolValue(false)} 76 | m2 := mockExpr{val: rule.BoolValue(true)} 77 | and := rule.And(&m1, &m2) 78 | val, err := and.Eval(nil) 79 | require.NoError(t, err) 80 | require.Equal(t, rule.BoolValue(false), val) 81 | require.Equal(t, 1, m1.evalCount) 82 | require.Equal(t, 0, m2.evalCount) 83 | }) 84 | 85 | t.Run("Eval/false", func(t *testing.T) { 86 | m1 := mockExpr{val: rule.BoolValue(true)} 87 | m2 := mockExpr{val: rule.BoolValue(false)} 88 | and := rule.And(&m1, &m2) 89 | val, err := and.Eval(nil) 90 | require.NoError(t, err) 91 | require.Equal(t, rule.BoolValue(false), val) 92 | require.Equal(t, 1, m1.evalCount) 93 | require.Equal(t, 1, m2.evalCount) 94 | }) 95 | 96 | t.Run("Eval/error", func(t *testing.T) { 97 | m1 := mockExpr{val: rule.StringValue("foobar")} 98 | m2 := mockExpr{val: rule.BoolValue(true)} 99 | and := rule.And(&m1, &m2) 100 | _, err := and.Eval(nil) 101 | require.Error(t, err) 102 | }) 103 | } 104 | 105 | func TestOr(t *testing.T) { 106 | t.Run("Eval/true", func(t *testing.T) { 107 | m1 := mockExpr{val: rule.BoolValue(true)} 108 | m2 := mockExpr{val: rule.BoolValue(true)} 109 | or := rule.Or(&m1, &m2) 110 | val, err := or.Eval(nil) 111 | require.NoError(t, err) 112 | require.Equal(t, rule.BoolValue(true), val) 113 | require.Equal(t, 1, m1.evalCount) 114 | require.Equal(t, 0, m2.evalCount) 115 | }) 116 | 117 | t.Run("Eval/short-circuit", func(t *testing.T) { 118 | m1 := mockExpr{val: rule.BoolValue(false)} 119 | m2 := mockExpr{val: rule.BoolValue(true)} 120 | or := rule.Or(&m1, &m2) 121 | val, err := or.Eval(nil) 122 | require.NoError(t, err) 123 | require.Equal(t, rule.BoolValue(true), val) 124 | require.Equal(t, 1, m1.evalCount) 125 | require.Equal(t, 1, m2.evalCount) 126 | }) 127 | 128 | t.Run("Eval/false", func(t *testing.T) { 129 | m1 := mockExpr{val: rule.BoolValue(false)} 130 | m2 := mockExpr{val: rule.BoolValue(false)} 131 | or := rule.Or(&m1, &m2) 132 | val, err := or.Eval(nil) 133 | require.NoError(t, err) 134 | require.Equal(t, rule.BoolValue(false), val) 135 | require.Equal(t, 1, m1.evalCount) 136 | require.Equal(t, 1, m2.evalCount) 137 | }) 138 | 139 | t.Run("Eval/error", func(t *testing.T) { 140 | m1 := mockExpr{val: rule.StringValue("foobar")} 141 | m2 := mockExpr{val: rule.BoolValue(true)} 142 | or := rule.Or(&m1, &m2) 143 | _, err := or.Eval(nil) 144 | require.Error(t, err) 145 | }) 146 | } 147 | 148 | func TestEq(t *testing.T) { 149 | t.Run("Eval/Match", func(t *testing.T) { 150 | m1 := mockExpr{val: rule.BoolValue(true)} 151 | m2 := mockExpr{val: rule.BoolValue(true)} 152 | params := regula.Params{"foo": "bar"} 153 | eq := rule.Eq(&m1, &m2) 154 | val, err := eq.Eval(params) 155 | require.NoError(t, err) 156 | require.Equal(t, rule.BoolValue(true), val) 157 | require.Equal(t, 1, m1.evalCount) 158 | require.Equal(t, 1, m2.evalCount) 159 | require.Equal(t, params, m1.lastParams) 160 | require.Equal(t, params, m2.lastParams) 161 | }) 162 | 163 | t.Run("Eval/NoMatch", func(t *testing.T) { 164 | m1 := mockExpr{val: rule.BoolValue(true)} 165 | m2 := mockExpr{val: rule.BoolValue(false)} 166 | eq := rule.Eq(&m1, &m2) 167 | val, err := eq.Eval(nil) 168 | require.NoError(t, err) 169 | require.Equal(t, rule.BoolValue(false), val) 170 | }) 171 | } 172 | 173 | func TestIn(t *testing.T) { 174 | t.Run("Eval/OK", func(t *testing.T) { 175 | m1 := mockExpr{val: rule.BoolValue(true)} 176 | m2 := mockExpr{val: rule.BoolValue(true)} 177 | params := regula.Params{"foo": "bar"} 178 | in := rule.In(&m1, &m2) 179 | val, err := in.Eval(params) 180 | require.NoError(t, err) 181 | require.Equal(t, rule.BoolValue(true), val) 182 | require.Equal(t, 1, m1.evalCount) 183 | require.Equal(t, 1, m2.evalCount) 184 | require.Equal(t, params, m1.lastParams) 185 | require.Equal(t, params, m2.lastParams) 186 | }) 187 | 188 | t.Run("Eval/Fail", func(t *testing.T) { 189 | m1 := mockExpr{val: rule.BoolValue(true)} 190 | m2 := mockExpr{val: rule.BoolValue(false)} 191 | eq := rule.In(&m1, &m2) 192 | val, err := eq.Eval(nil) 193 | require.NoError(t, err) 194 | require.Equal(t, rule.BoolValue(false), val) 195 | }) 196 | } 197 | 198 | func TestGT(t *testing.T) { 199 | cases := []struct { 200 | name string 201 | m1 mockExpr 202 | m2 mockExpr 203 | expected *rule.Value 204 | }{ 205 | { 206 | name: "String: true", 207 | m1: mockExpr{val: rule.StringValue("abd")}, 208 | m2: mockExpr{val: rule.StringValue("abc")}, 209 | expected: rule.BoolValue(true), 210 | }, 211 | { 212 | name: "String: false", 213 | m1: mockExpr{val: rule.StringValue("abc")}, 214 | m2: mockExpr{val: rule.StringValue("abd")}, 215 | expected: rule.BoolValue(false), 216 | }, 217 | { 218 | name: "Bool: true", 219 | m1: mockExpr{val: rule.BoolValue(true)}, 220 | m2: mockExpr{val: rule.BoolValue(false)}, 221 | expected: rule.BoolValue(true), 222 | }, 223 | { 224 | name: "Bool: false#1", 225 | m1: mockExpr{val: rule.BoolValue(true)}, 226 | m2: mockExpr{val: rule.BoolValue(true)}, 227 | expected: rule.BoolValue(false), 228 | }, 229 | { 230 | name: "Bool: false#2", 231 | m1: mockExpr{val: rule.BoolValue(false)}, 232 | m2: mockExpr{val: rule.BoolValue(true)}, 233 | expected: rule.BoolValue(false), 234 | }, 235 | { 236 | name: "Int64: true", 237 | m1: mockExpr{val: rule.Int64Value(12)}, 238 | m2: mockExpr{val: rule.Int64Value(11)}, 239 | expected: rule.BoolValue(true), 240 | }, 241 | { 242 | name: "Int64: false", 243 | m1: mockExpr{val: rule.Int64Value(12)}, 244 | m2: mockExpr{val: rule.Int64Value(12)}, 245 | expected: rule.BoolValue(false), 246 | }, 247 | { 248 | name: "Float64: true", 249 | m1: mockExpr{val: rule.Float64Value(12.1)}, 250 | m2: mockExpr{val: rule.Float64Value(12.0)}, 251 | expected: rule.BoolValue(true), 252 | }, 253 | { 254 | name: "Float64: false", 255 | m1: mockExpr{val: rule.Float64Value(12.0)}, 256 | m2: mockExpr{val: rule.Float64Value(12.1)}, 257 | expected: rule.BoolValue(false), 258 | }, 259 | } 260 | 261 | for _, tc := range cases { 262 | t.Run(tc.name, func(t *testing.T) { 263 | gt := rule.GT(&tc.m1, &tc.m2) 264 | val, err := gt.Eval(nil) 265 | require.NoError(t, err) 266 | require.Equal(t, tc.expected, val) 267 | }) 268 | } 269 | } 270 | 271 | func TestGTE(t *testing.T) { 272 | cases := []struct { 273 | name string 274 | m1 mockExpr 275 | m2 mockExpr 276 | expected *rule.Value 277 | }{ 278 | { 279 | name: "String: true#1", 280 | m1: mockExpr{val: rule.StringValue("abc")}, 281 | m2: mockExpr{val: rule.StringValue("abc")}, 282 | expected: rule.BoolValue(true), 283 | }, 284 | { 285 | name: "String: true#2", 286 | m1: mockExpr{val: rule.StringValue("abd")}, 287 | m2: mockExpr{val: rule.StringValue("abc")}, 288 | expected: rule.BoolValue(true), 289 | }, 290 | { 291 | name: "String: false", 292 | m1: mockExpr{val: rule.StringValue("abc")}, 293 | m2: mockExpr{val: rule.StringValue("abd")}, 294 | expected: rule.BoolValue(false), 295 | }, 296 | { 297 | name: "Bool: true#1", 298 | m1: mockExpr{val: rule.BoolValue(true)}, 299 | m2: mockExpr{val: rule.BoolValue(false)}, 300 | expected: rule.BoolValue(true), 301 | }, 302 | { 303 | name: "Bool: true#2", 304 | m1: mockExpr{val: rule.BoolValue(true)}, 305 | m2: mockExpr{val: rule.BoolValue(true)}, 306 | expected: rule.BoolValue(true), 307 | }, 308 | { 309 | name: "Bool: true#3", 310 | m1: mockExpr{val: rule.BoolValue(false)}, 311 | m2: mockExpr{val: rule.BoolValue(false)}, 312 | expected: rule.BoolValue(true), 313 | }, 314 | { 315 | name: "Bool: false", 316 | m1: mockExpr{val: rule.BoolValue(false)}, 317 | m2: mockExpr{val: rule.BoolValue(true)}, 318 | expected: rule.BoolValue(false), 319 | }, 320 | { 321 | name: "Int64: true#1", 322 | m1: mockExpr{val: rule.Int64Value(12)}, 323 | m2: mockExpr{val: rule.Int64Value(11)}, 324 | expected: rule.BoolValue(true), 325 | }, 326 | { 327 | name: "Int64: true#2", 328 | m1: mockExpr{val: rule.Int64Value(12)}, 329 | m2: mockExpr{val: rule.Int64Value(12)}, 330 | expected: rule.BoolValue(true), 331 | }, 332 | { 333 | name: "Int64: false", 334 | m1: mockExpr{val: rule.Int64Value(11)}, 335 | m2: mockExpr{val: rule.Int64Value(12)}, 336 | expected: rule.BoolValue(false), 337 | }, 338 | { 339 | name: "Float64: true#1", 340 | m1: mockExpr{val: rule.Float64Value(12.1)}, 341 | m2: mockExpr{val: rule.Float64Value(12.0)}, 342 | expected: rule.BoolValue(true), 343 | }, 344 | { 345 | name: "Float64: true#2", 346 | m1: mockExpr{val: rule.Float64Value(12.1)}, 347 | m2: mockExpr{val: rule.Float64Value(12.1)}, 348 | expected: rule.BoolValue(true), 349 | }, 350 | { 351 | name: "Float64: false", 352 | m1: mockExpr{val: rule.Float64Value(12.0)}, 353 | m2: mockExpr{val: rule.Float64Value(12.1)}, 354 | expected: rule.BoolValue(false), 355 | }, 356 | } 357 | 358 | for _, tc := range cases { 359 | t.Run(tc.name, func(t *testing.T) { 360 | gte := rule.GTE(&tc.m1, &tc.m2) 361 | val, err := gte.Eval(nil) 362 | require.NoError(t, err) 363 | require.Equal(t, tc.expected, val) 364 | }) 365 | } 366 | } 367 | 368 | func TestFNV(t *testing.T) { 369 | cases := []struct { 370 | name string 371 | val rule.Expr 372 | result int64 373 | }{ 374 | { 375 | name: "Int64Value", 376 | val: rule.Int64Value(1234), 377 | result: 2179869525, 378 | }, 379 | { 380 | name: "Float64Value", 381 | val: rule.Float64Value(1234.1234), 382 | result: 566939793, 383 | }, 384 | { 385 | name: "StringValue", 386 | val: rule.StringValue("travelling in style"), 387 | result: 536463009, 388 | }, 389 | { 390 | name: "BoolValue (true)", 391 | val: rule.BoolValue(true), 392 | result: 3053630529, 393 | }, 394 | { 395 | name: "BoolValue (false)", 396 | val: rule.BoolValue(false), 397 | result: 2452206122, 398 | }, 399 | } 400 | params := regula.Params{} 401 | for _, tc := range cases { 402 | t.Run(tc.name, func(t *testing.T) { 403 | hash := rule.FNV(tc.val) 404 | result, err := hash.Eval(params) 405 | require.NoError(t, err) 406 | require.Equal(t, rule.Int64Value(tc.result), result) 407 | }) 408 | } 409 | } 410 | 411 | func TestLT(t *testing.T) { 412 | cases := []struct { 413 | name string 414 | m1 mockExpr 415 | m2 mockExpr 416 | expected *rule.Value 417 | }{ 418 | { 419 | name: "String: true", 420 | m1: mockExpr{val: rule.StringValue("abc")}, 421 | m2: mockExpr{val: rule.StringValue("abd")}, 422 | expected: rule.BoolValue(true), 423 | }, 424 | { 425 | name: "String: false", 426 | m1: mockExpr{val: rule.StringValue("abd")}, 427 | m2: mockExpr{val: rule.StringValue("abc")}, 428 | expected: rule.BoolValue(false), 429 | }, 430 | { 431 | name: "Bool: true", 432 | m1: mockExpr{val: rule.BoolValue(false)}, 433 | m2: mockExpr{val: rule.BoolValue(true)}, 434 | expected: rule.BoolValue(true), 435 | }, 436 | { 437 | name: "Bool: false#1", 438 | m1: mockExpr{val: rule.BoolValue(false)}, 439 | m2: mockExpr{val: rule.BoolValue(false)}, 440 | expected: rule.BoolValue(false), 441 | }, 442 | { 443 | name: "Bool: false#2", 444 | m1: mockExpr{val: rule.BoolValue(true)}, 445 | m2: mockExpr{val: rule.BoolValue(false)}, 446 | expected: rule.BoolValue(false), 447 | }, 448 | { 449 | name: "Int64: true", 450 | m1: mockExpr{val: rule.Int64Value(11)}, 451 | m2: mockExpr{val: rule.Int64Value(12)}, 452 | expected: rule.BoolValue(true), 453 | }, 454 | { 455 | name: "Int64: false", 456 | m1: mockExpr{val: rule.Int64Value(12)}, 457 | m2: mockExpr{val: rule.Int64Value(12)}, 458 | expected: rule.BoolValue(false), 459 | }, 460 | { 461 | name: "Float64: true", 462 | m1: mockExpr{val: rule.Float64Value(12.0)}, 463 | m2: mockExpr{val: rule.Float64Value(12.1)}, 464 | expected: rule.BoolValue(true), 465 | }, 466 | { 467 | name: "Float64: false", 468 | m1: mockExpr{val: rule.Float64Value(12.1)}, 469 | m2: mockExpr{val: rule.Float64Value(12.0)}, 470 | expected: rule.BoolValue(false), 471 | }, 472 | } 473 | 474 | for _, tc := range cases { 475 | t.Run(tc.name, func(t *testing.T) { 476 | lt := rule.LT(&tc.m1, &tc.m2) 477 | val, err := lt.Eval(nil) 478 | require.NoError(t, err) 479 | require.Equal(t, tc.expected, val) 480 | }) 481 | } 482 | } 483 | 484 | func TestPercentile(t *testing.T) { 485 | // "Bob Dylan" is in the 96th percentile, so this is true 486 | v1 := rule.StringValue("Bob Dylan") 487 | p := rule.Int64Value(96) 488 | perc := rule.Percentile(v1, p) 489 | res, err := perc.Eval(nil) 490 | require.NoError(t, err) 491 | require.Equal(t, rule.BoolValue(true), res) 492 | 493 | // "Joni Mitchell" is in the 97th percentile, so this is false 494 | v2 := rule.StringValue("Joni Mitchell") 495 | perc = rule.Percentile(v2, p) 496 | res, err = perc.Eval(nil) 497 | require.NoError(t, err) 498 | require.Equal(t, rule.BoolValue(false), res) 499 | } 500 | 501 | func TestLTE(t *testing.T) { 502 | cases := []struct { 503 | name string 504 | m1 mockExpr 505 | m2 mockExpr 506 | expected *rule.Value 507 | }{ 508 | { 509 | name: "String: true#1", 510 | m1: mockExpr{val: rule.StringValue("abc")}, 511 | m2: mockExpr{val: rule.StringValue("abc")}, 512 | expected: rule.BoolValue(true), 513 | }, 514 | { 515 | name: "String: true#2", 516 | m1: mockExpr{val: rule.StringValue("abc")}, 517 | m2: mockExpr{val: rule.StringValue("abd")}, 518 | expected: rule.BoolValue(true), 519 | }, 520 | { 521 | name: "String: false", 522 | m1: mockExpr{val: rule.StringValue("abd")}, 523 | m2: mockExpr{val: rule.StringValue("abc")}, 524 | expected: rule.BoolValue(false), 525 | }, 526 | { 527 | name: "Bool: true#1", 528 | m1: mockExpr{val: rule.BoolValue(false)}, 529 | m2: mockExpr{val: rule.BoolValue(true)}, 530 | expected: rule.BoolValue(true), 531 | }, 532 | { 533 | name: "Bool: true#2", 534 | m1: mockExpr{val: rule.BoolValue(false)}, 535 | m2: mockExpr{val: rule.BoolValue(false)}, 536 | expected: rule.BoolValue(true), 537 | }, 538 | { 539 | name: "Bool: true#3", 540 | m1: mockExpr{val: rule.BoolValue(true)}, 541 | m2: mockExpr{val: rule.BoolValue(true)}, 542 | expected: rule.BoolValue(true), 543 | }, 544 | { 545 | name: "Bool: false", 546 | m1: mockExpr{val: rule.BoolValue(true)}, 547 | m2: mockExpr{val: rule.BoolValue(false)}, 548 | expected: rule.BoolValue(false), 549 | }, 550 | { 551 | name: "Int64: true#1", 552 | m1: mockExpr{val: rule.Int64Value(11)}, 553 | m2: mockExpr{val: rule.Int64Value(12)}, 554 | expected: rule.BoolValue(true), 555 | }, 556 | { 557 | name: "Int64: true#2", 558 | m1: mockExpr{val: rule.Int64Value(12)}, 559 | m2: mockExpr{val: rule.Int64Value(12)}, 560 | expected: rule.BoolValue(true), 561 | }, 562 | { 563 | name: "Int64: false", 564 | m1: mockExpr{val: rule.Int64Value(12)}, 565 | m2: mockExpr{val: rule.Int64Value(11)}, 566 | expected: rule.BoolValue(false), 567 | }, 568 | { 569 | name: "Float64: true#1", 570 | m1: mockExpr{val: rule.Float64Value(12.0)}, 571 | m2: mockExpr{val: rule.Float64Value(12.1)}, 572 | expected: rule.BoolValue(true), 573 | }, 574 | { 575 | name: "Float64: true#2", 576 | m1: mockExpr{val: rule.Float64Value(12.1)}, 577 | m2: mockExpr{val: rule.Float64Value(12.1)}, 578 | expected: rule.BoolValue(true), 579 | }, 580 | { 581 | name: "Float64: false", 582 | m1: mockExpr{val: rule.Float64Value(12.1)}, 583 | m2: mockExpr{val: rule.Float64Value(12.0)}, 584 | expected: rule.BoolValue(false), 585 | }, 586 | } 587 | 588 | for _, tc := range cases { 589 | t.Run(tc.name, func(t *testing.T) { 590 | lte := rule.LTE(&tc.m1, &tc.m2) 591 | val, err := lte.Eval(nil) 592 | require.NoError(t, err) 593 | require.Equal(t, tc.expected, val) 594 | }) 595 | } 596 | } 597 | 598 | func TestParam(t *testing.T) { 599 | t.Run("OK", func(t *testing.T) { 600 | v := rule.StringParam("foo") 601 | val, err := v.Eval(regula.Params{ 602 | "foo": "bar", 603 | }) 604 | require.NoError(t, err) 605 | require.Equal(t, rule.StringValue("bar"), val) 606 | }) 607 | 608 | t.Run("Not found", func(t *testing.T) { 609 | v := rule.StringParam("foo") 610 | _, err := v.Eval(regula.Params{ 611 | "boo": "bar", 612 | }) 613 | require.Error(t, err) 614 | }) 615 | 616 | t.Run("Empty context", func(t *testing.T) { 617 | v := rule.StringParam("foo") 618 | _, err := v.Eval(nil) 619 | require.Error(t, err) 620 | }) 621 | } 622 | 623 | func TestValue(t *testing.T) { 624 | v1 := rule.BoolValue(true) 625 | require.True(t, v1.Equal(v1)) 626 | require.True(t, v1.Equal(rule.BoolValue(true))) 627 | require.False(t, v1.Equal(rule.BoolValue(false))) 628 | require.False(t, v1.Equal(rule.StringValue("true"))) 629 | } 630 | -------------------------------------------------------------------------------- /rule/json.go: -------------------------------------------------------------------------------- 1 | package rule 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/tidwall/gjson" 8 | ) 9 | 10 | type operator struct { 11 | kind string 12 | operands []Expr 13 | } 14 | 15 | func (o *operator) UnmarshalJSON(data []byte) error { 16 | var node struct { 17 | Kind string 18 | Operands operands 19 | } 20 | 21 | err := json.Unmarshal(data, &node) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | o.operands = node.Operands.Exprs 27 | o.kind = node.Kind 28 | 29 | return nil 30 | } 31 | 32 | func (o *operator) MarshalJSON() ([]byte, error) { 33 | var op struct { 34 | Kind string `json:"kind"` 35 | Operands []Expr `json:"operands"` 36 | } 37 | 38 | op.Kind = o.kind 39 | op.Operands = o.operands 40 | return json.Marshal(&op) 41 | } 42 | 43 | func (o *operator) Eval(params Params) (*Value, error) { 44 | return nil, nil 45 | } 46 | 47 | func (o *operator) Operands() []Expr { 48 | return o.operands 49 | } 50 | 51 | type operands struct { 52 | Ops []json.RawMessage `json:"operands"` 53 | Exprs []Expr 54 | } 55 | 56 | func (o *operands) UnmarshalJSON(data []byte) error { 57 | err := json.Unmarshal(data, &o.Ops) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | for _, op := range o.Ops { 63 | r := gjson.Get(string(op), "kind") 64 | n, err := unmarshalExpr(r.Str, []byte(op)) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | o.Exprs = append(o.Exprs, n) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func unmarshalExpr(kind string, data []byte) (Expr, error) { 76 | var e Expr 77 | var err error 78 | 79 | switch kind { 80 | case "value": 81 | var v Value 82 | e = &v 83 | err = json.Unmarshal(data, &v) 84 | case "param": 85 | var p Param 86 | e = &p 87 | err = json.Unmarshal(data, &p) 88 | case "eq": 89 | var eq exprEq 90 | e = &eq 91 | err = eq.UnmarshalJSON(data) 92 | case "in": 93 | var in exprIn 94 | e = &in 95 | err = in.UnmarshalJSON(data) 96 | case "not": 97 | var not exprNot 98 | e = ¬ 99 | err = not.UnmarshalJSON(data) 100 | case "and": 101 | var and exprAnd 102 | e = &and 103 | err = and.UnmarshalJSON(data) 104 | case "or": 105 | var or exprOr 106 | e = &or 107 | err = or.UnmarshalJSON(data) 108 | case "gt": 109 | var gt exprGT 110 | e = > 111 | err = gt.UnmarshalJSON(data) 112 | case "gte": 113 | var gte exprGTE 114 | e = >e 115 | err = gte.UnmarshalJSON(data) 116 | case "percentile": 117 | var percentile exprPercentile 118 | e = &percentile 119 | err = percentile.UnmarshalJSON(data) 120 | case "fnv": 121 | var fnv exprFNV 122 | e = &fnv 123 | err = fnv.UnmarshalJSON(data) 124 | case "lt": 125 | var lt exprLT 126 | e = < 127 | err = lt.UnmarshalJSON(data) 128 | case "lte": 129 | var lte exprLTE 130 | e = <e 131 | err = lte.UnmarshalJSON(data) 132 | default: 133 | err = errors.New("unknown expression kind " + kind) 134 | } 135 | 136 | return e, err 137 | } 138 | -------------------------------------------------------------------------------- /rule/json_test.go: -------------------------------------------------------------------------------- 1 | package rule 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestOperands(t *testing.T) { 11 | t.Run("Empty", func(t *testing.T) { 12 | var ops operands 13 | 14 | err := ops.UnmarshalJSON([]byte(`[]`)) 15 | require.NoError(t, err) 16 | require.Empty(t, ops.Ops) 17 | require.Empty(t, ops.Exprs) 18 | }) 19 | 20 | t.Run("Some Ops", func(t *testing.T) { 21 | var ops operands 22 | 23 | err := ops.UnmarshalJSON([]byte(`[ 24 | {"kind": "value"}, 25 | {"kind": "param"}, 26 | {"kind": "eq","operands": [{"kind": "value"}, {"kind": "param"}]}, 27 | {"kind": "in","operands": [{"kind": "value"}, {"kind": "param"}]} 28 | ]`)) 29 | require.NoError(t, err) 30 | require.Len(t, ops.Ops, 4) 31 | require.Len(t, ops.Exprs, 4) 32 | }) 33 | } 34 | 35 | func TestUnmarshalExpr(t *testing.T) { 36 | t.Run("Empty", func(t *testing.T) { 37 | _, err := unmarshalExpr("", []byte(``)) 38 | require.Error(t, err) 39 | }) 40 | 41 | t.Run("Unknown kind", func(t *testing.T) { 42 | _, err := unmarshalExpr("kiwi", []byte(``)) 43 | require.Error(t, err) 44 | }) 45 | 46 | t.Run("OK", func(t *testing.T) { 47 | tests := []struct { 48 | kind string 49 | data []byte 50 | typ interface{} 51 | }{ 52 | {"eq", []byte(`{"kind": "eq","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprEq)}, 53 | {"in", []byte(`{"kind":"in","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprIn)}, 54 | {"not", []byte(`{"kind":"not","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprNot)}, 55 | {"and", []byte(`{"kind":"and","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprAnd)}, 56 | {"or", []byte(`{"kind":"or","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprOr)}, 57 | {"percentile", []byte(`{"kind":"percentile","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprPercentile)}, 58 | {"gt", []byte(`{"kind":"gt","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprGT)}, 59 | {"gte", []byte(`{"kind":"gte","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprGTE)}, 60 | {"lt", []byte(`{"kind":"lt","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprLT)}, 61 | {"lte", []byte(`{"kind":"lte","operands": [{"kind": "value"}, {"kind": "param"}]}`), new(exprLTE)}, 62 | {"param", []byte(`{"kind":"param"}`), new(Param)}, 63 | {"value", []byte(`{"kind":"value"}`), new(Value)}, 64 | } 65 | 66 | for _, test := range tests { 67 | n, err := unmarshalExpr(test.kind, test.data) 68 | require.NoError(t, err) 69 | require.NotNil(t, n) 70 | require.IsType(t, test.typ, n) 71 | } 72 | }) 73 | } 74 | 75 | func TestRuleUnmarshalling(t *testing.T) { 76 | t.Run("OK", func(t *testing.T) { 77 | var rule Rule 78 | 79 | err := rule.UnmarshalJSON([]byte(`{ 80 | "result": { 81 | "data": "foo", 82 | "type": "string" 83 | }, 84 | "expr": { 85 | "kind": "eq", 86 | "operands": [ 87 | { 88 | "kind": "value", 89 | "type": "string", 90 | "data": "bar" 91 | }, 92 | { 93 | "kind": "eq", 94 | "operands": [ 95 | { 96 | "kind": "param", 97 | "type": "string", 98 | "name": "foo" 99 | }, 100 | { 101 | "kind": "value", 102 | "type": "string", 103 | "data": "bar" 104 | } 105 | ] 106 | } 107 | ] 108 | } 109 | }`)) 110 | require.NoError(t, err) 111 | require.Equal(t, "string", rule.Result.Type) 112 | require.Equal(t, "foo", rule.Result.Data) 113 | require.IsType(t, new(exprEq), rule.Expr) 114 | eq := rule.Expr.(*exprEq) 115 | require.Len(t, eq.operands, 2) 116 | require.IsType(t, new(Value), eq.operands[0]) 117 | require.IsType(t, new(exprEq), eq.operands[1]) 118 | }) 119 | 120 | t.Run("EncDec", func(t *testing.T) { 121 | r1 := New( 122 | And( 123 | Or( 124 | Eq( 125 | StringValue("foo"), 126 | Int64Value(10), 127 | Float64Value(10), 128 | BoolValue(true), 129 | ), 130 | In( 131 | StringParam("foo"), 132 | Int64Param("foo"), 133 | Float64Param("foo"), 134 | BoolParam("foo"), 135 | ), 136 | Not( 137 | BoolValue(true), 138 | ), 139 | Percentile( 140 | StringValue("Bob Dylan"), 141 | Int64Value(96), 142 | ), 143 | Not( 144 | Eq( 145 | FNV(StringValue("Bob Dylan")), 146 | StringValue("Bob Dylan"), 147 | ), 148 | ), 149 | GT( 150 | Int64Value(11), 151 | Int64Value(10), 152 | ), 153 | GTE( 154 | Int64Value(11), 155 | Int64Value(11), 156 | ), 157 | LT( 158 | Int64Value(10), 159 | Int64Value(11), 160 | ), 161 | LTE( 162 | Int64Value(10), 163 | Int64Value(10), 164 | ), 165 | ), 166 | True(), 167 | ), 168 | StringValue("ok"), 169 | ) 170 | 171 | raw, err := json.Marshal(r1) 172 | require.NoError(t, err) 173 | 174 | var r2 Rule 175 | err = json.Unmarshal(raw, &r2) 176 | require.NoError(t, err) 177 | 178 | require.Equal(t, r1, &r2) 179 | }) 180 | 181 | t.Run("Missing result type", func(t *testing.T) { 182 | var rule Rule 183 | 184 | err := rule.UnmarshalJSON([]byte(`{ 185 | "result": { 186 | "value": "foo" 187 | }, 188 | "root": { 189 | "kind": "not", 190 | "operands": [ 191 | { 192 | "kind": "value", 193 | "type": "bool", 194 | "data": "true" 195 | } 196 | ] 197 | } 198 | }`)) 199 | 200 | require.Error(t, err) 201 | }) 202 | } 203 | -------------------------------------------------------------------------------- /rule/rule.go: -------------------------------------------------------------------------------- 1 | package rule 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strconv" 7 | 8 | "github.com/tidwall/gjson" 9 | ) 10 | 11 | var ( 12 | // ErrRulesetNotFound must be returned when no ruleset is found for a given key. 13 | ErrRulesetNotFound = errors.New("ruleset not found") 14 | 15 | // ErrParamTypeMismatch is returned when a parameter type is different from expected. 16 | ErrParamTypeMismatch = errors.New("parameter type mismatches") 17 | 18 | // ErrParamNotFound is returned when a parameter is not defined. 19 | ErrParamNotFound = errors.New("parameter not found") 20 | 21 | // ErrNoMatch is returned when the rule doesn't match the given params. 22 | ErrNoMatch = errors.New("rule doesn't match the given params") 23 | 24 | // ErrRulesetIncoherentType is returned when a ruleset contains rules of different types. 25 | ErrRulesetIncoherentType = errors.New("types in ruleset are incoherent") 26 | ) 27 | 28 | // A Rule represents a logical boolean expression that evaluates to a result. 29 | type Rule struct { 30 | Expr Expr `json:"expr"` 31 | Result *Value `json:"result"` 32 | } 33 | 34 | // New creates a rule with the given expression and that returns the given result on evaluation. 35 | func New(expr Expr, result *Value) *Rule { 36 | return &Rule{ 37 | Expr: expr, 38 | Result: result, 39 | } 40 | } 41 | 42 | // UnmarshalJSON implements the json.Unmarshaler interface. 43 | func (r *Rule) UnmarshalJSON(data []byte) error { 44 | tree := struct { 45 | Expr json.RawMessage 46 | Result *Value 47 | }{} 48 | 49 | err := json.Unmarshal(data, &tree) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if tree.Result.Type == "" { 55 | return errors.New("invalid rule result type") 56 | } 57 | 58 | res := gjson.Get(string(tree.Expr), "kind") 59 | n, err := unmarshalExpr(res.Str, []byte(tree.Expr)) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | r.Expr = n 65 | r.Result = tree.Result 66 | return err 67 | } 68 | 69 | // Eval evaluates the rule against the given params. 70 | // If it matches it returns a result, otherwise it returns ErrNoMatch 71 | // or any encountered error. 72 | func (r *Rule) Eval(params Params) (*Value, error) { 73 | value, err := r.Expr.Eval(params) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | if value.Type != "bool" { 79 | return nil, errors.New("invalid rule returning non boolean value") 80 | } 81 | 82 | ok, err := strconv.ParseBool(value.Data) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | if !ok { 88 | return nil, ErrNoMatch 89 | } 90 | 91 | return r.Result, nil 92 | } 93 | 94 | // Params returns a list of all the parameters expected by this rule. 95 | func (r *Rule) Params() []Param { 96 | var list []Param 97 | 98 | walk(r.Expr, func(e Expr) error { 99 | if p, ok := e.(*Param); ok { 100 | list = append(list, *p) 101 | } 102 | 103 | return nil 104 | }) 105 | 106 | return list 107 | } 108 | -------------------------------------------------------------------------------- /rule/rule_test.go: -------------------------------------------------------------------------------- 1 | package rule_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/heetch/regula" 7 | "github.com/heetch/regula/rule" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRuleEval(t *testing.T) { 12 | t.Run("Match", func(t *testing.T) { 13 | tests := []struct { 14 | expr rule.Expr 15 | params rule.Params 16 | }{ 17 | {rule.Eq(rule.StringValue("foo"), rule.StringValue("foo")), nil}, 18 | {rule.Eq(rule.StringValue("foo"), rule.StringParam("bar")), regula.Params{"bar": "foo"}}, 19 | {rule.In(rule.StringValue("foo"), rule.StringParam("bar")), regula.Params{"bar": "foo"}}, 20 | { 21 | rule.Eq( 22 | rule.Eq(rule.StringValue("bar"), rule.StringValue("bar")), 23 | rule.Eq(rule.StringValue("foo"), rule.StringValue("foo")), 24 | ), 25 | nil, 26 | }, 27 | {rule.True(), nil}, 28 | } 29 | 30 | for _, test := range tests { 31 | r := rule.New(test.expr, rule.StringValue("matched")) 32 | res, err := r.Eval(test.params) 33 | require.NoError(t, err) 34 | require.Equal(t, "matched", res.Data) 35 | require.Equal(t, "string", res.Type) 36 | } 37 | }) 38 | 39 | t.Run("Invalid return", func(t *testing.T) { 40 | tests := []struct { 41 | expr rule.Expr 42 | params rule.Params 43 | }{ 44 | {rule.StringValue("foo"), nil}, 45 | {rule.StringParam("bar"), regula.Params{"bar": "foo"}}, 46 | } 47 | 48 | for _, test := range tests { 49 | r := rule.New(test.expr, rule.StringValue("matched")) 50 | _, err := r.Eval(test.params) 51 | require.Error(t, err) 52 | } 53 | }) 54 | } 55 | 56 | func TestRuleParams(t *testing.T) { 57 | tc := []struct { 58 | rule *rule.Rule 59 | params []rule.Param 60 | }{ 61 | {rule.New(rule.True(), rule.StringValue("result")), nil}, 62 | { 63 | rule.New(rule.StringParam("a"), rule.StringValue("result")), 64 | []rule.Param{*rule.StringParam("a")}, 65 | }, 66 | { 67 | rule.New( 68 | rule.And( 69 | rule.Eq(rule.Int64Param("a"), rule.Int64Value(10)), 70 | rule.Eq(rule.BoolParam("b"), rule.BoolValue(true)), 71 | ), rule.StringValue("result")), 72 | []rule.Param{*rule.Int64Param("a"), *rule.BoolParam("b")}, 73 | }, 74 | } 75 | 76 | for _, tt := range tc { 77 | params := tt.rule.Params() 78 | require.Equal(t, tt.params, params) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ruleset.go: -------------------------------------------------------------------------------- 1 | package regula 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/heetch/regula/rule" 8 | ) 9 | 10 | // A Ruleset is list of rules that must return the same type. 11 | type Ruleset struct { 12 | Rules []*rule.Rule `json:"rules"` 13 | Type string `json:"type"` 14 | } 15 | 16 | // NewStringRuleset creates a ruleset which rules all return a string otherwise 17 | // ErrRulesetIncoherentType is returned. 18 | func NewStringRuleset(rules ...*rule.Rule) (*Ruleset, error) { 19 | return newRuleset("string", rules...) 20 | } 21 | 22 | // NewBoolRuleset creates a ruleset which rules all return a bool otherwise 23 | // ErrRulesetIncoherentType is returned. 24 | func NewBoolRuleset(rules ...*rule.Rule) (*Ruleset, error) { 25 | return newRuleset("bool", rules...) 26 | } 27 | 28 | // NewInt64Ruleset creates a ruleset which rules all return an int64 otherwise 29 | // ErrRulesetIncoherentType is returned. 30 | func NewInt64Ruleset(rules ...*rule.Rule) (*Ruleset, error) { 31 | return newRuleset("int64", rules...) 32 | } 33 | 34 | // NewFloat64Ruleset creates a ruleset which rules all return an float64 otherwise 35 | // ErrRulesetIncoherentType is returned. 36 | func NewFloat64Ruleset(rules ...*rule.Rule) (*Ruleset, error) { 37 | return newRuleset("float64", rules...) 38 | } 39 | 40 | func newRuleset(typ string, rules ...*rule.Rule) (*Ruleset, error) { 41 | rs := Ruleset{ 42 | Rules: rules, 43 | Type: typ, 44 | } 45 | 46 | err := rs.validate() 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return &rs, nil 52 | } 53 | 54 | // Eval evaluates every rule of the ruleset until one matches. 55 | // It returns rule.ErrNoMatch if no rule matches the given context. 56 | func (r *Ruleset) Eval(params rule.Params) (*rule.Value, error) { 57 | for _, rl := range r.Rules { 58 | res, err := rl.Eval(params) 59 | if err != rule.ErrNoMatch { 60 | return res, err 61 | } 62 | } 63 | 64 | return nil, rule.ErrNoMatch 65 | } 66 | 67 | // UnmarshalJSON implements the json.Unmarshaler interface. 68 | func (r *Ruleset) UnmarshalJSON(data []byte) error { 69 | type ruleset Ruleset 70 | if err := json.Unmarshal(data, (*ruleset)(r)); err != nil { 71 | return err 72 | } 73 | 74 | if r.Type != "string" && r.Type != "bool" && r.Type != "int64" && r.Type != "float64" { 75 | return errors.New("unsupported ruleset type") 76 | } 77 | 78 | return r.validate() 79 | } 80 | 81 | // Params returns a list of all the parameters used in all the underlying rules. 82 | func (r *Ruleset) Params() []rule.Param { 83 | bm := make(map[string]bool) 84 | var params []rule.Param 85 | 86 | for _, rl := range r.Rules { 87 | ps := rl.Params() 88 | for _, p := range ps { 89 | if !bm[p.Name] { 90 | params = append(params, p) 91 | bm[p.Name] = true 92 | } 93 | } 94 | } 95 | 96 | return params 97 | } 98 | 99 | func (r *Ruleset) validate() error { 100 | paramTypes := make(map[string]string) 101 | 102 | for _, rl := range r.Rules { 103 | if rl.Result.Type != r.Type { 104 | return ErrRulesetIncoherentType 105 | } 106 | 107 | ps := rl.Params() 108 | for _, p := range ps { 109 | tp, ok := paramTypes[p.Name] 110 | if ok { 111 | if p.Type != tp { 112 | return ErrRulesetIncoherentType 113 | } 114 | } else { 115 | paramTypes[p.Name] = p.Type 116 | } 117 | } 118 | } 119 | 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /ruleset_test.go: -------------------------------------------------------------------------------- 1 | package regula 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/heetch/regula/rule" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRulesetEval(t *testing.T) { 12 | t.Run("Match string", func(t *testing.T) { 13 | r, err := NewStringRuleset( 14 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("bar")), rule.StringValue("first")), 15 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("foo")), rule.StringValue("second")), 16 | ) 17 | require.NoError(t, err) 18 | 19 | res, err := r.Eval(nil) 20 | require.NoError(t, err) 21 | require.Equal(t, "second", res.Data) 22 | }) 23 | 24 | t.Run("Match bool", func(t *testing.T) { 25 | r, err := NewBoolRuleset( 26 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("bar")), rule.BoolValue(false)), 27 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("foo")), rule.BoolValue(true)), 28 | ) 29 | require.NoError(t, err) 30 | 31 | res, err := r.Eval(nil) 32 | require.NoError(t, err) 33 | require.Equal(t, "true", res.Data) 34 | }) 35 | 36 | t.Run("Type mismatch", func(t *testing.T) { 37 | _, err := NewStringRuleset( 38 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("bar")), rule.StringValue("first")), 39 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("foo")), rule.BoolValue(true)), 40 | ) 41 | require.Equal(t, ErrRulesetIncoherentType, err) 42 | }) 43 | 44 | t.Run("No match", func(t *testing.T) { 45 | r, err := NewStringRuleset( 46 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("bar")), rule.StringValue("first")), 47 | rule.New(rule.Eq(rule.StringValue("bar"), rule.StringValue("foo")), rule.StringValue("second")), 48 | ) 49 | require.NoError(t, err) 50 | 51 | _, err = r.Eval(nil) 52 | require.Equal(t, rule.ErrNoMatch, err) 53 | }) 54 | 55 | t.Run("Default", func(t *testing.T) { 56 | r, err := NewStringRuleset( 57 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("bar")), rule.StringValue("first")), 58 | rule.New(rule.Eq(rule.StringValue("bar"), rule.StringValue("foo")), rule.StringValue("second")), 59 | rule.New(rule.True(), rule.StringValue("default")), 60 | ) 61 | require.NoError(t, err) 62 | 63 | res, err := r.Eval(nil) 64 | require.NoError(t, err) 65 | require.Equal(t, "default", res.Data) 66 | }) 67 | } 68 | 69 | func TestRulesetEncDec(t *testing.T) { 70 | r1, err := NewStringRuleset( 71 | rule.New(rule.Eq(rule.StringValue("foo"), rule.StringValue("bar")), rule.StringValue("first")), 72 | rule.New(rule.Eq(rule.StringValue("bar"), rule.StringParam("foo")), rule.StringValue("second")), 73 | rule.New(rule.True(), rule.StringValue("default")), 74 | ) 75 | require.NoError(t, err) 76 | 77 | raw, err := json.Marshal(r1) 78 | require.NoError(t, err) 79 | 80 | var r2 Ruleset 81 | err = json.Unmarshal(raw, &r2) 82 | require.NoError(t, err) 83 | 84 | require.Equal(t, r1, &r2) 85 | } 86 | 87 | func TestRulesetParams(t *testing.T) { 88 | r1, err := NewStringRuleset( 89 | rule.New(rule.Eq(rule.StringParam("foo"), rule.Int64Param("bar")), rule.StringValue("first")), 90 | rule.New(rule.Eq(rule.StringParam("foo"), rule.Float64Param("baz")), rule.StringValue("second")), 91 | rule.New(rule.True(), rule.StringValue("default")), 92 | ) 93 | require.NoError(t, err) 94 | require.Equal(t, []rule.Param{ 95 | *rule.StringParam("foo"), 96 | *rule.Int64Param("bar"), 97 | *rule.Float64Param("baz"), 98 | }, r1.Params()) 99 | } 100 | -------------------------------------------------------------------------------- /store/etcd/rulesets.go: -------------------------------------------------------------------------------- 1 | package etcd 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/base64" 7 | "encoding/json" 8 | "fmt" 9 | "path" 10 | "regexp" 11 | "strconv" 12 | 13 | "github.com/coreos/etcd/clientv3" 14 | "github.com/coreos/etcd/clientv3/concurrency" 15 | "github.com/coreos/etcd/mvcc/mvccpb" 16 | "github.com/heetch/regula" 17 | "github.com/heetch/regula/rule" 18 | "github.com/heetch/regula/store" 19 | "github.com/pkg/errors" 20 | "github.com/rs/zerolog" 21 | "github.com/segmentio/ksuid" 22 | ) 23 | 24 | // RulesetService manages the rulesets using etcd. 25 | type RulesetService struct { 26 | Client *clientv3.Client 27 | Logger zerolog.Logger 28 | Namespace string 29 | } 30 | 31 | // List returns all the rulesets entries under the given prefix. 32 | func (s *RulesetService) List(ctx context.Context, prefix string, limit int, continueToken string) (*store.RulesetEntries, error) { 33 | options := make([]clientv3.OpOption, 0, 2) 34 | 35 | var key string 36 | 37 | if limit < 0 || limit > 100 { 38 | limit = 50 // TODO(asdine): make this configurable in future releases. 39 | } 40 | 41 | if continueToken != "" { 42 | lastPath, err := base64.URLEncoding.DecodeString(continueToken) 43 | if err != nil { 44 | return nil, store.ErrInvalidContinueToken 45 | } 46 | 47 | key = string(lastPath) 48 | 49 | rangeEnd := clientv3.GetPrefixRangeEnd(s.rulesetsPath(prefix, "")) 50 | options = append(options, clientv3.WithRange(rangeEnd)) 51 | } else { 52 | key = prefix 53 | options = append(options, clientv3.WithPrefix()) 54 | } 55 | 56 | options = append(options, clientv3.WithLimit(int64(limit))) 57 | 58 | resp, err := s.Client.KV.Get(ctx, s.rulesetsPath(key, ""), options...) 59 | if err != nil { 60 | return nil, errors.Wrap(err, "failed to fetch all entries") 61 | } 62 | 63 | // if a prefix is provided it must always return results 64 | // otherwise it doesn't exist. 65 | if resp.Count == 0 && prefix != "" { 66 | return nil, store.ErrNotFound 67 | } 68 | 69 | var entries store.RulesetEntries 70 | entries.Revision = strconv.FormatInt(resp.Header.Revision, 10) 71 | entries.Entries = make([]store.RulesetEntry, len(resp.Kvs)) 72 | for i, pair := range resp.Kvs { 73 | err = json.Unmarshal(pair.Value, &entries.Entries[i]) 74 | if err != nil { 75 | s.Logger.Debug().Err(err).Bytes("entry", pair.Value).Msg("list: unmarshalling failed") 76 | return nil, errors.Wrap(err, "failed to unmarshal entry") 77 | } 78 | } 79 | 80 | if len(entries.Entries) < limit || !resp.More { 81 | return &entries, nil 82 | } 83 | 84 | lastEntry := entries.Entries[len(entries.Entries)-1] 85 | 86 | // we want to start immediately after the last key 87 | entries.Continue = base64.URLEncoding.EncodeToString([]byte(path.Join(lastEntry.Path, lastEntry.Version+"\x00"))) 88 | 89 | return &entries, nil 90 | } 91 | 92 | // Latest returns the latest version of the ruleset entry which corresponds to the given path. 93 | // It returns store.ErrNotFound if the path doesn't exist or if it's not a ruleset. 94 | func (s *RulesetService) Latest(ctx context.Context, path string) (*store.RulesetEntry, error) { 95 | if path == "" { 96 | return nil, store.ErrNotFound 97 | } 98 | 99 | resp, err := s.Client.KV.Get(ctx, s.latestRulesetPath(path)) 100 | if err != nil { 101 | return nil, errors.Wrapf(err, "failed to fetch latest version: %s", path) 102 | } 103 | 104 | // Count will be 0 if the path doesn't exist or if it's not a ruleset. 105 | if resp.Count == 0 { 106 | return nil, store.ErrNotFound 107 | } 108 | 109 | resp, err = s.Client.KV.Get(ctx, string(resp.Kvs[0].Value)) 110 | if err != nil { 111 | return nil, errors.Wrapf(err, "failed to fetch the entry: %s", path) 112 | } 113 | 114 | var entry store.RulesetEntry 115 | err = json.Unmarshal(resp.Kvs[0].Value, &entry) 116 | if err != nil { 117 | s.Logger.Debug().Err(err).Bytes("entry", resp.Kvs[0].Value).Msg("latest: unmarshalling failed") 118 | return nil, errors.Wrap(err, "failed to unmarshal entry") 119 | } 120 | 121 | return &entry, nil 122 | } 123 | 124 | // OneByVersion returns the ruleset entry which corresponds to the given path at the given version. 125 | // It returns store.ErrNotFound if the path doesn't exist or if it's not a ruleset. 126 | func (s *RulesetService) OneByVersion(ctx context.Context, path, version string) (*store.RulesetEntry, error) { 127 | if path == "" { 128 | return nil, store.ErrNotFound 129 | } 130 | 131 | resp, err := s.Client.KV.Get(ctx, s.rulesetsPath(path, version)) 132 | if err != nil { 133 | return nil, errors.Wrapf(err, "failed to fetch the entry: %s", path) 134 | } 135 | 136 | // Count will be 0 if the path doesn't exist or if it's not a ruleset. 137 | if resp.Count == 0 { 138 | return nil, store.ErrNotFound 139 | } 140 | 141 | var entry store.RulesetEntry 142 | err = json.Unmarshal(resp.Kvs[0].Value, &entry) 143 | if err != nil { 144 | s.Logger.Debug().Err(err).Bytes("entry", resp.Kvs[0].Value).Msg("one-by-version: unmarshalling failed") 145 | return nil, errors.Wrap(err, "failed to unmarshal entry") 146 | } 147 | 148 | return &entry, nil 149 | } 150 | 151 | // Put adds a version of the given ruleset using an uuid. 152 | func (s *RulesetService) Put(ctx context.Context, path string, ruleset *regula.Ruleset) (*store.RulesetEntry, error) { 153 | sig, err := validateRuleset(path, ruleset) 154 | if err != nil { 155 | return nil, err 156 | } 157 | 158 | var entry store.RulesetEntry 159 | 160 | txfn := func(stm concurrency.STM) error { 161 | // generate a checksum from the ruleset for comparison purpose 162 | h := md5.New() 163 | err = json.NewEncoder(h).Encode(ruleset) 164 | if err != nil { 165 | return errors.Wrap(err, "failed to generate checksum") 166 | } 167 | checksum := string(h.Sum(nil)) 168 | 169 | // if nothing changed return latest ruleset 170 | if stm.Get(s.checksumsPath(path)) == checksum { 171 | v := stm.Get(stm.Get(s.latestRulesetPath(path))) 172 | 173 | err = json.Unmarshal([]byte(v), &entry) 174 | if err != nil { 175 | s.Logger.Debug().Err(err).Str("entry", v).Msg("put: entry unmarshalling failed") 176 | return errors.Wrap(err, "failed to unmarshal entry") 177 | } 178 | 179 | return store.ErrNotModified 180 | } 181 | 182 | // make sure signature didn't change 183 | rawSig := stm.Get(s.signaturesPath(path)) 184 | if rawSig != "" { 185 | var curSig signature 186 | err := json.Unmarshal([]byte(rawSig), &curSig) 187 | if err != nil { 188 | s.Logger.Debug().Err(err).Str("signature", rawSig).Msg("put: signature unmarshalling failed") 189 | return errors.Wrap(err, "failed to decode ruleset signature") 190 | } 191 | 192 | err = curSig.matchWith(sig) 193 | if err != nil { 194 | return err 195 | } 196 | } 197 | 198 | // if no signature found, create one 199 | if rawSig == "" { 200 | v, err := json.Marshal(&sig) 201 | if err != nil { 202 | return errors.Wrap(err, "failed to encode updated signature") 203 | } 204 | 205 | stm.Put(s.signaturesPath(path), string(v)) 206 | } 207 | 208 | // update checksum 209 | stm.Put(s.checksumsPath(path), checksum) 210 | 211 | // create a new ruleset version 212 | k, err := ksuid.NewRandom() 213 | if err != nil { 214 | return errors.Wrap(err, "failed to generate ruleset version") 215 | } 216 | version := k.String() 217 | 218 | re := store.RulesetEntry{ 219 | Path: path, 220 | Version: version, 221 | Ruleset: ruleset, 222 | } 223 | 224 | raw, err := json.Marshal(&re) 225 | if err != nil { 226 | return errors.Wrap(err, "failed to encode entry") 227 | } 228 | 229 | stm.Put(s.rulesetsPath(path, version), string(raw)) 230 | 231 | // update the pointer to the latest ruleset 232 | stm.Put(s.latestRulesetPath(path), s.rulesetsPath(path, version)) 233 | 234 | entry = re 235 | return nil 236 | } 237 | 238 | _, err = concurrency.NewSTM(s.Client, txfn, concurrency.WithAbortContext(ctx)) 239 | if err != nil && err != store.ErrNotModified && !store.IsValidationError(err) { 240 | return nil, errors.Wrap(err, "failed to put ruleset") 241 | } 242 | 243 | return &entry, err 244 | } 245 | 246 | type signature struct { 247 | ReturnType string 248 | ParamTypes map[string]string 249 | } 250 | 251 | func newSignature(rs *regula.Ruleset) *signature { 252 | pt := make(map[string]string) 253 | for _, p := range rs.Params() { 254 | pt[p.Name] = p.Type 255 | } 256 | 257 | return &signature{ 258 | ParamTypes: pt, 259 | ReturnType: rs.Type, 260 | } 261 | } 262 | 263 | func (s *signature) matchWith(other *signature) error { 264 | if s.ReturnType != other.ReturnType { 265 | return &store.ValidationError{ 266 | Field: "return type", 267 | Value: other.ReturnType, 268 | Reason: fmt.Sprintf("signature mismatch: return type must be of type %s", s.ReturnType), 269 | } 270 | } 271 | 272 | for name, tp := range other.ParamTypes { 273 | stp, ok := s.ParamTypes[name] 274 | if !ok { 275 | return &store.ValidationError{ 276 | Field: "param", 277 | Value: name, 278 | Reason: "signature mismatch: unknown parameter", 279 | } 280 | } 281 | 282 | if tp != stp { 283 | return &store.ValidationError{ 284 | Field: "param type", 285 | Value: tp, 286 | Reason: fmt.Sprintf("signature mismatch: param must be of type %s", stp), 287 | } 288 | } 289 | } 290 | 291 | return nil 292 | } 293 | 294 | func validateRuleset(path string, rs *regula.Ruleset) (*signature, error) { 295 | err := validateRulesetName(path) 296 | if err != nil { 297 | return nil, err 298 | } 299 | 300 | sig := newSignature(rs) 301 | 302 | for _, r := range rs.Rules { 303 | params := r.Params() 304 | err = validateParamNames(params) 305 | if err != nil { 306 | return nil, err 307 | } 308 | } 309 | 310 | return sig, nil 311 | } 312 | 313 | // regex used to validate ruleset names. 314 | var rgxRuleset = regexp.MustCompile(`^[a-z]+(?:[a-z0-9-\/]?[a-z0-9])*$`) 315 | 316 | func validateRulesetName(path string) error { 317 | if !rgxRuleset.MatchString(path) { 318 | return &store.ValidationError{ 319 | Field: "path", 320 | Value: path, 321 | Reason: "invalid format", 322 | } 323 | } 324 | 325 | return nil 326 | } 327 | 328 | // regex used to validate parameters name. 329 | var rgxParam = regexp.MustCompile(`^[a-z]+(?:[a-z0-9-]?[a-z0-9])*$`) 330 | 331 | // list of reserved words that shouldn't be used as parameters. 332 | var reservedWords = []string{ 333 | "version", 334 | "list", 335 | "eval", 336 | "watch", 337 | "revision", 338 | } 339 | 340 | func validateParamNames(params []rule.Param) error { 341 | for i := range params { 342 | if !rgxParam.MatchString(params[i].Name) { 343 | return &store.ValidationError{ 344 | Field: "param", 345 | Value: params[i].Name, 346 | Reason: "invalid format", 347 | } 348 | } 349 | 350 | for _, w := range reservedWords { 351 | if params[i].Name == w { 352 | return &store.ValidationError{ 353 | Field: "param", 354 | Value: params[i].Name, 355 | Reason: "forbidden value", 356 | } 357 | } 358 | } 359 | } 360 | 361 | return nil 362 | } 363 | 364 | // Watch the given prefix for anything new. 365 | func (s *RulesetService) Watch(ctx context.Context, prefix string, revision string) (*store.RulesetEvents, error) { 366 | ctx, cancel := context.WithCancel(ctx) 367 | defer cancel() 368 | 369 | opts := []clientv3.OpOption{clientv3.WithPrefix()} 370 | if i, _ := strconv.ParseInt(revision, 10, 64); i > 0 { 371 | // watch from the next revision 372 | opts = append(opts, clientv3.WithRev(i+1)) 373 | } 374 | 375 | wc := s.Client.Watch(ctx, s.rulesetsPath(prefix, ""), opts...) 376 | for { 377 | select { 378 | case wresp := <-wc: 379 | if err := wresp.Err(); err != nil { 380 | return nil, errors.Wrapf(err, "failed to watch prefix: '%s'", prefix) 381 | } 382 | 383 | if len(wresp.Events) == 0 { 384 | continue 385 | } 386 | 387 | events := make([]store.RulesetEvent, len(wresp.Events)) 388 | for i, ev := range wresp.Events { 389 | switch ev.Type { 390 | case mvccpb.PUT: 391 | events[i].Type = store.RulesetPutEvent 392 | default: 393 | s.Logger.Debug().Str("type", string(ev.Type)).Msg("watch: ignoring event type") 394 | continue 395 | } 396 | 397 | var e store.RulesetEntry 398 | err := json.Unmarshal(ev.Kv.Value, &e) 399 | if err != nil { 400 | s.Logger.Debug().Bytes("entry", ev.Kv.Value).Msg("watch: unmarshalling failed") 401 | return nil, errors.Wrap(err, "failed to unmarshal entry") 402 | } 403 | events[i].Path = e.Path 404 | events[i].Ruleset = e.Ruleset 405 | events[i].Version = e.Version 406 | } 407 | 408 | return &store.RulesetEvents{ 409 | Events: events, 410 | Revision: strconv.FormatInt(wresp.Header.Revision, 10), 411 | }, nil 412 | case <-ctx.Done(): 413 | return nil, ctx.Err() 414 | } 415 | } 416 | 417 | } 418 | 419 | // Eval evaluates a ruleset given a path and a set of parameters. It implements the regula.Evaluator interface. 420 | func (s *RulesetService) Eval(ctx context.Context, path string, params rule.Params) (*regula.EvalResult, error) { 421 | re, err := s.Latest(ctx, path) 422 | if err != nil { 423 | if err == store.ErrNotFound { 424 | return nil, regula.ErrRulesetNotFound 425 | } 426 | 427 | return nil, err 428 | } 429 | 430 | v, err := re.Ruleset.Eval(params) 431 | if err != nil { 432 | return nil, err 433 | } 434 | 435 | return ®ula.EvalResult{ 436 | Value: v, 437 | Version: re.Version, 438 | }, nil 439 | } 440 | 441 | // EvalVersion evaluates a ruleset given a path and a set of parameters. It implements the regula.Evaluator interface. 442 | func (s *RulesetService) EvalVersion(ctx context.Context, path, version string, params rule.Params) (*regula.EvalResult, error) { 443 | re, err := s.OneByVersion(ctx, path, version) 444 | if err != nil { 445 | if err == store.ErrNotFound { 446 | return nil, regula.ErrRulesetNotFound 447 | } 448 | 449 | return nil, err 450 | } 451 | 452 | v, err := re.Ruleset.Eval(params) 453 | if err != nil { 454 | return nil, err 455 | } 456 | 457 | return ®ula.EvalResult{ 458 | Value: v, 459 | Version: re.Version, 460 | }, nil 461 | } 462 | 463 | func (s *RulesetService) rulesetsPath(p, v string) string { 464 | return path.Join(s.Namespace, "rulesets", "entries", p, v) 465 | } 466 | 467 | func (s *RulesetService) checksumsPath(p string) string { 468 | return path.Join(s.Namespace, "rulesets", "checksums", p) 469 | } 470 | 471 | func (s *RulesetService) signaturesPath(p string) string { 472 | return path.Join(s.Namespace, "rulesets", "signatures", p) 473 | } 474 | 475 | func (s *RulesetService) latestRulesetPath(p string) string { 476 | return path.Join(s.Namespace, "rulesets", "latest", p) 477 | } 478 | -------------------------------------------------------------------------------- /store/etcd/rulesets_internal_test.go: -------------------------------------------------------------------------------- 1 | package etcd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/heetch/regula" 7 | "github.com/heetch/regula/rule" 8 | "github.com/heetch/regula/store" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestValidation(t *testing.T) { 13 | t.Run("OK - ruleset name", func(t *testing.T) { 14 | names := []string{ 15 | "path/to/my-ruleset", 16 | "path/to/my-awesome-ruleset", 17 | "path/to/my-123-ruleset", 18 | "path/to/my-ruleset-123", 19 | } 20 | 21 | for _, n := range names { 22 | err := validateRulesetName(n) 23 | require.NoError(t, err) 24 | } 25 | }) 26 | 27 | t.Run("NOK - ruleset name", func(t *testing.T) { 28 | names := []string{ 29 | "PATH/TO/MY-RULESET", 30 | "/path/to/my-ruleset", 31 | "path/to/my-ruleset/", 32 | "path/to//my-ruleset", 33 | "path/to/my_ruleset", 34 | "1path/to/my-ruleset", 35 | "path/to/my--ruleset", 36 | } 37 | 38 | for _, n := range names { 39 | err := validateRulesetName(n) 40 | require.True(t, store.IsValidationError(err)) 41 | } 42 | }) 43 | 44 | t.Run("OK - param names", func(t *testing.T) { 45 | names := []string{ 46 | "a", 47 | "abc", 48 | "abc-xyz", 49 | "abc-123", 50 | "abc-123-xyz", 51 | } 52 | 53 | for _, n := range names { 54 | rs, _ := regula.NewBoolRuleset( 55 | rule.New( 56 | rule.BoolParam(n), 57 | rule.BoolValue(true), 58 | ), 59 | ) 60 | 61 | for _, r := range rs.Rules { 62 | params := r.Params() 63 | err := validateParamNames(params) 64 | require.NoError(t, err) 65 | } 66 | } 67 | }) 68 | 69 | t.Run("NOK - param names", func(t *testing.T) { 70 | names := []string{ 71 | "ABC", 72 | "abc-", 73 | "abc_", 74 | "abc--xyz", 75 | "abc_xyz", 76 | "0abc", 77 | } 78 | 79 | names = append(names, reservedWords...) 80 | 81 | for _, n := range names { 82 | rs, _ := regula.NewBoolRuleset( 83 | rule.New( 84 | rule.BoolParam(n), 85 | rule.BoolValue(true), 86 | ), 87 | ) 88 | 89 | for _, r := range rs.Rules { 90 | params := r.Params() 91 | err := validateParamNames(params) 92 | require.True(t, store.IsValidationError(err)) 93 | } 94 | } 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /store/etcd/rulesets_test.go: -------------------------------------------------------------------------------- 1 | package etcd_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | ppath "path" 8 | "strings" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/coreos/etcd/clientv3" 14 | "github.com/heetch/regula" 15 | "github.com/heetch/regula/rule" 16 | "github.com/heetch/regula/store" 17 | "github.com/heetch/regula/store/etcd" 18 | "github.com/stretchr/testify/require" 19 | ) 20 | 21 | var ( 22 | _ store.RulesetService = new(etcd.RulesetService) 23 | _ regula.Evaluator = new(etcd.RulesetService) 24 | ) 25 | 26 | var ( 27 | dialTimeout = 5 * time.Second 28 | endpoints = []string{"localhost:2379", "etcd:2379"} 29 | ) 30 | 31 | func Init() { 32 | rand.Seed(time.Now().Unix()) 33 | } 34 | 35 | func newEtcdRulesetService(t *testing.T) (*etcd.RulesetService, func()) { 36 | t.Helper() 37 | 38 | cli, err := clientv3.New(clientv3.Config{ 39 | Endpoints: endpoints, 40 | DialTimeout: dialTimeout, 41 | }) 42 | require.NoError(t, err) 43 | 44 | s := etcd.RulesetService{ 45 | Client: cli, 46 | Namespace: fmt.Sprintf("regula-store-tests-%d/", rand.Int()), 47 | } 48 | 49 | return &s, func() { 50 | cli.Delete(context.Background(), s.Namespace, clientv3.WithPrefix()) 51 | cli.Close() 52 | } 53 | } 54 | 55 | func createRuleset(t *testing.T, s *etcd.RulesetService, path string, r *regula.Ruleset) *store.RulesetEntry { 56 | e, err := s.Put(context.Background(), path, r) 57 | if err != nil && err != store.ErrNotModified { 58 | require.NoError(t, err) 59 | } 60 | return e 61 | } 62 | 63 | func TestList(t *testing.T) { 64 | t.Parallel() 65 | 66 | s, cleanup := newEtcdRulesetService(t) 67 | defer cleanup() 68 | 69 | rs, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 70 | 71 | t.Run("Root", func(t *testing.T) { 72 | createRuleset(t, s, "c", rs) 73 | createRuleset(t, s, "a", rs) 74 | createRuleset(t, s, "a/1", rs) 75 | createRuleset(t, s, "b", rs) 76 | createRuleset(t, s, "a", rs) 77 | 78 | paths := []string{"a/1", "a", "b", "c"} 79 | 80 | entries, err := s.List(context.Background(), "", 0, "") 81 | require.NoError(t, err) 82 | require.Len(t, entries.Entries, len(paths)) 83 | for i, e := range entries.Entries { 84 | require.Equal(t, paths[i], e.Path) 85 | } 86 | require.NotEmpty(t, entries.Revision) 87 | }) 88 | 89 | t.Run("Prefix", func(t *testing.T) { 90 | createRuleset(t, s, "x", rs) 91 | createRuleset(t, s, "xx", rs) 92 | createRuleset(t, s, "x/1", rs) 93 | createRuleset(t, s, "x/2", rs) 94 | 95 | paths := []string{"x/1", "x", "x/2", "xx"} 96 | 97 | entries, err := s.List(context.Background(), "x", 0, "") 98 | require.NoError(t, err) 99 | require.Len(t, entries.Entries, len(paths)) 100 | for i, e := range entries.Entries { 101 | require.Equal(t, paths[i], e.Path) 102 | } 103 | require.NotEmpty(t, entries.Revision) 104 | }) 105 | 106 | t.Run("NotFound", func(t *testing.T) { 107 | _, err := s.List(context.Background(), "doesntexist", 0, "") 108 | require.Equal(t, err, store.ErrNotFound) 109 | }) 110 | 111 | t.Run("Paging", func(t *testing.T) { 112 | createRuleset(t, s, "y", rs) 113 | createRuleset(t, s, "yy", rs) 114 | createRuleset(t, s, "y/1", rs) 115 | createRuleset(t, s, "y/2", rs) 116 | createRuleset(t, s, "y/3", rs) 117 | 118 | entries, err := s.List(context.Background(), "y", 2, "") 119 | require.NoError(t, err) 120 | require.Len(t, entries.Entries, 2) 121 | require.Equal(t, "y/1", entries.Entries[0].Path) 122 | require.Equal(t, "y", entries.Entries[1].Path) 123 | require.NotEmpty(t, entries.Continue) 124 | 125 | token := entries.Continue 126 | entries, err = s.List(context.Background(), "y", 2, entries.Continue) 127 | require.NoError(t, err) 128 | require.Len(t, entries.Entries, 2) 129 | require.Equal(t, "y/2", entries.Entries[0].Path) 130 | require.Equal(t, "y/3", entries.Entries[1].Path) 131 | require.NotEmpty(t, entries.Continue) 132 | 133 | entries, err = s.List(context.Background(), "y", 2, entries.Continue) 134 | require.NoError(t, err) 135 | require.Len(t, entries.Entries, 1) 136 | require.Equal(t, "yy", entries.Entries[0].Path) 137 | require.Empty(t, entries.Continue) 138 | 139 | entries, err = s.List(context.Background(), "y", 3, token) 140 | require.NoError(t, err) 141 | require.Len(t, entries.Entries, 3) 142 | require.Equal(t, "y/2", entries.Entries[0].Path) 143 | require.Equal(t, "y/3", entries.Entries[1].Path) 144 | require.Equal(t, "yy", entries.Entries[2].Path) 145 | require.Empty(t, entries.Continue) 146 | 147 | entries, err = s.List(context.Background(), "y", 3, "some token") 148 | require.Equal(t, store.ErrInvalidContinueToken, err) 149 | 150 | entries, err = s.List(context.Background(), "y", -10, "") 151 | require.NoError(t, err) 152 | require.Len(t, entries.Entries, 5) 153 | }) 154 | } 155 | 156 | func TestLatest(t *testing.T) { 157 | t.Parallel() 158 | 159 | s, cleanup := newEtcdRulesetService(t) 160 | defer cleanup() 161 | 162 | oldRse, _ := regula.NewStringRuleset( 163 | rule.New( 164 | rule.True(), 165 | rule.StringValue("a"), 166 | ), 167 | ) 168 | 169 | newRse, _ := regula.NewStringRuleset( 170 | rule.New( 171 | rule.True(), 172 | rule.StringValue("b"), 173 | ), 174 | ) 175 | 176 | createRuleset(t, s, "a", oldRse) 177 | // sleep 1 second because ksuid doesn't guarantee the order within the same second since it's based on a 32 bits timestamp (second). 178 | time.Sleep(time.Second) 179 | createRuleset(t, s, "a", newRse) 180 | 181 | rs, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 182 | rs2, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(false))) 183 | createRuleset(t, s, "b", rs) 184 | createRuleset(t, s, "c", rs) 185 | createRuleset(t, s, "abc", rs) 186 | createRuleset(t, s, "abcd", rs) 187 | createRuleset(t, s, "abcd/e", rs2) 188 | 189 | t.Run("OK - several versions of a ruleset", func(t *testing.T) { 190 | path := "a" 191 | 192 | entry, err := s.Latest(context.Background(), path) 193 | require.NoError(t, err) 194 | require.Equal(t, path, entry.Path) 195 | require.Equal(t, newRse, entry.Ruleset) 196 | }) 197 | 198 | t.Run("OK - only one version of a ruleset", func(t *testing.T) { 199 | path := "b" 200 | 201 | entry, err := s.Latest(context.Background(), path) 202 | require.NoError(t, err) 203 | require.Equal(t, path, entry.Path) 204 | require.Equal(t, rs, entry.Ruleset) 205 | }) 206 | 207 | t.Run("NOK - path doesn't exist", func(t *testing.T) { 208 | path := "aa" 209 | 210 | _, err := s.Latest(context.Background(), path) 211 | require.Equal(t, err, store.ErrNotFound) 212 | }) 213 | 214 | t.Run("NOK - empty path", func(t *testing.T) { 215 | path := "" 216 | 217 | _, err := s.Latest(context.Background(), path) 218 | require.Equal(t, err, store.ErrNotFound) 219 | }) 220 | 221 | t.Run("NOK - path exists but it's not a ruleset", func(t *testing.T) { 222 | path := "ab" 223 | 224 | _, err := s.Latest(context.Background(), path) 225 | require.Error(t, err) 226 | require.Equal(t, err, store.ErrNotFound) 227 | }) 228 | 229 | t.Run("OK - ruleset with sub ruleset", func(t *testing.T) { 230 | entry, err := s.Latest(context.Background(), "abcd") 231 | require.NoError(t, err) 232 | require.Equal(t, rs, entry.Ruleset) 233 | 234 | entry, err = s.Latest(context.Background(), "abcd/e") 235 | require.NoError(t, err) 236 | require.Equal(t, rs2, entry.Ruleset) 237 | }) 238 | } 239 | 240 | func TestOneByVersion(t *testing.T) { 241 | t.Parallel() 242 | 243 | s, cleanup := newEtcdRulesetService(t) 244 | defer cleanup() 245 | 246 | oldRse, _ := regula.NewStringRuleset( 247 | rule.New( 248 | rule.True(), 249 | rule.StringValue("a"), 250 | ), 251 | ) 252 | 253 | newRse, _ := regula.NewStringRuleset( 254 | rule.New( 255 | rule.True(), 256 | rule.StringValue("b"), 257 | ), 258 | ) 259 | 260 | createRuleset(t, s, "a", oldRse) 261 | entry, err := s.Latest(context.Background(), "a") 262 | require.NoError(t, err) 263 | version := entry.Version 264 | 265 | createRuleset(t, s, "a", newRse) 266 | 267 | rs, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 268 | createRuleset(t, s, "abc", rs) 269 | 270 | t.Run("OK", func(t *testing.T) { 271 | path := "a" 272 | 273 | entry, err := s.OneByVersion(context.Background(), path, version) 274 | require.NoError(t, err) 275 | require.Equal(t, path, entry.Path) 276 | require.Equal(t, version, entry.Version) 277 | require.Equal(t, oldRse, entry.Ruleset) 278 | }) 279 | 280 | t.Run("NOK", func(t *testing.T) { 281 | paths := []string{ 282 | "a", // doesn't exist 283 | "ab", // exists but not a ruleset 284 | "", // empty path 285 | } 286 | 287 | for _, path := range paths { 288 | _, err := s.OneByVersion(context.Background(), path, "123version") 289 | require.Equal(t, err, store.ErrNotFound) 290 | } 291 | }) 292 | } 293 | 294 | func TestPut(t *testing.T) { 295 | t.Parallel() 296 | 297 | s, cleanup := newEtcdRulesetService(t) 298 | defer cleanup() 299 | 300 | t.Run("OK", func(t *testing.T) { 301 | path := "a" 302 | rs, _ := regula.NewBoolRuleset( 303 | rule.New( 304 | rule.True(), 305 | rule.BoolValue(true), 306 | ), 307 | ) 308 | 309 | entry, err := s.Put(context.Background(), path, rs) 310 | require.NoError(t, err) 311 | require.Equal(t, path, entry.Path) 312 | require.NotEmpty(t, entry.Version) 313 | require.Equal(t, rs, entry.Ruleset) 314 | 315 | // verify ruleset creation 316 | resp, err := s.Client.Get(context.Background(), ppath.Join(s.Namespace, "rulesets", "entries", path), clientv3.WithPrefix()) 317 | require.NoError(t, err) 318 | require.EqualValues(t, 1, resp.Count) 319 | // verify if the path contains the right ruleset version 320 | require.Equal(t, entry.Version, strings.TrimPrefix(string(resp.Kvs[0].Key), ppath.Join(s.Namespace, "rulesets", "entries", "a")+"/")) 321 | 322 | // verify checksum creation 323 | resp, err = s.Client.Get(context.Background(), ppath.Join(s.Namespace, "rulesets", "checksums", path), clientv3.WithPrefix()) 324 | require.NoError(t, err) 325 | require.EqualValues(t, 1, resp.Count) 326 | 327 | // verify latest pointer creation 328 | resp, err = s.Client.Get(context.Background(), ppath.Join(s.Namespace, "rulesets", "latest", path), clientv3.WithPrefix()) 329 | require.NoError(t, err) 330 | require.EqualValues(t, 1, resp.Count) 331 | 332 | // create new version with same ruleset 333 | entry2, err := s.Put(context.Background(), path, rs) 334 | require.Equal(t, err, store.ErrNotModified) 335 | require.Equal(t, entry, entry2) 336 | 337 | // create new version with different ruleset 338 | rs, _ = regula.NewBoolRuleset( 339 | rule.New( 340 | rule.True(), 341 | rule.BoolValue(false), 342 | ), 343 | ) 344 | entry2, err = s.Put(context.Background(), path, rs) 345 | require.NoError(t, err) 346 | require.NotEqual(t, entry.Version, entry2.Version) 347 | }) 348 | 349 | t.Run("Signatures", func(t *testing.T) { 350 | path := "b" 351 | rs1, err := regula.NewBoolRuleset( 352 | rule.New( 353 | rule.Eq( 354 | rule.StringParam("a"), 355 | rule.BoolParam("b"), 356 | rule.Int64Param("c"), 357 | ), 358 | rule.BoolValue(true), 359 | ), 360 | ) 361 | require.NoError(t, err) 362 | 363 | _, err = s.Put(context.Background(), path, rs1) 364 | require.NoError(t, err) 365 | 366 | // same params, different return type 367 | rs2, err := regula.NewStringRuleset( 368 | rule.New( 369 | rule.Eq( 370 | rule.StringParam("a"), 371 | rule.BoolParam("b"), 372 | rule.Int64Param("c"), 373 | ), 374 | rule.StringValue("true"), 375 | ), 376 | ) 377 | require.NoError(t, err) 378 | 379 | _, err = s.Put(context.Background(), path, rs2) 380 | require.True(t, store.IsValidationError(err)) 381 | 382 | // adding new params 383 | rs3, err := regula.NewBoolRuleset( 384 | rule.New( 385 | rule.Eq( 386 | rule.StringParam("a"), 387 | rule.BoolParam("b"), 388 | rule.Int64Param("c"), 389 | rule.BoolParam("d"), 390 | ), 391 | rule.BoolValue(true), 392 | ), 393 | ) 394 | require.NoError(t, err) 395 | 396 | _, err = s.Put(context.Background(), path, rs3) 397 | require.True(t, store.IsValidationError(err)) 398 | 399 | // changing param types 400 | rs4, err := regula.NewBoolRuleset( 401 | rule.New( 402 | rule.Eq( 403 | rule.StringParam("a"), 404 | rule.StringParam("b"), 405 | rule.Int64Param("c"), 406 | rule.BoolParam("d"), 407 | ), 408 | rule.BoolValue(true), 409 | ), 410 | ) 411 | require.NoError(t, err) 412 | 413 | _, err = s.Put(context.Background(), path, rs4) 414 | require.True(t, store.IsValidationError(err)) 415 | 416 | // adding new rule with different param types 417 | rs5, err := regula.NewBoolRuleset( 418 | rule.New( 419 | rule.Eq( 420 | rule.StringParam("a"), 421 | rule.StringParam("b"), 422 | rule.Int64Param("c"), 423 | rule.BoolParam("d"), 424 | ), 425 | rule.BoolValue(true), 426 | ), 427 | rule.New( 428 | rule.Eq( 429 | rule.StringParam("a"), 430 | rule.StringParam("b"), 431 | rule.Int64Param("c"), 432 | rule.BoolParam("d"), 433 | ), 434 | rule.BoolValue(true), 435 | ), 436 | ) 437 | 438 | _, err = s.Put(context.Background(), path, rs5) 439 | require.True(t, store.IsValidationError(err)) 440 | 441 | // adding new rule with correct param types but less 442 | rs6, _ := regula.NewBoolRuleset( 443 | rule.New( 444 | rule.Eq( 445 | rule.StringParam("a"), 446 | rule.BoolParam("b"), 447 | ), 448 | rule.BoolValue(true), 449 | ), 450 | rule.New( 451 | rule.Eq( 452 | rule.StringParam("a"), 453 | rule.BoolParam("b"), 454 | ), 455 | rule.BoolValue(true), 456 | ), 457 | ) 458 | 459 | _, err = s.Put(context.Background(), path, rs6) 460 | require.NoError(t, err) 461 | }) 462 | } 463 | 464 | func TestWatch(t *testing.T) { 465 | t.Parallel() 466 | 467 | s, cleanup := newEtcdRulesetService(t) 468 | defer cleanup() 469 | 470 | var wg sync.WaitGroup 471 | 472 | wg.Add(1) 473 | go func() { 474 | defer wg.Done() 475 | 476 | time.Sleep(time.Second) 477 | 478 | rs, _ := regula.NewBoolRuleset(rule.New(rule.True(), rule.BoolValue(true))) 479 | 480 | createRuleset(t, s, "aa", rs) 481 | createRuleset(t, s, "ab", rs) 482 | createRuleset(t, s, "a/1", rs) 483 | }() 484 | 485 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 486 | defer cancel() 487 | 488 | events, err := s.Watch(ctx, "a", "") 489 | require.NoError(t, err) 490 | require.Len(t, events.Events, 1) 491 | require.NotEmpty(t, events.Revision) 492 | require.Equal(t, "aa", events.Events[0].Path) 493 | require.Equal(t, store.RulesetPutEvent, events.Events[0].Type) 494 | 495 | wg.Wait() 496 | 497 | events, err = s.Watch(ctx, "a", events.Revision) 498 | require.NoError(t, err) 499 | require.Len(t, events.Events, 2) 500 | require.NotEmpty(t, events.Revision) 501 | require.Equal(t, store.RulesetPutEvent, events.Events[0].Type) 502 | require.Equal(t, "ab", events.Events[0].Path) 503 | require.Equal(t, store.RulesetPutEvent, events.Events[1].Type) 504 | require.Equal(t, "a/1", events.Events[1].Path) 505 | } 506 | 507 | func TestEval(t *testing.T) { 508 | t.Parallel() 509 | 510 | s, cleanup := newEtcdRulesetService(t) 511 | defer cleanup() 512 | 513 | rs, _ := regula.NewBoolRuleset( 514 | rule.New( 515 | rule.Eq( 516 | rule.StringParam("id"), 517 | rule.StringValue("123"), 518 | ), 519 | rule.BoolValue(true), 520 | ), 521 | ) 522 | 523 | entry := createRuleset(t, s, "a", rs) 524 | 525 | t.Run("OK", func(t *testing.T) { 526 | res, err := s.Eval(context.Background(), "a", regula.Params{ 527 | "id": "123", 528 | }) 529 | require.NoError(t, err) 530 | require.Equal(t, entry.Version, res.Version) 531 | require.Equal(t, rule.BoolValue(true), res.Value) 532 | }) 533 | 534 | t.Run("NotFound", func(t *testing.T) { 535 | _, err := s.Eval(context.Background(), "notexists", regula.Params{ 536 | "id": "123", 537 | }) 538 | require.Equal(t, regula.ErrRulesetNotFound, err) 539 | }) 540 | } 541 | 542 | func TestEvalVersion(t *testing.T) { 543 | t.Parallel() 544 | 545 | s, cleanup := newEtcdRulesetService(t) 546 | defer cleanup() 547 | 548 | rs, _ := regula.NewBoolRuleset( 549 | rule.New( 550 | rule.Eq( 551 | rule.StringParam("id"), 552 | rule.StringValue("123"), 553 | ), 554 | rule.BoolValue(true), 555 | ), 556 | ) 557 | 558 | entry := createRuleset(t, s, "a", rs) 559 | 560 | t.Run("OK", func(t *testing.T) { 561 | res, err := s.EvalVersion(context.Background(), "a", entry.Version, regula.Params{ 562 | "id": "123", 563 | }) 564 | require.NoError(t, err) 565 | require.Equal(t, entry.Version, res.Version) 566 | require.Equal(t, rule.BoolValue(true), res.Value) 567 | }) 568 | 569 | t.Run("NotFound", func(t *testing.T) { 570 | _, err := s.EvalVersion(context.Background(), "b", entry.Version, regula.Params{ 571 | "id": "123", 572 | }) 573 | require.Equal(t, regula.ErrRulesetNotFound, err) 574 | }) 575 | 576 | t.Run("BadVersion", func(t *testing.T) { 577 | _, err := s.EvalVersion(context.Background(), "a", "someversion", regula.Params{ 578 | "id": "123", 579 | }) 580 | require.Equal(t, regula.ErrRulesetNotFound, err) 581 | }) 582 | } 583 | -------------------------------------------------------------------------------- /store/service.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/heetch/regula" 9 | "github.com/heetch/regula/rule" 10 | ) 11 | 12 | // Common errors. 13 | var ( 14 | ErrNotFound = errors.New("not found") 15 | ErrNotModified = errors.New("not modified") 16 | ErrInvalidContinueToken = errors.New("invalid continue token") 17 | ) 18 | 19 | // ValidationError gives informations about the reason of failed validation. 20 | type ValidationError struct { 21 | Field string 22 | Value string 23 | Reason string 24 | } 25 | 26 | func (v *ValidationError) Error() string { 27 | return fmt.Sprintf("invalid %s with value '%s': %s", v.Field, v.Value, v.Reason) 28 | } 29 | 30 | // IsValidationError indicates if the given error is a ValidationError pointer. 31 | func IsValidationError(err error) bool { 32 | _, ok := err.(*ValidationError) 33 | return ok 34 | } 35 | 36 | // RulesetService manages rulesets. 37 | type RulesetService interface { 38 | // List returns all the rulesets entries under the given prefix. 39 | List(ctx context.Context, prefix string, limit int, continueToken string) (*RulesetEntries, error) 40 | // Latest returns the latest version of the ruleset entry which corresponds to the given path. 41 | Latest(ctx context.Context, path string) (*RulesetEntry, error) 42 | // OneByVersion returns the ruleset entry which corresponds to the given path at the given version. 43 | OneByVersion(ctx context.Context, path, version string) (*RulesetEntry, error) 44 | // Watch a prefix for changes and return a list of events. 45 | Watch(ctx context.Context, prefix string, revision string) (*RulesetEvents, error) 46 | // Put is used to store a ruleset version. 47 | Put(ctx context.Context, path string, ruleset *regula.Ruleset) (*RulesetEntry, error) 48 | // Eval evaluates a ruleset given a path and a set of parameters. It implements the regula.Evaluator interface. 49 | Eval(ctx context.Context, path string, params rule.Params) (*regula.EvalResult, error) 50 | // EvalVersion evaluates a ruleset given a path and a set of parameters. It implements the regula.Evaluator interface. 51 | EvalVersion(ctx context.Context, path, version string, params rule.Params) (*regula.EvalResult, error) 52 | } 53 | 54 | // RulesetEntry holds a ruleset and its metadata. 55 | type RulesetEntry struct { 56 | Path string 57 | Version string 58 | Ruleset *regula.Ruleset 59 | } 60 | 61 | // RulesetEntries holds a list of ruleset entries. 62 | type RulesetEntries struct { 63 | Entries []RulesetEntry 64 | Revision string // revision when the request was applied 65 | Continue string // token of the next page, if any 66 | } 67 | 68 | // List of possible events executed against a ruleset. 69 | const ( 70 | RulesetPutEvent = "PUT" 71 | ) 72 | 73 | // RulesetEvent describes an event that occured on a ruleset. 74 | type RulesetEvent struct { 75 | Type string 76 | Path string 77 | Version string 78 | Ruleset *regula.Ruleset 79 | } 80 | 81 | // RulesetEvents holds a list of events occured on a group of rulesets. 82 | type RulesetEvents struct { 83 | Events []RulesetEvent 84 | Revision string 85 | } 86 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // Package version contains the version of Regula. 2 | package version 3 | 4 | // Version of Regula. 5 | const Version = "v0.6.0" 6 | --------------------------------------------------------------------------------