├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── codec.go ├── conn.go ├── doc.go ├── errors.go ├── examples ├── 01-hello-world │ ├── README.md │ └── main.go ├── 02-edit-text │ ├── .gitignore │ ├── README.md │ ├── index.html │ └── main.go ├── 03-book-collection │ ├── .gitignore │ ├── README.md │ ├── main.go │ └── wwwroot │ │ ├── app.js │ │ ├── index.html │ │ └── style.css ├── 04-book-collection-store │ ├── .gitignore │ ├── README.md │ ├── bookhandler.go │ ├── bookshandler.go │ ├── bookstore.go │ ├── main.go │ └── wwwroot │ │ ├── app.js │ │ ├── index.html │ │ └── style.css └── 05-search-query │ ├── .gitignore │ ├── README.md │ ├── countries.go │ ├── customer.go │ ├── customerhandler.go │ ├── customerqueryhandler.go │ ├── customerstore.go │ ├── main.go │ ├── mock_customers.json │ └── wwwroot │ ├── app.js │ ├── index.html │ └── style.css ├── getrequest.go ├── go.mod ├── go.sum ├── group.go ├── group_test.go ├── logger ├── logger.go ├── memlogger.go └── stdlogger.go ├── middleware ├── README.md ├── badgerdb.go ├── doc.go ├── example_test.go └── resbadger │ ├── README.md │ ├── collection.go │ ├── index.go │ ├── model.go │ ├── querycollection.go │ ├── resbadger.go │ └── resourcehandler.go ├── mux.go ├── pattern.go ├── pattern_test.go ├── queryevent.go ├── request.go ├── resource.go ├── resprot ├── README.md ├── doc.go ├── resprot.go └── resprot_test.go ├── restest ├── README.md ├── assert.go ├── codec.go ├── doc.go ├── event.go ├── mockconn.go ├── msg.go ├── natsrequest.go └── session.go ├── scripts ├── check.sh ├── cover.sh ├── install-checks.sh └── lint.sh ├── service.go ├── store ├── README.md ├── badgerstore │ ├── index.go │ ├── querystore.go │ └── store.go ├── mockstore │ ├── querystore.go │ └── store.go ├── querystorehandler.go ├── store.go ├── storehandler.go ├── transformer.go └── value.go ├── test ├── 00service_test.go ├── 01register_handler_test.go ├── 02access_request_test.go ├── 03get_request_test.go ├── 04call_request_test.go ├── 05new_request_test.go ├── 06auth_request_test.go ├── 07resource_request_methods_test.go ├── 08resource_request_path_params_test.go ├── 09meta_test.go ├── 10resource_request_event_test.go ├── 11resource_request_value_test.go ├── 12resource_request_apply_test.go ├── 13listener_test.go ├── 20type_ref_test.go ├── 21error_test.go ├── 22query_event_test.go ├── 23mux_test.go ├── 30store_handler_test.go ├── 31store_handler_transformer_test.go ├── 32store_queryhandler_test.go ├── resources.go └── test.go ├── types.go └── worker.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: [jirenius] 3 | custom: ["https://www.paypal.me/jirenius"] 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | 12 | go-legacy-test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | go-version: [ '1.20', '1.21' ] 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | - name: Go get 24 | run: go get -t ./... 25 | - name: Build 26 | run: go build -v ./... 27 | - name: Test 28 | run: go test -v ./... 29 | 30 | test: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Set up Go 35 | uses: actions/setup-go@v5 36 | with: 37 | go-version: '1.22.4' 38 | - name: Go get 39 | run: go get -t ./... 40 | - name: Build 41 | run: go build -v ./... 42 | - name: Test 43 | run: go test -v -covermode=atomic -coverprofile=cover.out -coverpkg=. ./... 44 | - name: Install goveralls 45 | run: go install github.com/mattn/goveralls@latest 46 | - name: Send coverage 47 | env: 48 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | run: goveralls -coverprofile=cover.out -service=github 50 | 51 | lint: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4 55 | - name: Set up Go 56 | uses: actions/setup-go@v5 57 | with: 58 | go-version: '1.22.4' 59 | - name: Install checks 60 | run: | 61 | go install honnef.co/go/tools/cmd/staticcheck@latest 62 | go install github.com/client9/misspell/cmd/misspell@latest 63 | - name: Go get 64 | run: go get -t ./... 65 | - name: Go vet 66 | run: go vet $(go list ./... | grep -v /vendor/) 67 | - name: Go mod 68 | run: go mod tidy; git diff --exit-code go.mod go.sum 69 | - name: Go fmt 70 | run: go fmt $(go list ./... | grep -v /vendor/); git diff --exit-code 71 | - name: Staticcheck 72 | run: staticcheck -checks all,-ST1000 ./... 73 | - name: Misspell 74 | run: misspell -error -locale US . 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | # Make sure the GITHUB_TOKEN has permission to upload to our releases 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | 14 | create_release: 15 | name: Create Release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Check out the repo 19 | uses: actions/checkout@v4 20 | - name: Create release draft 21 | id: create_release 22 | uses: actions/create-release@v1 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | with: 26 | tag_name: ${{ github.ref }} 27 | release_name: Release ${{ github.ref_name }} 28 | body: | 29 | A new release 30 | draft: true 31 | prerelease: false 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | debug 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool 13 | *.out 14 | 15 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Samuel Jirénius 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. -------------------------------------------------------------------------------- /codec.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import "encoding/json" 4 | 5 | type resRequest struct { 6 | CID string `json:"cid"` 7 | Params json.RawMessage `json:"params"` 8 | Token json.RawMessage `json:"token"` 9 | Header map[string][]string `json:"header"` 10 | Host string `json:"host"` 11 | RemoteAddr string `json:"remoteAddr"` 12 | URI string `json:"uri"` 13 | Query string `json:"query"` 14 | IsHTTP bool `json:"isHttp"` 15 | } 16 | 17 | type metaObject struct { 18 | Status int `json:"status,omitempty"` 19 | Header map[string][]string `json:"header,omitempty"` 20 | } 21 | 22 | type successResponse struct { 23 | Result interface{} `json:"result"` 24 | Meta *metaObject `json:"meta,omitempty"` 25 | } 26 | 27 | type resourceResponse struct { 28 | Resource Ref `json:"resource"` 29 | Meta *metaObject `json:"meta,omitempty"` 30 | } 31 | 32 | type errorResponse struct { 33 | Error *Error `json:"error"` 34 | Meta *metaObject `json:"meta,omitempty"` 35 | } 36 | 37 | type accessResponse struct { 38 | Get bool `json:"get,omitempty"` 39 | Call string `json:"call,omitempty"` 40 | Meta *metaObject `json:"meta,omitempty"` 41 | } 42 | 43 | type modelResponse struct { 44 | Model interface{} `json:"model"` 45 | Query string `json:"query,omitempty"` 46 | } 47 | 48 | type collectionResponse struct { 49 | Collection interface{} `json:"collection"` 50 | Query string `json:"query,omitempty"` 51 | } 52 | 53 | type resetEvent struct { 54 | Resources []string `json:"resources,omitempty"` 55 | Access []string `json:"access,omitempty"` 56 | } 57 | 58 | type tokenEvent struct { 59 | Token interface{} `json:"token"` 60 | TID string `json:"tid,omitempty"` 61 | } 62 | 63 | type tokenResetEvent struct { 64 | TIDs []string `json:"tids"` 65 | Subject string `json:"subject"` 66 | } 67 | 68 | type changeEvent struct { 69 | Values map[string]interface{} `json:"values"` 70 | } 71 | 72 | type addEvent struct { 73 | Value interface{} `json:"value"` 74 | Idx int `json:"idx"` 75 | } 76 | 77 | type removeEvent struct { 78 | Idx int `json:"idx"` 79 | } 80 | 81 | type resQueryEvent struct { 82 | Subject string `json:"subject"` 83 | } 84 | 85 | type resQueryRequest struct { 86 | Query string `json:"query"` 87 | } 88 | 89 | type resEvent struct { 90 | Event string `json:"event"` 91 | Data interface{} `json:"data"` 92 | } 93 | 94 | type queryResponse struct { 95 | Events []resEvent `json:"events"` 96 | } 97 | -------------------------------------------------------------------------------- /conn.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import nats "github.com/nats-io/nats.go" 4 | 5 | // Conn is an interface that represents a connection to a NATS server. 6 | // It is implemented by nats.Conn. 7 | type Conn interface { 8 | // Publish publishes the data argument to the given subject 9 | Publish(subject string, payload []byte) error 10 | 11 | // PublishRequest publishes a request expecting a response on the reply 12 | // subject. 13 | PublishRequest(subject, reply string, data []byte) error 14 | 15 | // ChanSubscribe subscribes to messages matching the subject pattern. 16 | ChanSubscribe(subject string, ch chan *nats.Msg) (*nats.Subscription, error) 17 | 18 | // ChanQueueSubscribe subscribes to messages matching the subject pattern. 19 | // All subscribers with the same queue name will form the queue group and 20 | // only one member of the group will be selected to receive any given 21 | // message, which will be placed on the channel. 22 | ChanQueueSubscribe(subject, queue string, ch chan *nats.Msg) (*nats.Subscription, error) 23 | 24 | // Close will close the connection to the server. 25 | Close() 26 | } 27 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package res is used to create REST, real time, and RPC APIs, where all your 3 | reactive web clients are synchronized seamlessly through Resgate: 4 | 5 | https://github.com/resgateio/resgate 6 | 7 | The implementation provides structs and methods for creating services that 8 | listen to requests and send events over NATS server. 9 | 10 | # Concurrency 11 | 12 | Requests are handled concurrently for multiple resources, but the package 13 | guarantees that only one goroutine is executing handlers for any unique resource 14 | at any one time. This allows handlers to modify models and collections without 15 | additional synchronization such as mutexes. 16 | 17 | # Usage 18 | 19 | Create a new service: 20 | 21 | s := res.NewService("myservice") 22 | 23 | Add handlers for a model resource: 24 | 25 | mymodel := map[string]interface{}{"name": "foo", "value": 42} 26 | s.Handle("mymodel", 27 | res.Access(res.AccessGranted), 28 | res.GetModel(func(r res.ModelRequest) { 29 | r.Model(mymodel) 30 | }), 31 | ) 32 | 33 | Add handlers for a collection resource: 34 | 35 | mycollection := []string{"first", "second", "third"} 36 | s.Handle("mycollection", 37 | res.Access(res.AccessGranted), 38 | res.GetCollection(func(r res.CollectionRequest) { 39 | r.Collection(mycollection) 40 | }), 41 | ) 42 | 43 | Add handlers for parameterized resources: 44 | 45 | s.Handle("article.$id", 46 | res.Access(res.AccessGranted), 47 | res.GetModel(func(r res.ModelRequest) { 48 | article := getArticle(r.PathParam("id")) 49 | if article == nil { 50 | r.NotFound() 51 | } else { 52 | r.Model(article) 53 | } 54 | }), 55 | ) 56 | 57 | Add handlers for method calls: 58 | 59 | s.Handle("math", 60 | res.Access(res.AccessGranted), 61 | res.Call("double", func(r res.CallRequest) { 62 | var p struct { 63 | Value int `json:"value"` 64 | } 65 | r.ParseParams(&p) 66 | r.OK(p.Value * 2) 67 | }), 68 | ) 69 | 70 | Send change event on model update: 71 | 72 | s.With("myservice.mymodel", func(r res.Resource) { 73 | mymodel["name"] = "bar" 74 | r.ChangeEvent(map[string]interface{}{"name": "bar"}) 75 | }) 76 | 77 | Send add event on collection update: 78 | 79 | s.With("myservice.mycollection", func(r res.Resource) { 80 | mycollection = append(mycollection, "fourth") 81 | r.AddEvent("fourth", len(mycollection)-1) 82 | }) 83 | 84 | Add handlers for authentication: 85 | 86 | s.Handle("myauth", 87 | res.Auth("login", func(r res.AuthRequest) { 88 | var p struct { 89 | Password string `json:"password"` 90 | } 91 | r.ParseParams(&p) 92 | if p.Password != "mysecret" { 93 | r.InvalidParams("Wrong password") 94 | } else { 95 | r.TokenEvent(map[string]string{"user": "admin"}) 96 | r.OK(nil) 97 | } 98 | }), 99 | ) 100 | 101 | Add handlers for access control: 102 | 103 | s.Handle("mymodel", 104 | res.Access(func(r res.AccessRequest) { 105 | var t struct { 106 | User string `json:"user"` 107 | } 108 | r.ParseToken(&t) 109 | if t.User == "admin" { 110 | r.AccessGranted() 111 | } else { 112 | r.AccessDenied() 113 | } 114 | }), 115 | res.GetModel(func(r res.ModelRequest) { 116 | r.Model(mymodel) 117 | }), 118 | ) 119 | 120 | Start service: 121 | 122 | s.ListenAndServe("nats://localhost:4222") 123 | */ 124 | package res 125 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | // Error represents an RES error 4 | type Error struct { 5 | Code string `json:"code"` 6 | Message string `json:"message"` 7 | Data interface{} `json:"data,omitempty"` 8 | } 9 | 10 | func (e *Error) Error() string { 11 | return e.Message 12 | } 13 | 14 | // ToError converts an error to an *Error. If it isn't of type *Error already, it will become a system.internalError. 15 | func ToError(err error) *Error { 16 | rerr, ok := err.(*Error) 17 | if !ok { 18 | rerr = InternalError(err) 19 | } 20 | return rerr 21 | } 22 | 23 | // InternalError converts an error to an *Error with the code system.internalError. 24 | func InternalError(err error) *Error { 25 | return &Error{Code: CodeInternalError, Message: "Internal error: " + err.Error()} 26 | } 27 | 28 | // Predefined error codes 29 | const ( 30 | CodeAccessDenied = "system.accessDenied" 31 | CodeInternalError = "system.internalError" 32 | CodeInvalidParams = "system.invalidParams" 33 | CodeInvalidQuery = "system.invalidQuery" 34 | CodeMethodNotFound = "system.methodNotFound" 35 | CodeNotFound = "system.notFound" 36 | CodeTimeout = "system.timeout" 37 | ) 38 | 39 | // Predefined errors 40 | var ( 41 | ErrAccessDenied = &Error{Code: CodeAccessDenied, Message: "Access denied"} 42 | ErrInternalError = &Error{Code: CodeInternalError, Message: "Internal error"} 43 | ErrInvalidParams = &Error{Code: CodeInvalidParams, Message: "Invalid parameters"} 44 | ErrInvalidQuery = &Error{Code: CodeInvalidQuery, Message: "Invalid query"} 45 | ErrMethodNotFound = &Error{Code: CodeMethodNotFound, Message: "Method not found"} 46 | ErrNotFound = &Error{Code: CodeNotFound, Message: "Not found"} 47 | ErrTimeout = &Error{Code: CodeTimeout, Message: "Request timeout"} 48 | ) 49 | -------------------------------------------------------------------------------- /examples/01-hello-world/README.md: -------------------------------------------------------------------------------- 1 | # Hello World Example 2 | 3 | **Tags:** *Models* 4 | 5 | ## Description 6 | Simple service serving a message to the world. 7 | 8 | ## Prerequisite 9 | 10 | * [Download](https://golang.org/dl/) and install Go 11 | * [Install](https://resgate.io/docs/get-started/installation/) *NATS Server* and *Resgate* (done with 3 docker commands). 12 | 13 | ## Install and run 14 | 15 | ```text 16 | git clone https://github.com/jirenius/go-res 17 | cd go-res/examples/01-hello-world 18 | go run . 19 | ``` 20 | 21 | ## Things to try out 22 | 23 | ### Access API through HTTP 24 | * Open the browser and go to: 25 | ```text 26 | http://localhost:8080/api/example/model 27 | ``` 28 | 29 | ### Access API through WebSocket 30 | * Open *Chrome* and go to [resgate.io - resource viewer](https://resgate.io/viewer). 31 | * Type in the resource ID below, and click *View*: 32 | ```text 33 | example.model 34 | ``` 35 | > **Note** 36 | > 37 | > Chrome allows websites to connect to *localhost*, while other browsers may give a security error. 38 | 39 | ### Real time update on static resource 40 | * Stop the project, and change the `"Hello, World!"` message in *main.go*. 41 | * Restart the project and observe how the message is updated in the viewer (see [above](#access-api-through-websocket)). 42 | 43 | ### Get resource with ResClient 44 | * In the [resgate.io - resource viewer](https://resgate.io/viewer), open the DevTools console (*Ctrl+Shift+J*). 45 | * Type the following command, and press *Enter*: 46 | ```javascript 47 | client.get("example.model").then(m => console.log(m.message)); 48 | ``` 49 | > **Note** 50 | > 51 | > The resource viewer app stores the *ResClient* instance in the global `client` variable, for easy access. 52 | 53 | -------------------------------------------------------------------------------- /examples/01-hello-world/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import res "github.com/jirenius/go-res" 4 | 5 | func main() { 6 | s := res.NewService("example") 7 | s.Handle("model", 8 | res.Access(res.AccessGranted), 9 | res.GetModel(func(r res.ModelRequest) { 10 | r.Model(struct { 11 | Message string `json:"message"` 12 | }{"Hello, World!"}) 13 | }), 14 | ) 15 | s.ListenAndServe("nats://localhost:4222") 16 | } 17 | -------------------------------------------------------------------------------- /examples/02-edit-text/.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | /edit-text 3 | -------------------------------------------------------------------------------- /examples/02-edit-text/README.md: -------------------------------------------------------------------------------- 1 | # Edit Text Example 2 | 3 | **Tags:** *Models*, *Call methods*, *Client subscriptions* 4 | 5 | ## Description 6 | A simple text field that can be edited by multiple clients simultaneously. 7 | 8 | ## Prerequisite 9 | 10 | * [Download](https://golang.org/dl/) and install Go 11 | * [Install](https://resgate.io/docs/get-started/installation/) *NATS Server* and *Resgate* (done with 3 docker commands). 12 | 13 | ## Install and run 14 | 15 | ```text 16 | git clone https://github.com/jirenius/go-res 17 | cd go-res/examples/02-edit-text 18 | go run . 19 | ``` 20 | 21 | Open the client 22 | ``` 23 | http://localhost:8082 24 | ``` 25 | 26 | ## Things to try out 27 | 28 | ### Realtime updates 29 | * Open the client in two separate tabs. 30 | * Edit the message in one tab, and observe realtime updates in both. 31 | 32 | ### System reset 33 | * Stop the service. 34 | * Edit the default text (`"Hello, Go World!"`) in *main.go*. 35 | * Restart the service to observe resetting of the message in all clients. 36 | 37 | ## API 38 | 39 | Request | Resource | Description 40 | --- | --- | --- 41 | *get* | `text.shared` | Simple model. 42 | *call* | `text.shared.set` | Sets the model's *message* property. 43 | 44 | ## REST API 45 | 46 | Resources can be retrieved using ordinary HTTP GET requests, and methods can be called using HTTP POST requests. 47 | 48 | ### Get model 49 | ``` 50 | GET http://localhost:8080/api/text/shared 51 | ``` 52 | 53 | ### Update model 54 | ``` 55 | POST http://localhost:8080/api/text/shared/set 56 | ``` 57 | *Body* 58 | ``` 59 | { "message": "Updated through HTTP" } 60 | ``` -------------------------------------------------------------------------------- /examples/02-edit-text/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Edit Text Example 6 | 7 | 8 | 9 | 10 |

Try running the client in two separate tabs!

11 |

Web resource: http://localhost:8080/api/text/shared

12 |
13 |
14 | 41 | 42 | -------------------------------------------------------------------------------- /examples/02-edit-text/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | This is an example of a simple text field that can be edited by multiple clients. 3 | * It exposes a single resource: "text.shared". 4 | * It allows setting the resource's Message property through the "set" method. 5 | * It resets the model on server restart. 6 | * It serves a web client at http://localhost:8082 7 | */ 8 | package main 9 | 10 | import ( 11 | "log" 12 | "net/http" 13 | 14 | "github.com/jirenius/go-res" 15 | ) 16 | 17 | // Model is the structure for our model resource 18 | type Model struct { 19 | Message string `json:"message"` 20 | } 21 | 22 | // The model we will serve 23 | var shared = &Model{Message: "Hello, Go World!"} 24 | 25 | func main() { 26 | // Create a new RES Service 27 | s := res.NewService("text") 28 | 29 | // Add handlers for "text.shared" resource 30 | s.Handle("shared", 31 | // Allow everone to access this resource 32 | res.Access(res.AccessGranted), 33 | 34 | // Respond to get requests with the model 35 | res.GetModel(func(r res.ModelRequest) { 36 | r.Model(shared) 37 | }), 38 | 39 | // Handle setting of the message 40 | res.Set(func(r res.CallRequest) { 41 | var p struct { 42 | Message *string `json:"message,omitempty"` 43 | } 44 | r.ParseParams(&p) 45 | 46 | // Check if the message property was changed 47 | if p.Message != nil && *p.Message != shared.Message { 48 | // Update the model 49 | shared.Message = *p.Message 50 | // Send a change event with updated fields 51 | r.ChangeEvent(map[string]interface{}{"message": p.Message}) 52 | } 53 | 54 | // Send success response 55 | r.OK(nil) 56 | }), 57 | ) 58 | 59 | // Run a simple webserver to serve the client. 60 | // This is only for the purpose of making the example easier to run. 61 | go func() { log.Fatal(http.ListenAndServe(":8082", http.FileServer(http.Dir("./")))) }() 62 | log.Println("Client at: http://localhost:8082/") 63 | 64 | // Start the service 65 | s.ListenAndServe("nats://localhost:4222") 66 | } 67 | -------------------------------------------------------------------------------- /examples/03-book-collection/.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | /book-collection 3 | -------------------------------------------------------------------------------- /examples/03-book-collection/README.md: -------------------------------------------------------------------------------- 1 | # Book Collection Example 2 | 3 | **Tags:** *Models*, *Collections*, *Linked resources*, *Call methods*, *Resource parameters* 4 | 5 | ## Description 6 | A simple library management system, listing books by title and author. Books can be edited, added, or deleted by multiple users simultaneously. 7 | 8 | ## Prerequisite 9 | 10 | * [Download](https://golang.org/dl/) and install Go 11 | * [Install](https://resgate.io/docs/get-started/installation/) *NATS Server* and *Resgate* (done with 3 docker commands). 12 | 13 | ## Install and run 14 | 15 | ```text 16 | git clone https://github.com/jirenius/go-res 17 | cd go-res/examples/03-book-collection 18 | go run . 19 | ``` 20 | 21 | Open the client 22 | ```text 23 | http://localhost:8083 24 | ``` 25 | 26 | 27 | ## Things to try out 28 | 29 | ### Realtime updates 30 | * Open the client in two separate tabs. 31 | * Add/Edit/Delete entries to observe realtime updates. 32 | 33 | ### System reset 34 | * Open the client and make some changes. 35 | * Restart the service to observe resetting of resources in all clients. 36 | 37 | ### Resynchronization 38 | * Open the client on two separate devices. 39 | * Disconnect one device. 40 | * Make changes using the other device. 41 | * Reconnect the first device to observe resynchronization. 42 | 43 | ## API 44 | 45 | Request | Resource | Description 46 | --- | --- | --- 47 | *get* | `library.books` | Collection of book model references. 48 | *call* | `library.books.new` | Creates a new book. 49 | *get* | `library.book.` | Models representing books. 50 | *call* | `library.book..set` | Sets the books' *title* and *author* properties. 51 | *call* | `library.book..delete` | Deletes a book. 52 | 53 | ## REST API 54 | 55 | Resources can be retrieved using ordinary HTTP GET requests, and methods can be called using HTTP POST requests. 56 | 57 | ### Get book collection 58 | ``` 59 | GET http://localhost:8080/api/library/books 60 | ``` 61 | 62 | ### Get book 63 | ``` 64 | GET http://localhost:8080/api/library/book/ 65 | ``` 66 | 67 | ### Update book properties 68 | ``` 69 | POST http://localhost:8080/api/library/book//set 70 | ``` 71 | *Body* 72 | ``` 73 | { "title": "Animal Farming" } 74 | ``` 75 | 76 | ### Add new book 77 | ``` 78 | POST http://localhost:8080/api/library/books/new 79 | ``` 80 | *Body* 81 | ``` 82 | { "title": "Dracula", "author": "Bram Stoker" } 83 | ``` 84 | 85 | ### Delete book 86 | ``` 87 | POST http://localhost:8080/api/library/books/delete 88 | ``` 89 | *Body* 90 | ``` 91 | { "id": } 92 | ``` 93 | -------------------------------------------------------------------------------- /examples/03-book-collection/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | This is an example RES service that shows a lists of books, where book titles can be added, 3 | edited and deleted by multiple users simultaneously. 4 | 5 | * It exposes a collection, `library.books`, containing book model references. 6 | * It exposes book models, `library.book.`, of each book. 7 | * It allows setting the books' *title* and *author* property through the `set` method. 8 | * It allows creating new books that are added to the collection with the `new` method. 9 | * It allows deleting existing books from the collection with the `delete` method. 10 | * It verifies that a *title* and *author* is always set. 11 | * It resets the collection and models on server restart. 12 | * It serves a web client at http://localhost:8083 13 | */ 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "log" 19 | "net/http" 20 | "strings" 21 | 22 | "github.com/jirenius/go-res" 23 | ) 24 | 25 | // Book represents a book model 26 | type Book struct { 27 | ID int64 `json:"id"` 28 | Title string `json:"title"` 29 | Author string `json:"author"` 30 | } 31 | 32 | // Map of all book models 33 | var bookModels = map[string]*Book{ 34 | "library.book.1": {ID: 1, Title: "Animal Farm", Author: "George Orwell"}, 35 | "library.book.2": {ID: 2, Title: "Brave New World", Author: "Aldous Huxley"}, 36 | "library.book.3": {ID: 3, Title: "Coraline", Author: "Neil Gaiman"}, 37 | } 38 | 39 | // Collection of books 40 | var books = []res.Ref{ 41 | res.Ref("library.book.1"), 42 | res.Ref("library.book.2"), 43 | res.Ref("library.book.3"), 44 | } 45 | 46 | // ID counter for new book models 47 | var nextBookID int64 = 4 48 | 49 | func main() { 50 | // Create a new RES Service 51 | s := res.NewService("library") 52 | 53 | // Add handlers for "library.book.$id" models 54 | s.Handle( 55 | "book.$id", 56 | res.Access(res.AccessGranted), 57 | res.GetModel(getBookHandler), 58 | res.Set(setBookHandler), 59 | ) 60 | 61 | // Add handlers for "library.books" collection 62 | s.Handle( 63 | "books", 64 | res.Access(res.AccessGranted), 65 | res.GetCollection(getBooksHandler), 66 | res.Call("new", newBookHandler), 67 | res.Call("delete", deleteBookHandler), 68 | ) 69 | 70 | // Run a simple webserver to serve the client. 71 | // This is only for the purpose of making the example easier to run. 72 | go func() { log.Fatal(http.ListenAndServe(":8083", http.FileServer(http.Dir("wwwroot/")))) }() 73 | fmt.Println("Client at: http://localhost:8083/") 74 | 75 | s.ListenAndServe("nats://localhost:4222") 76 | } 77 | 78 | func getBookHandler(r res.ModelRequest) { 79 | book := bookModels[r.ResourceName()] 80 | if book == nil { 81 | r.NotFound() 82 | return 83 | } 84 | r.Model(book) 85 | } 86 | 87 | func setBookHandler(r res.CallRequest) { 88 | book := bookModels[r.ResourceName()] 89 | if book == nil { 90 | r.NotFound() 91 | return 92 | } 93 | 94 | // Unmarshal parameters to an anonymous struct 95 | var p struct { 96 | Title *string `json:"title,omitempty"` 97 | Author *string `json:"author,omitempty"` 98 | } 99 | r.ParseParams(&p) 100 | 101 | // Validate title param 102 | if p.Title != nil { 103 | *p.Title = strings.TrimSpace(*p.Title) 104 | if *p.Title == "" { 105 | r.InvalidParams("Title must not be empty") 106 | return 107 | } 108 | } 109 | 110 | // Validate author param 111 | if p.Author != nil { 112 | *p.Author = strings.TrimSpace(*p.Author) 113 | if *p.Author == "" { 114 | r.InvalidParams("Author must not be empty") 115 | return 116 | } 117 | } 118 | 119 | changed := make(map[string]interface{}, 2) 120 | 121 | // Check if the title property was changed 122 | if p.Title != nil && *p.Title != book.Title { 123 | // Update the model. 124 | book.Title = *p.Title 125 | changed["title"] = book.Title 126 | } 127 | 128 | // Check if the author property was changed 129 | if p.Author != nil && *p.Author != book.Author { 130 | // Update the model. 131 | book.Author = *p.Author 132 | changed["author"] = book.Author 133 | } 134 | 135 | // Send a change event with updated fields 136 | r.ChangeEvent(changed) 137 | 138 | // Send success response 139 | r.OK(nil) 140 | } 141 | 142 | func getBooksHandler(r res.CollectionRequest) { 143 | r.Collection(books) 144 | } 145 | 146 | func newBookHandler(r res.CallRequest) { 147 | var p struct { 148 | Title string `json:"title"` 149 | Author string `json:"author"` 150 | } 151 | r.ParseParams(&p) 152 | 153 | // Trim whitespace 154 | title := strings.TrimSpace(p.Title) 155 | author := strings.TrimSpace(p.Author) 156 | 157 | // Check if we received both title and author 158 | if title == "" || author == "" { 159 | r.InvalidParams("Must provide both title and author") 160 | return 161 | } 162 | // Create a new book model 163 | rid := fmt.Sprintf("library.book.%d", nextBookID) 164 | book := &Book{ID: nextBookID, Title: title, Author: author} 165 | nextBookID++ 166 | bookModels[rid] = book 167 | 168 | // Convert resource ID to a resource reference 169 | ref := res.Ref(rid) 170 | // Send add event 171 | r.AddEvent(ref, len(books)) 172 | // Appends the book reference to the collection 173 | books = append(books, ref) 174 | 175 | // Respond with a reference to the newly created book model 176 | r.Resource(rid) 177 | } 178 | 179 | func deleteBookHandler(r res.CallRequest) { 180 | // Unmarshal parameters to an anonymous struct 181 | var p struct { 182 | ID int64 `json:"id,omitempty"` 183 | } 184 | r.ParseParams(&p) 185 | 186 | rname := fmt.Sprintf("library.book.%d", p.ID) 187 | 188 | // Ddelete book if it exist 189 | if _, ok := bookModels[rname]; ok { 190 | delete(bookModels, rname) 191 | // Find the book in books collection, and remove it 192 | for i, rid := range books { 193 | if rid == res.Ref(rname) { 194 | // Remove it from slice 195 | books = append(books[:i], books[i+1:]...) 196 | // Send remove event 197 | r.RemoveEvent(i) 198 | 199 | break 200 | } 201 | } 202 | } 203 | 204 | // Send success response. It is up to the service to define if a delete 205 | // should be idempotent or not. In this case we send success regardless 206 | // if the book existed or not, making it idempotent. 207 | r.OK(nil) 208 | } 209 | -------------------------------------------------------------------------------- /examples/03-book-collection/wwwroot/app.js: -------------------------------------------------------------------------------- 1 | // This example uses modapp components to render the view. 2 | // Read more about it here: https://resgate.io/docs/writing-clients/using-modapp/ 3 | const { Elem, Txt, Button, Input } = window["modapp-base-component"]; 4 | const { CollectionList, ModelTxt } = window["modapp-resource-component"]; 5 | const ResClient = resclient.default; 6 | 7 | // Creating the client instance. 8 | let client = new ResClient('ws://localhost:8080'); 9 | 10 | // Error handling 11 | let errMsg = new Txt(); 12 | let errTimer = null; 13 | errMsg.render(document.getElementById('error-msg')); 14 | let showError = (err) => { 15 | errMsg.setText(err && err.message ? err.message : String(err)); 16 | clearTimeout(errTimer); 17 | errTimer = setTimeout(() => errMsg.setText(''), 7000); 18 | }; 19 | 20 | // Add new click callback 21 | document.getElementById('add-new').addEventListener('click', () => { 22 | let newTitle = document.getElementById('new-title'); 23 | let newAuthor = document.getElementById('new-author'); 24 | client.call('library.books', 'new', { 25 | title: newTitle.value, 26 | author: newAuthor.value 27 | }).then(() => { 28 | // Clear values on successful add 29 | newTitle.value = ""; 30 | newAuthor.value = ""; 31 | }).catch(showError); 32 | }); 33 | 34 | // Get the collection from the service. 35 | client.get('library.books').then(books => { 36 | // Render the collection of books 37 | new Elem(n => 38 | n.component(new CollectionList(books, book => { 39 | let c = new Elem(n => 40 | n.elem('div', { className: 'list-item' }, [ 41 | n.elem('div', { className: 'card shadow' }, [ 42 | // View card mode 43 | n.elem('div', { className: 'view' }, [ 44 | n.elem('div', { className: 'action' }, [ 45 | n.component(new Button(`Edit`, () => { 46 | c.getNode('titleInput').setValue(book.title); 47 | c.getNode('authorInput').setValue(book.author); 48 | c.addClass('editing'); 49 | })), 50 | n.component(new Button(`Delete`, () => books.call('delete', { id: book.id }).catch(showError))) 51 | ]), 52 | n.elem('div', { className: 'title' }, [ 53 | n.component(new ModelTxt(book, book => book.title, { tagName: 'h3' })) 54 | ]), 55 | n.elem('div', { className: 'author' }, [ 56 | n.component(new Txt("By ")), 57 | n.component(new ModelTxt(book, book => book.author)) 58 | ]) 59 | ]), 60 | // Edit card mode 61 | n.elem('div', { className: 'edit' }, [ 62 | n.elem('div', { className: 'action' }, [ 63 | n.component(new Button(`OK`, () => { 64 | book.set({ 65 | title: c.getNode('titleInput').getValue(), 66 | author: c.getNode('authorInput').getValue() 67 | }) 68 | .then(() => c.removeClass('editing')) 69 | .catch(showError); 70 | })), 71 | n.component(new Button(`Cancel`, () => c.removeClass('editing'))) 72 | ]), 73 | n.elem('div', { className: 'edit-input' }, [ 74 | n.elem('label', [ 75 | n.component(new Txt("Title", { className: 'span' })), 76 | n.component('titleInput', new Input()) 77 | ]), 78 | n.elem('label', [ 79 | n.component(new Txt("Author", { className: 'span' })), 80 | n.component('authorInput', new Input()) 81 | ]) 82 | ]) 83 | ]) 84 | ]) 85 | ]) 86 | ); 87 | return c; 88 | }, { className: 'list' })) 89 | ).render(document.getElementById('books')); 90 | }).catch(err => showError(err.code === 'system.connectionError' 91 | ? "Connection error. Are NATS Server and Resgate running?" 92 | : err 93 | )); 94 | -------------------------------------------------------------------------------- /examples/03-book-collection/wwwroot/index.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | resgate.io | book collection example 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |   |  book collection example 17 |
18 |
19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 |
27 |
28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /examples/03-book-collection/wwwroot/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #eee; 3 | font-family: 'Open Sans', sans-serif; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | /* Header */ 9 | .header { 10 | background: #161925; 11 | color: #91aec1; 12 | padding: 14px 1em; 13 | box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 14 | } 15 | 16 | a.logo:link, a.logo:visited { 17 | text-decoration: inherit; 18 | color: inherit; 19 | } 20 | 21 | .logo, .header-title { 22 | line-height: 34px; 23 | font-size: 24px; 24 | } 25 | 26 | .logo-img { 27 | vertical-align: middle; 28 | margin-right: 14px; 29 | } 30 | 31 | .logo-res { 32 | font-weight: 700; 33 | color: #91aec1; 34 | } 35 | 36 | .logo-gate { 37 | color: #5a5d93; 38 | } 39 | 40 | h1, h2, h3 { 41 | font-family: 'Encode Sans', sans-serif 42 | } 43 | 44 | h1 { 45 | margin: 0; 46 | padding: 0; 47 | line-height: 28px; 48 | font-size: 24px; 49 | font-weight: 700; 50 | } 51 | 52 | h3 { 53 | font-weight: 700; 54 | font-size: 1.2em; 55 | color: #5a5d93; 56 | } 57 | 58 | .top { 59 | margin: 1em 1em; 60 | } 61 | 62 | .shadow { 63 | box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 64 | } 65 | 66 | #books { 67 | max-width: 800px; 68 | margin: 0 1em; 69 | } 70 | 71 | .new-container { 72 | margin: 1em 0; 73 | line-height: 2em; 74 | } 75 | 76 | .new-container label { 77 | margin-right: 10px; 78 | } 79 | 80 | label { 81 | display: inline-block; 82 | font-weight: bold; 83 | } 84 | 85 | label span { 86 | display: inline-block; 87 | width: 64px; 88 | } 89 | 90 | #error-msg { 91 | color: #800 92 | } 93 | 94 | button { 95 | display: inline-block; 96 | border: none; 97 | color: #006; 98 | background: none; 99 | border-radius: 2px; 100 | margin-left: 6px; 101 | padding: 4px 8px; 102 | } 103 | 104 | button:focus { 105 | outline: 0; 106 | } 107 | 108 | button:hover { 109 | background: rgba(0,0,0,0.12); 110 | } 111 | 112 | .list-item { 113 | padding: 8px 0; 114 | } 115 | 116 | .card { 117 | background: #fff; 118 | padding: 1em 1em; 119 | box-sizing: border-box; 120 | } 121 | 122 | .action { 123 | float: right 124 | } 125 | 126 | .editing > .card { 127 | background: #eaeaff; 128 | } 129 | 130 | .edit { 131 | display: none; 132 | } 133 | 134 | .editing .edit { 135 | display: inherit; 136 | } 137 | 138 | .editing .view { 139 | display: none; 140 | } 141 | 142 | .card h3 { 143 | margin: 0 0 8px 0; 144 | } 145 | 146 | .card .author { 147 | font-style: italic; 148 | } 149 | 150 | .edit-input { 151 | display: inline-block; 152 | } 153 | 154 | .edit-input label { 155 | display: block; 156 | } 157 | 158 | .edit-input label + label { 159 | margin-top: 6px; 160 | } 161 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | /book-collection 3 | /db 4 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/README.md: -------------------------------------------------------------------------------- 1 | # Book Collection Store Example 2 | 3 | **Tags:** *Models*, *Collections*, *Linked resources*, *Call methods*, *Resource parameters*, *Store* 4 | 5 | ## Description 6 | This is the Book Collection example where all changes are persisted using a badgerDB store. 7 | 8 | ## Prerequisite 9 | 10 | * [Download](https://golang.org/dl/) and install Go 11 | * [Install](https://resgate.io/docs/get-started/installation/) *NATS Server* and *Resgate* (done with 3 docker commands). 12 | 13 | ## Install and run 14 | 15 | ```text 16 | git clone https://github.com/jirenius/go-res 17 | cd go-res/examples/04-book-collection-store 18 | go run . 19 | ``` 20 | 21 | Open the client 22 | ```text 23 | http://localhost:8084 24 | ``` 25 | 26 | ## Things to try out 27 | 28 | ### BadgerDB persistence 29 | Run the client and make changes to the list of books. Restart the service and observe that all changes are persisted. 30 | 31 | ### Title sorting 32 | The store sorts books by title. Change the title of one of the books. Observe how the list remains sorted by title. 33 | 34 | ## API 35 | 36 | Request | Resource | Description 37 | --- | --- | --- 38 | *get* | `library.books` | Collection of book model references. 39 | *call* | `library.books.new` | Creates a new book. 40 | *get* | `library.book.` | Models representing books. 41 | *call* | `library.book..set` | Sets the books' *title* and *author* properties. 42 | *call* | `library.book..delete` | Deletes a book. 43 | 44 | ## REST API 45 | 46 | Resources can be retrieved using ordinary HTTP GET requests, and methods can be called using HTTP POST requests. 47 | 48 | ### Get book collection 49 | ``` 50 | GET http://localhost:8080/api/library/books 51 | ``` 52 | 53 | ### Get book 54 | ``` 55 | GET http://localhost:8080/api/library/book/ 56 | ``` 57 | 58 | ### Update book properties 59 | ``` 60 | POST http://localhost:8080/api/library/book//set 61 | ``` 62 | *Body* 63 | ``` 64 | { "title": "Animal Farming" } 65 | ``` 66 | 67 | ### Add new book 68 | ``` 69 | POST http://localhost:8080/api/library/books/new 70 | ``` 71 | *Body* 72 | ``` 73 | { "title": "Dracula", "author": "Bram Stoker" } 74 | ``` 75 | 76 | ### Delete book 77 | ``` 78 | POST http://localhost:8080/api/library/books/delete 79 | ``` 80 | *Body* 81 | ``` 82 | { "id": } 83 | ``` 84 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/bookhandler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/jirenius/go-res" 7 | "github.com/jirenius/go-res/store" 8 | ) 9 | 10 | // BookHandler is a handler for book requests. 11 | type BookHandler struct { 12 | BookStore *BookStore 13 | } 14 | 15 | // SetOption sets the res.Handler options. 16 | func (h *BookHandler) SetOption(rh *res.Handler) { 17 | rh.Option( 18 | // Handler handels models 19 | res.Model, 20 | // Store handler that handles get requests and change events. 21 | store.Handler{Store: h.BookStore, Transformer: h}, 22 | // Set call method handler, for updating the book's fields. 23 | res.Call("set", h.set), 24 | ) 25 | } 26 | 27 | // RIDToID transforms an external resource ID to a book ID used by the store. 28 | // 29 | // Since id is equal is to the value of the $id tag in the resource name, we can 30 | // just take it from pathParams. 31 | func (h *BookHandler) RIDToID(rid string, pathParams map[string]string) string { 32 | return pathParams["id"] 33 | } 34 | 35 | // IDToRID transforms a book ID used by the store to an external resource ID. 36 | // 37 | // The pattern, p, is the full pattern registered to the service (eg. 38 | // "library.book.$id") for this resource. 39 | func (h *BookHandler) IDToRID(id string, v interface{}, p res.Pattern) string { 40 | return string(p.ReplaceTag("id", id)) 41 | } 42 | 43 | // Transform allows us to transform the stored book model before sending it off 44 | // to external clients. In this example, we do no transformation. 45 | func (h *BookHandler) Transform(id string, v interface{}) (interface{}, error) { 46 | // // We could convert the book to a type with a different JSON marshaler, 47 | // // or perhaps return a res.ErrNotFound if a deleted flag is set. 48 | // return BookWithDifferentJSONMarshaler(v.(Book)), nil 49 | return v, nil 50 | } 51 | 52 | // set handles set call requests on a book. 53 | func (h *BookHandler) set(r res.CallRequest) { 54 | // Create a store write transaction. 55 | txn := h.BookStore.Write(r.PathParam("id")) 56 | defer txn.Close() 57 | 58 | // Get book value from store 59 | v, err := txn.Value() 60 | if err != nil { 61 | r.Error(err) 62 | return 63 | } 64 | book := v.(Book) 65 | 66 | // Unmarshal parameters to an anonymous struct 67 | var p struct { 68 | Title *string `json:"title,omitempty"` 69 | Author *string `json:"author,omitempty"` 70 | } 71 | r.ParseParams(&p) 72 | 73 | // Validate title param 74 | if p.Title != nil { 75 | *p.Title = strings.TrimSpace(*p.Title) 76 | if *p.Title == "" { 77 | r.InvalidParams("Title must not be empty") 78 | return 79 | } 80 | book.Title = *p.Title 81 | } 82 | 83 | // Validate author param 84 | if p.Author != nil { 85 | *p.Author = strings.TrimSpace(*p.Author) 86 | if *p.Author == "" { 87 | r.InvalidParams("Author must not be empty") 88 | return 89 | } 90 | book.Author = *p.Author 91 | } 92 | 93 | // Update book in store. 94 | // This will produce a change event, if any fields were updated. 95 | // It might also produce events for the books collection, if the change 96 | // affects the sort order. 97 | err = txn.Update(book) 98 | if err != nil { 99 | r.Error(err) 100 | return 101 | } 102 | 103 | // Send success response 104 | r.OK(nil) 105 | } 106 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/bookshandler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/jirenius/go-res" 7 | "github.com/jirenius/go-res/store" 8 | "github.com/rs/xid" 9 | ) 10 | 11 | // BooksHandler is a handler for book collection requests. 12 | type BooksHandler struct { 13 | BookStore *BookStore 14 | } 15 | 16 | // SetOption sets the res.Handler options. 17 | func (h *BooksHandler) SetOption(rh *res.Handler) { 18 | rh.Option( 19 | // Handler handels a collection 20 | res.Collection, 21 | // QueryStore handler that handles get requests and change events. 22 | store.QueryHandler{ 23 | QueryStore: h.BookStore.BooksByTitle, 24 | // The transformer transforms the QueryStore's resulting collection 25 | // of id strings, []string{"1","2"}, into a collection of resource 26 | // references, []res.Ref{"library.book.1","library.book.2"}. 27 | Transformer: store.IDToRIDCollectionTransformer(func(id string) string { 28 | return "library.book." + id 29 | }), 30 | }, 31 | // New call method handler, for creating new books. 32 | res.Call("new", h.newBook), 33 | // Delete call method handler, for deleting books. 34 | res.Call("delete", h.deleteBook), 35 | ) 36 | } 37 | 38 | // newBook handles new call requests on the book collection. 39 | func (h *BooksHandler) newBook(r res.CallRequest) { 40 | // Parse request parameters into a book model 41 | var book Book 42 | r.ParseParams(&book) 43 | 44 | // Trim whitespace 45 | book.Title = strings.TrimSpace(book.Title) 46 | book.Author = strings.TrimSpace(book.Author) 47 | 48 | // Check if we received both title and author 49 | if book.Title == "" || book.Author == "" { 50 | r.InvalidParams("Must provide both title and author") 51 | return 52 | } 53 | 54 | // Create a new ID for the book 55 | book.ID = xid.New().String() 56 | 57 | // Create a store write transaction 58 | txn := h.BookStore.Write(book.ID) 59 | defer txn.Close() 60 | 61 | // Add the book to the store. 62 | // This will produce an add event for the books collection. 63 | if err := txn.Create(book); err != nil { 64 | r.Error(err) 65 | return 66 | } 67 | 68 | // Return a resource reference to a new book 69 | r.Resource("library.book." + book.ID) 70 | } 71 | 72 | // deleteBook handles delete call requests on the book collection. 73 | func (h *BooksHandler) deleteBook(r res.CallRequest) { 74 | // Unmarshal parameters to an anonymous struct 75 | var p struct { 76 | ID string `json:"id,omitempty"` 77 | } 78 | r.ParseParams(&p) 79 | 80 | // Create a store write transaction 81 | txn := h.BookStore.Write(p.ID) 82 | defer txn.Close() 83 | 84 | // Delete the book from the store. 85 | // This will produce a remove event for the books collection. 86 | if err := txn.Delete(); err != nil { 87 | r.Error(err) 88 | return 89 | } 90 | 91 | // Send success response. 92 | r.OK(nil) 93 | } 94 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/bookstore.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | 7 | "github.com/dgraph-io/badger" 8 | "github.com/jirenius/go-res/store/badgerstore" 9 | "github.com/rs/xid" 10 | ) 11 | 12 | // BookStore contains the store and query stores for the books. BadgerDB is used 13 | // for storage, but any other database can be used. What is needed is a wrapper 14 | // that implements the Store and QueryStore interfaces found in package: 15 | // 16 | // github.com/jirenius/go-res/store 17 | type BookStore struct { 18 | *badgerstore.Store 19 | BooksByTitle *badgerstore.QueryStore 20 | } 21 | 22 | // A badgerstore db index by book title (lower case). 23 | var idxBookTitle = badgerstore.Index{ 24 | Name: "idxBook_title", 25 | Key: func(v interface{}) []byte { 26 | book := v.(Book) 27 | return []byte(strings.ToLower(book.Title)) 28 | }, 29 | } 30 | 31 | // NewBookStore creates a new BookStore. 32 | func NewBookStore(db *badger.DB) *BookStore { 33 | st := badgerstore.NewStore(db). 34 | SetType(Book{}). 35 | SetPrefix("book") 36 | return &BookStore{ 37 | Store: st, 38 | BooksByTitle: badgerstore.NewQueryStore(st, booksByTitleIndexQuery). 39 | AddIndex(idxBookTitle), 40 | } 41 | } 42 | 43 | // booksByTitleIndexQuery handles query requests. This method is badgerstore 44 | // specific, and allows for simple index based queries towards the badgerDB 45 | // store. 46 | // 47 | // Other database implementations for store.QueryStore would do it differently. 48 | // A sql implementation might have you generate a proper WHERE statement, where 49 | // as a mongoDB implementation would need a bson query document. 50 | func booksByTitleIndexQuery(qs *badgerstore.QueryStore, q url.Values) (*badgerstore.IndexQuery, error) { 51 | // All query parameters are ignored. Just query all books without limit. 52 | return &badgerstore.IndexQuery{ 53 | Index: idxBookTitle, 54 | Limit: -1, 55 | }, nil 56 | } 57 | 58 | // Init seeds an empty store with some initial books. It panics on errors. 59 | func (st *BookStore) Init() { 60 | if err := st.Store.Init(func(add func(id string, v interface{})) error { 61 | for _, book := range []Book{ 62 | {Title: "Animal Farm", Author: "George Orwell"}, 63 | {Title: "Brave New World", Author: "Aldous Huxley"}, 64 | {Title: "Coraline", Author: "Neil Gaiman"}, 65 | } { 66 | book.ID = xid.New().String() 67 | add(book.ID, book) 68 | } 69 | return nil 70 | }); err != nil { 71 | panic(err) 72 | } 73 | // Wait for the badgerDB index to be created 74 | st.BooksByTitle.Flush() 75 | } 76 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | This is the Book Collection example where all changes are persisted using a 3 | badgerDB store. 4 | 5 | * It exposes a collection, `library.books`, containing book model references. 6 | * It exposes book models, `library.book.`, of each book. 7 | * The books are persisted in a badgerDB store under `./db`. 8 | * The store.Handler handles get requests by loading resources from the store. 9 | * The store.QueryHandler handles get requests by getting a list of stored books. 10 | * Changed made to the store bubbles up as events. 11 | * It serves a web client at http://localhost:8084 12 | */ 13 | package main 14 | 15 | import ( 16 | "fmt" 17 | "log" 18 | "net/http" 19 | 20 | "github.com/dgraph-io/badger" 21 | 22 | "github.com/jirenius/go-res" 23 | ) 24 | 25 | // Book represents a book model. 26 | type Book struct { 27 | ID string `json:"id"` 28 | Title string `json:"title"` 29 | Author string `json:"author"` 30 | } 31 | 32 | func main() { 33 | // Create badger DB 34 | db, err := badger.Open(badger.DefaultOptions("./db").WithTruncate(true)) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | defer db.Close() 39 | 40 | // Create badgerDB store for books 41 | bookStore := NewBookStore(db) 42 | // Create some initial books, if not done before 43 | bookStore.Init() 44 | 45 | // Create a new RES Service 46 | s := res.NewService("library") 47 | 48 | // Add handler for "library.book.$id" models 49 | s.Handle("book.$id", 50 | &BookHandler{BookStore: bookStore}, 51 | res.Access(res.AccessGranted)) 52 | // Add handler for "library.books" collection 53 | s.Handle("books", 54 | &BooksHandler{BookStore: bookStore}, 55 | res.Access(res.AccessGranted)) 56 | 57 | // Run a simple webserver to serve the client. 58 | // This is only for the purpose of making the example easier to run. 59 | go func() { log.Fatal(http.ListenAndServe(":8084", http.FileServer(http.Dir("wwwroot/")))) }() 60 | fmt.Println("Client at: http://localhost:8084/") 61 | 62 | s.ListenAndServe("nats://localhost:4222") 63 | } 64 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/wwwroot/app.js: -------------------------------------------------------------------------------- 1 | // This example uses modapp components to render the view. 2 | // Read more about it here: https://resgate.io/docs/writing-clients/using-modapp/ 3 | const { Elem, Txt, Button, Input } = window["modapp-base-component"]; 4 | const { CollectionList, ModelTxt } = window["modapp-resource-component"]; 5 | const ResClient = resclient.default; 6 | 7 | // Creating the client instance. 8 | let client = new ResClient('ws://localhost:8080'); 9 | 10 | // Error handling 11 | let errMsg = new Txt(); 12 | let errTimer = null; 13 | errMsg.render(document.getElementById('error-msg')); 14 | let showError = (err) => { 15 | errMsg.setText(err && err.message ? err.message : String(err)); 16 | clearTimeout(errTimer); 17 | errTimer = setTimeout(() => errMsg.setText(''), 7000); 18 | }; 19 | 20 | // Add new click callback 21 | document.getElementById('add-new').addEventListener('click', () => { 22 | let newTitle = document.getElementById('new-title'); 23 | let newAuthor = document.getElementById('new-author'); 24 | client.call('library.books', 'new', { 25 | title: newTitle.value, 26 | author: newAuthor.value 27 | }).then(() => { 28 | // Clear values on successful add 29 | newTitle.value = ""; 30 | newAuthor.value = ""; 31 | }).catch(showError); 32 | }); 33 | 34 | // Get the collection from the service. 35 | client.get('library.books').then(books => { 36 | // Render the collection of books 37 | new Elem(n => 38 | n.component(new CollectionList(books, book => { 39 | let c = new Elem(n => 40 | n.elem('div', { className: 'list-item' }, [ 41 | n.elem('div', { className: 'card shadow' }, [ 42 | // View card mode 43 | n.elem('div', { className: 'view' }, [ 44 | n.elem('div', { className: 'action' }, [ 45 | n.component(new Button(`Edit`, () => { 46 | c.getNode('titleInput').setValue(book.title); 47 | c.getNode('authorInput').setValue(book.author); 48 | c.addClass('editing'); 49 | })), 50 | n.component(new Button(`Delete`, () => books.call('delete', { id: book.id }).catch(showError))) 51 | ]), 52 | n.elem('div', { className: 'title' }, [ 53 | n.component(new ModelTxt(book, book => book.title, { tagName: 'h3' })) 54 | ]), 55 | n.elem('div', { className: 'author' }, [ 56 | n.component(new Txt("By ")), 57 | n.component(new ModelTxt(book, book => book.author)) 58 | ]) 59 | ]), 60 | // Edit card mode 61 | n.elem('div', { className: 'edit' }, [ 62 | n.elem('div', { className: 'action' }, [ 63 | n.component(new Button(`OK`, () => { 64 | book.set({ 65 | title: c.getNode('titleInput').getValue(), 66 | author: c.getNode('authorInput').getValue() 67 | }) 68 | .then(() => c.removeClass('editing')) 69 | .catch(showError); 70 | })), 71 | n.component(new Button(`Cancel`, () => c.removeClass('editing'))) 72 | ]), 73 | n.elem('div', { className: 'edit-input' }, [ 74 | n.elem('label', [ 75 | n.component(new Txt("Title", { className: 'span' })), 76 | n.component('titleInput', new Input()) 77 | ]), 78 | n.elem('label', [ 79 | n.component(new Txt("Author", { className: 'span' })), 80 | n.component('authorInput', new Input()) 81 | ]) 82 | ]) 83 | ]) 84 | ]) 85 | ]) 86 | ); 87 | return c; 88 | }, { className: 'list' })) 89 | ).render(document.getElementById('books')); 90 | }).catch(err => showError(err.code === 'system.connectionError' 91 | ? "Connection error. Are NATS Server and Resgate running?" 92 | : err 93 | )); 94 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/wwwroot/index.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | resgate.io | book collection persisted example 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |   |  book collection store example 17 |
18 |
19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 |
27 |
28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /examples/04-book-collection-store/wwwroot/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #eee; 3 | font-family: 'Open Sans', sans-serif; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | /* Header */ 9 | .header { 10 | background: #161925; 11 | color: #91aec1; 12 | padding: 14px 1em; 13 | box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 14 | } 15 | 16 | a.logo:link, a.logo:visited { 17 | text-decoration: inherit; 18 | color: inherit; 19 | } 20 | 21 | .logo, .header-title { 22 | line-height: 34px; 23 | font-size: 24px; 24 | } 25 | 26 | .logo-img { 27 | vertical-align: middle; 28 | margin-right: 14px; 29 | } 30 | 31 | .logo-res { 32 | font-weight: 700; 33 | color: #91aec1; 34 | } 35 | 36 | .logo-gate { 37 | color: #5a5d93; 38 | } 39 | 40 | h1, h2, h3 { 41 | font-family: 'Encode Sans', sans-serif 42 | } 43 | 44 | h1 { 45 | margin: 0; 46 | padding: 0; 47 | line-height: 28px; 48 | font-size: 24px; 49 | font-weight: 700; 50 | } 51 | 52 | h3 { 53 | font-weight: 700; 54 | font-size: 1.2em; 55 | color: #5a5d93; 56 | } 57 | 58 | .top { 59 | margin: 1em 1em; 60 | } 61 | 62 | .shadow { 63 | box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 64 | } 65 | 66 | #books { 67 | max-width: 800px; 68 | margin: 0 1em; 69 | } 70 | 71 | .new-container { 72 | margin: 1em 0; 73 | line-height: 2em; 74 | } 75 | 76 | .new-container label { 77 | margin-right: 10px; 78 | } 79 | 80 | label { 81 | display: inline-block; 82 | font-weight: bold; 83 | } 84 | 85 | label span { 86 | display: inline-block; 87 | width: 64px; 88 | } 89 | 90 | #error-msg { 91 | color: #800 92 | } 93 | 94 | button { 95 | display: inline-block; 96 | border: none; 97 | color: #006; 98 | background: none; 99 | border-radius: 2px; 100 | margin-left: 6px; 101 | padding: 4px 8px; 102 | } 103 | 104 | button:focus { 105 | outline: 0; 106 | } 107 | 108 | button:hover { 109 | background: rgba(0,0,0,0.12); 110 | } 111 | 112 | .list-item { 113 | padding: 8px 0; 114 | } 115 | 116 | .card { 117 | background: #fff; 118 | padding: 1em 1em; 119 | box-sizing: border-box; 120 | } 121 | 122 | .action { 123 | float: right 124 | } 125 | 126 | .editing > .card { 127 | background: #eaeaff; 128 | } 129 | 130 | .edit { 131 | display: none; 132 | } 133 | 134 | .editing .edit { 135 | display: inherit; 136 | } 137 | 138 | .editing .view { 139 | display: none; 140 | } 141 | 142 | .card h3 { 143 | margin: 0 0 8px 0; 144 | } 145 | 146 | .card .author { 147 | font-style: italic; 148 | } 149 | 150 | .edit-input { 151 | display: inline-block; 152 | } 153 | 154 | .edit-input label { 155 | display: block; 156 | } 157 | 158 | .edit-input label + label { 159 | margin-top: 6px; 160 | } 161 | -------------------------------------------------------------------------------- /examples/05-search-query/.gitignore: -------------------------------------------------------------------------------- 1 | db -------------------------------------------------------------------------------- /examples/05-search-query/README.md: -------------------------------------------------------------------------------- 1 | # Search Query Example 2 | 3 | **Tags:** *Models*, *Collections*, *Linked resources*, *Queries*, *Pagination*, *Store*, *Call methods*, *Resource parameters* 4 | 5 | ## Description 6 | A customer management system, where you can search and filter customers by name and country. The search results are live and updated as customers are edited, added, or deleted by multiple users simultaneously. 7 | 8 | ## Prerequisite 9 | 10 | * [Download](https://golang.org/dl/) and install Go 11 | * [Install](https://resgate.io/docs/get-started/installation/) *NATS Server* and *Resgate* (done with 3 docker commands). 12 | 13 | ## Install and run 14 | 15 | ```text 16 | git clone https://github.com/jirenius/go-res 17 | cd go-res/examples/05-search-query 18 | go run . 19 | ``` 20 | 21 | Open the client 22 | ```text 23 | http://localhost:8085 24 | ``` 25 | 26 | ## Things to try out 27 | 28 | ### Live query 29 | * Open the client in two separate tabs. 30 | * Make a query (eg. Set *Filter* to `B`, and *Country* to `Germany`) in one tab. 31 | * In a separate tab, try to: 32 | * create a *New customer* matching the query. 33 | * edit a customer so that it starts to match the query. 34 | * edit a customer so that it no longer matches the query. 35 | * delete a customer that matches the query. 36 | * In the tab with the query, try to: 37 | * edit the name of a customer so that it affects its sort order. 38 | * edit the country of a customer so that it no longer matches the query. 39 | 40 | ### Persistence 41 | * Open the client and make some changes. 42 | * Restart the service and the client. 43 | * Observe that all changes are persisted (using Badger DB). 44 | 45 | ## API 46 | 47 | Request | Resource | Description 48 | --- | --- | --- 49 | *get* | `search.customers?from=0&limit=5&name=A&country=Sweden` | Query collection of customer references. All query parameters are optional. 50 | *call* | `search.customers.newCustomer` | Adds a new customer. 51 | *get* | `search.customer.` | Models representing customers. 52 | *call* | `search.customer..set` | Sets the customers' *name*, *email*, and *country* properties. 53 | *call* | `search.customer..delete` | Deletes a customer. 54 | *get* | `search.countries` | Collection of available countries. 55 | 56 | ## REST API 57 | 58 | Resources can be retrieved using ordinary HTTP GET requests, and methods can be called using HTTP POST requests. 59 | 60 | ### Get customer query collection (all parameters are optional) 61 | ``` 62 | GET http://localhost:8080/api/search/customers?from=0&limit=5&name=A&country=Sweden 63 | ``` 64 | 65 | ### Add new customer 66 | ``` 67 | POST http://localhost:8080/api/search/customers/newCustomer 68 | ``` 69 | *Body* 70 | ``` 71 | { "name": "John Doe", "email": "john.doe@example.com", "country": "France" } 72 | ``` 73 | 74 | ### Get customer 75 | ``` 76 | GET http://localhost:8080/api/search/customer/ 77 | ``` 78 | 79 | ### Update customer properties 80 | ``` 81 | POST http://localhost:8080/api/search/customer//set 82 | ``` 83 | *Body* 84 | ``` 85 | { "name": "Jane Doe", "country": "United Kingdom" } 86 | ``` 87 | 88 | ### Delete customer 89 | ``` 90 | POST http://localhost:8080/api/search/customer//delete 91 | ``` 92 | *No body* 93 | 94 | ### Get country collection 95 | ``` 96 | GET http://localhost:8080/api/search/countries 97 | ``` -------------------------------------------------------------------------------- /examples/05-search-query/countries.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/jirenius/go-res" 7 | ) 8 | 9 | // Countries is a sorted list of country names. 10 | var Countries = sort.StringSlice{ 11 | "France", 12 | "Germany", 13 | "Sweden", 14 | "United Kingdom", 15 | } 16 | 17 | // CountriesContains searches for a country and returns true if it is found in 18 | // Countries. 19 | func CountriesContains(country string) bool { 20 | i := sort.SearchStrings(Countries, country) 21 | return i != len(Countries) && Countries[i] == country 22 | } 23 | 24 | // CountriesHandler is a handler that serves the static Countries collection. 25 | var CountriesHandler = res.OptionFunc(func(rh *res.Handler) { 26 | rh.Get = func(r res.GetRequest) { 27 | r.Collection(Countries) 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /examples/05-search-query/customer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/mail" 5 | "strings" 6 | 7 | "github.com/jirenius/go-res" 8 | ) 9 | 10 | // Customer represents a customer model. 11 | type Customer struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | Email string `json:"email"` 15 | Country string `json:"country"` 16 | } 17 | 18 | // TrimAndValidate trims the customer properties from whitespace and validates 19 | // the values. If an error is encountered, a res.CodeInvalidParams error is 20 | // returned. 21 | func (c *Customer) TrimAndValidate() error { 22 | // Trim and validate name 23 | c.Name = strings.TrimSpace(c.Name) 24 | if c.Name == "" { 25 | return &res.Error{Code: res.CodeInvalidParams, Message: "Name must not be empty."} 26 | } 27 | // Trim and validate email 28 | c.Email = strings.TrimSpace(c.Email) 29 | if c.Email != "" { 30 | if _, err := mail.ParseAddress(c.Email); err != nil { 31 | return &res.Error{Code: res.CodeInvalidParams, Message: "Invalid email address."} 32 | } 33 | } 34 | // Trim and validate country 35 | c.Country = strings.TrimSpace(c.Country) 36 | if c.Country != "" && !CountriesContains(c.Country) { 37 | return &res.Error{Code: res.CodeInvalidParams, Message: "Country must be empty or one of the following: " + strings.Join(Countries, ", ") + "."} 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /examples/05-search-query/customerhandler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jirenius/go-res" 5 | "github.com/jirenius/go-res/store" 6 | ) 7 | 8 | // CustomerHandler is a handler for customer requests. 9 | type CustomerHandler struct { 10 | CustomerStore *CustomerStore 11 | } 12 | 13 | // SetOption sets the res.Handler options. 14 | func (h *CustomerHandler) SetOption(rh *res.Handler) { 15 | rh.Option( 16 | // Handler handels models 17 | res.Model, 18 | // Store handler that handles get requests and change events. 19 | store.Handler{Store: h.CustomerStore, Transformer: h}, 20 | // Set call method handler, for updating the customer's fields. 21 | res.Call("set", h.setCustomer), 22 | // Delete call method handler, for deleting customers. 23 | res.Call("delete", h.deleteCustomer), 24 | ) 25 | } 26 | 27 | // RIDToID transforms an external resource ID to a customer ID used by the store. 28 | // 29 | // Since id is equal is to the value of the $id tag in the resource name, we can 30 | // just take it from pathParams. 31 | func (h *CustomerHandler) RIDToID(rid string, pathParams map[string]string) string { 32 | return pathParams["id"] 33 | } 34 | 35 | // IDToRID transforms a customer ID used by the store to an external resource 36 | // ID. 37 | // 38 | // The pattern, p, is the full pattern registered to the service (eg. 39 | // "search.customer.$id") for this resource. 40 | func (h *CustomerHandler) IDToRID(id string, v interface{}, p res.Pattern) string { 41 | return string(p.ReplaceTag("id", id)) 42 | } 43 | 44 | // Transform allows us to transform the stored customer model before sending it 45 | // off to external clients. In this example, we do no transformation. 46 | func (h *CustomerHandler) Transform(id string, v interface{}) (interface{}, error) { 47 | // // We could convert the customer to a type with a different JSON marshaler, 48 | // // or perhaps return a res.ErrNotFound if a deleted flag is set. 49 | // return CustomerWithDifferentJSONMarshaler(v.(Customer)), nil 50 | return v, nil 51 | } 52 | 53 | // setCustomer handles call requests to edit customer properties. 54 | func (h *CustomerHandler) setCustomer(r res.CallRequest) { 55 | // Create a store write transaction. 56 | txn := h.CustomerStore.Write(r.PathParam("id")) 57 | defer txn.Close() 58 | 59 | // Get customer value from store 60 | v, err := txn.Value() 61 | if err != nil { 62 | r.Error(err) 63 | return 64 | } 65 | customer := v.(Customer) 66 | 67 | // Parse parameters 68 | var p struct { 69 | Name *string `json:"name"` 70 | Email *string `json:"email"` 71 | Country *string `json:"country"` 72 | } 73 | r.ParseParams(&p) 74 | 75 | // Set the provided fields 76 | if p.Name != nil { 77 | customer.Name = *p.Name 78 | } 79 | if p.Email != nil { 80 | customer.Email = *p.Email 81 | } 82 | if p.Country != nil { 83 | customer.Country = *p.Country 84 | } 85 | 86 | // Trim and validate fields 87 | err = customer.TrimAndValidate() 88 | if err != nil { 89 | r.Error(err) 90 | return 91 | } 92 | 93 | // Update customer in store. 94 | // This will produce a change event and a customers query collection event, 95 | // if any indexed fields were updated. 96 | err = txn.Update(customer) 97 | if err != nil { 98 | r.Error(err) 99 | return 100 | } 101 | 102 | // Send success response 103 | r.OK(nil) 104 | } 105 | 106 | // deleteCustomer handles call requests to delete customers. 107 | func (h *CustomerHandler) deleteCustomer(r res.CallRequest) { 108 | // Create a store write transaction 109 | txn := h.CustomerStore.Write(r.PathParam("id")) 110 | defer txn.Close() 111 | 112 | // Delete the customer from the store. 113 | // This will produce a query event for the customers query collection. 114 | if err := txn.Delete(); err != nil { 115 | r.Error(err) 116 | return 117 | } 118 | 119 | // Send success response. 120 | r.OK(nil) 121 | } 122 | -------------------------------------------------------------------------------- /examples/05-search-query/customerqueryhandler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | 8 | "github.com/jirenius/go-res" 9 | "github.com/jirenius/go-res/store" 10 | "github.com/rs/xid" 11 | ) 12 | 13 | // CustomerQueryHandler is a handler for customer query collection requests. 14 | type CustomerQueryHandler struct { 15 | CustomerStore *CustomerStore 16 | } 17 | 18 | // SetOption sets the handler methods to the res.Handler object 19 | func (h *CustomerQueryHandler) SetOption(rh *res.Handler) { 20 | rh.Option( 21 | // Handler handels a collection 22 | res.Collection, 23 | // QueryStore handler that handles get requests and change events. 24 | store.QueryHandler{ 25 | QueryStore: h.CustomerStore.CustomersQuery, 26 | QueryRequestHandler: h.queryRequestHandler, 27 | // The transformer transforms the QueryStore's resulting collection 28 | // of id strings, []string{"a","b"}, into a collection of resource 29 | // references, []res.Ref{"search.customer.a","search.customer.b"}. 30 | Transformer: store.IDToRIDCollectionTransformer(func(id string) string { 31 | return "search.customer." + id 32 | }), 33 | }, 34 | // NewCustomer call method handler, for creating new customers. 35 | res.Call("newCustomer", h.newCustomer), 36 | ) 37 | } 38 | 39 | // newCustomer handles call requests to create new customers. 40 | func (h *CustomerQueryHandler) newCustomer(r res.CallRequest) { 41 | // Parse request parameters into a customer model 42 | var customer Customer 43 | r.ParseParams(&customer) 44 | 45 | // Trim and validate parameters 46 | if err := customer.TrimAndValidate(); err != nil { 47 | r.Error(err) 48 | return 49 | } 50 | 51 | // Create a new ID for the customer 52 | customer.ID = xid.New().String() 53 | 54 | // Create a store write transaction 55 | txn := h.CustomerStore.Write(customer.ID) 56 | defer txn.Close() 57 | 58 | // Add the customer to the store. 59 | // This will produce a query event for the customers query collection. 60 | if err := txn.Create(customer); err != nil { 61 | r.Error(err) 62 | return 63 | } 64 | 65 | // Return a resource reference to a new customer 66 | r.Resource("search.customer." + customer.ID) 67 | } 68 | 69 | // queryRequestHandler takes an incoming request and returns url.Values that 70 | // can be passed to the customer QueryStore. 71 | func (h *CustomerQueryHandler) queryRequestHandler(rname string, pathParams map[string]string, q url.Values) (url.Values, string, error) { 72 | // Parse the query string 73 | name, country, from, limit, err := h.CustomerStore.ParseQuery(q) 74 | if err != nil { 75 | return nil, "", err 76 | } 77 | 78 | // Create query for the customers query store. 79 | cq := url.Values{ 80 | "name": {name}, 81 | "country": {country}, 82 | "from": {strconv.Itoa(from)}, 83 | "limit": {strconv.Itoa(limit)}, 84 | } 85 | 86 | // Create a normalized query string with the properties in a set order. This 87 | // is used by Resgate to tell if two different looking query strings are 88 | // essentially the same. 89 | normalizedQuery := fmt.Sprintf("name=%s&country=%s&from=%d&limit=%d", url.QueryEscape(name), url.QueryEscape(country), from, limit) 90 | 91 | return cq, normalizedQuery, nil 92 | } 93 | -------------------------------------------------------------------------------- /examples/05-search-query/customerstore.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/dgraph-io/badger" 11 | "github.com/jirenius/go-res" 12 | "github.com/jirenius/go-res/store/badgerstore" 13 | "github.com/rs/xid" 14 | ) 15 | 16 | // CustomerStore holds all the customers and provides a way to make queries for 17 | // customers matching certain filters. 18 | // 19 | // It implements the store.QueryStore interface which allows simple read/write 20 | // functionality based on an ID string. 21 | // 22 | // CustomerStore uses BadgerDB, a key/value store, where each customer model is 23 | // stored as a single value. Other databases could be used as well: a SQL table 24 | // where each row is a customer model, or a mongoDB collection where each 25 | // customer is a document. What is needed is a wrapper that implements the Store 26 | // and QueryStore interfaces found in package: 27 | // 28 | // github.com/jirenius/go-res/store 29 | type CustomerStore struct { 30 | *badgerstore.Store 31 | CustomersQuery *badgerstore.QueryStore 32 | } 33 | 34 | // BadgerDB store indexes. 35 | var ( 36 | // Index on lower case name 37 | idxCustomerName = badgerstore.Index{ 38 | Name: "idxCustomer_name", 39 | Key: func(v interface{}) []byte { 40 | customer := v.(Customer) 41 | return []byte(strings.ToLower(customer.Name)) 42 | }, 43 | } 44 | 45 | // Index on country and lower case name 46 | idxCustomerCountryName = badgerstore.Index{ 47 | Name: "idxCustomer_country_name", 48 | Key: func(v interface{}) []byte { 49 | customer := v.(Customer) 50 | return []byte(customer.Country + "_" + strings.ToLower(customer.Name)) 51 | }, 52 | } 53 | ) 54 | 55 | // NewCustomerStore creates a new CustomerStore. 56 | func NewCustomerStore(db *badger.DB) *CustomerStore { 57 | st := badgerstore.NewStore(db). 58 | SetType(Customer{}). 59 | SetPrefix("customer") 60 | return &CustomerStore{ 61 | Store: st, 62 | CustomersQuery: badgerstore.NewQueryStore(st, customersIndexQuery). 63 | AddIndex(idxCustomerName). 64 | AddIndex(idxCustomerCountryName), 65 | } 66 | } 67 | 68 | // ParseQuery parses and validates a query to pass use with CustomersQuery. 69 | func (st *CustomerStore) ParseQuery(q url.Values) (name string, country string, from int, limit int, err error) { 70 | return parseQuery(q) 71 | } 72 | 73 | // parseQuery validates and returns the values out of the provided url.Values. 74 | // On parse error, parseQuery returns a res.CodeInvalidQuery error. 75 | func parseQuery(q url.Values) (name string, country string, from int, limit int, err error) { 76 | name = strings.ToLower(q.Get("name")) 77 | country = q.Get("country") 78 | from, err = strconv.Atoi(q.Get("from")) 79 | if err != nil { 80 | from = 0 81 | } 82 | limit, err = strconv.Atoi(q.Get("limit")) 83 | if err != nil { 84 | limit = -1 85 | } 86 | if from < 0 { 87 | from = 0 88 | } 89 | if limit < 0 { 90 | limit = 10 91 | } 92 | if limit > 50 { 93 | err = &res.Error{Code: res.CodeInvalidQuery, Message: "Limit must be 50 or less."} 94 | return 95 | } 96 | err = nil 97 | return 98 | } 99 | 100 | // customersIndexQuery handles query requests. This method is badgerstore 101 | // specific, and allows for simple index based queries towards the badgerDB 102 | // store. 103 | // 104 | // Other database implementations for store.QueryStore would do it differently. 105 | // A sql implementation might have you generate a proper WHERE statement, where 106 | // as a mongoDB implementation would need a bson query document. 107 | func customersIndexQuery(qs *badgerstore.QueryStore, q url.Values) (*badgerstore.IndexQuery, error) { 108 | // Parse the query string 109 | name, country, from, limit, err := parseQuery(q) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | // Get the index and prefix for this search 115 | var prefix []byte 116 | var idx badgerstore.Index 117 | switch { 118 | case country != "": 119 | idx = idxCustomerCountryName 120 | prefix = []byte(country + "_" + name) 121 | case name != "": 122 | idx = idxCustomerName 123 | prefix = []byte(name) 124 | default: 125 | idx = idxCustomerName 126 | } 127 | 128 | return &badgerstore.IndexQuery{ 129 | Index: idx, 130 | KeyPrefix: prefix, 131 | Offset: from, 132 | Limit: limit, 133 | }, nil 134 | } 135 | 136 | // Init bootstraps an empty store with customers loaded from a file. It panics 137 | // on errors. 138 | func (st *CustomerStore) Init() { 139 | if err := st.Store.Init(func(add func(id string, v interface{})) error { 140 | dta, err := ioutil.ReadFile("mock_customers.json") 141 | if err != nil { 142 | return err 143 | } 144 | var customers []Customer 145 | if err = json.Unmarshal(dta, &customers); err != nil { 146 | return err 147 | } 148 | for _, customer := range customers { 149 | customer.ID = xid.New().String() 150 | add(customer.ID, customer) 151 | } 152 | return nil 153 | }); err != nil { 154 | panic(err) 155 | } 156 | // Wait for the badgerDB index to be created 157 | st.CustomersQuery.Flush() 158 | } 159 | -------------------------------------------------------------------------------- /examples/05-search-query/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | A customer management system, where you can search and filter customers by name 3 | and country. The search results are live and updated as customers are edited, 4 | added, or deleted by multiple users simultaneously. 5 | */ 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "log" 11 | "net/http" 12 | 13 | "github.com/dgraph-io/badger" 14 | 15 | "github.com/jirenius/go-res" 16 | ) 17 | 18 | func main() { 19 | // Create badger DB 20 | db, err := badger.Open(badger.DefaultOptions("./db").WithTruncate(true)) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | defer db.Close() 25 | 26 | // Create badgerDB store for customers 27 | customerStore := NewCustomerStore(db) 28 | // Seed database with initial customers, if not done before 29 | customerStore.Init() 30 | 31 | // Create a new RES Service 32 | s := res.NewService("search") 33 | 34 | // Add handlers 35 | s.Handle("countries", 36 | CountriesHandler, 37 | res.Access(res.AccessGranted)) 38 | s.Handle("customer.$id", 39 | &CustomerHandler{CustomerStore: customerStore}, 40 | res.Access(res.AccessGranted)) 41 | s.Handle("customers", 42 | &CustomerQueryHandler{CustomerStore: customerStore}, 43 | res.Access(res.AccessGranted)) 44 | 45 | // Run a simple webserver to serve the client. 46 | // This is only for the purpose of making the example easier to run. 47 | go func() { log.Fatal(http.ListenAndServe(":8085", http.FileServer(http.Dir("wwwroot/")))) }() 48 | fmt.Println("Client at: http://localhost:8085/") 49 | 50 | s.ListenAndServe("nats://localhost:4222") 51 | } 52 | -------------------------------------------------------------------------------- /examples/05-search-query/wwwroot/index.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | resgate.io | search example 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |   |  search example 17 |
18 |
19 |
20 | Create a filtered search. The search is live, and the results will be updated in real time. 21 |
22 |
23 |
24 |
25 |
26 |
27 | 28 |
29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /examples/05-search-query/wwwroot/style.css: -------------------------------------------------------------------------------- 1 | /* Global */ 2 | body { 3 | background: #eee; 4 | font-family: 'Open Sans', sans-serif; 5 | padding: 0; 6 | margin: 0; 7 | } 8 | 9 | h1, h2, h3 { 10 | font-family: 'Encode Sans', sans-serif; 11 | } 12 | 13 | h1 { 14 | margin: 0; 15 | padding: 0; 16 | line-height: 28px; 17 | font-size: 24px; 18 | font-weight: 700; 19 | } 20 | 21 | h3 { 22 | font-weight: 700; 23 | font-size: 1.2em; 24 | color: #5a5d93; 25 | margin: 0 0 8px 0; 26 | } 27 | 28 | .shadow { 29 | box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 30 | } 31 | 32 | button { 33 | display: inline-block; 34 | border: none; 35 | color: #006; 36 | background: none; 37 | border-radius: 2px; 38 | margin-left: 6px; 39 | padding: 4px 8px; 40 | } 41 | 42 | button:focus { 43 | outline: 0; 44 | } 45 | 46 | button:hover:enabled { 47 | background: rgba(0,0,0,0.12); 48 | } 49 | 50 | button:disabled { 51 | color: #aaa; 52 | } 53 | 54 | button.primary:enabled { 55 | background: #5a5d93; 56 | color: #fff 57 | } 58 | 59 | button.primary:hover:enabled { 60 | background: #7477ad; 61 | } 62 | 63 | /* Header */ 64 | .header { 65 | background: #161925; 66 | color: #91aec1; 67 | padding: 14px 1em; 68 | box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 69 | } 70 | 71 | a.logo:link, a.logo:visited { 72 | text-decoration: inherit; 73 | color: inherit; 74 | } 75 | 76 | .logo, .header-title { 77 | line-height: 34px; 78 | font-size: 24px; 79 | } 80 | 81 | .logo-img { 82 | vertical-align: middle; 83 | margin-right: 14px; 84 | } 85 | 86 | .logo-res { 87 | font-weight: 700; 88 | color: #91aec1; 89 | } 90 | 91 | .logo-gate { 92 | color: #5a5d93; 93 | } 94 | 95 | .top { 96 | max-width: 800px; 97 | margin: 1em 1em; 98 | } 99 | 100 | .customers { 101 | max-width: 800px; 102 | margin: 0 1em; 103 | } 104 | 105 | .search { 106 | margin: 1em 0; 107 | line-height: 2em; 108 | } 109 | 110 | .search label { 111 | margin-right: 10px; 112 | } 113 | 114 | label { 115 | display: inline-block; 116 | font-weight: bold; 117 | } 118 | 119 | label span { 120 | display: inline-block; 121 | width: 75px; 122 | } 123 | 124 | .error { 125 | color: #800; 126 | } 127 | 128 | .new { 129 | float: right; 130 | } 131 | 132 | /* Card */ 133 | .list-item { 134 | padding: 8px 0; 135 | } 136 | 137 | .card { 138 | background: #fff; 139 | padding: 1em 1em; 140 | box-sizing: border-box; 141 | } 142 | 143 | .action { 144 | float: right 145 | } 146 | 147 | .author { 148 | font-style: italic; 149 | } 150 | 151 | .avatar::before { 152 | content: "👤"; 153 | font-size: 3em; 154 | position: relative; 155 | top: -8px; 156 | float: left; 157 | margin-right: 16px; 158 | } 159 | 160 | .country { 161 | display: inline-block; 162 | width: 158px; 163 | } 164 | 165 | .country::before { 166 | content: "🌍"; 167 | margin-right: 8px; 168 | } 169 | 170 | .email { 171 | display: inline-block; 172 | } 173 | 174 | .email::before { 175 | content: "📧"; 176 | margin-right: 8px; 177 | } 178 | 179 | /* Modal */ 180 | .modal { 181 | position: fixed; 182 | z-index: 1; 183 | left: 0; 184 | top: 0; 185 | width: 100%; 186 | height: 100%; 187 | overflow: auto; 188 | background-color: rgba(0,0,0,0.5); 189 | } 190 | 191 | .modal-content { 192 | background-color: #c4e1f4; 193 | margin: 15% auto; 194 | padding: 16px; 195 | border: 1px solid #888; 196 | width: 80%; 197 | max-width: 480px; 198 | position: relative; 199 | } 200 | 201 | .modal-close { 202 | float: right; 203 | } 204 | 205 | .edit-input { 206 | display: inline-block; 207 | } 208 | 209 | .edit-input label { 210 | display: block; 211 | } 212 | 213 | .edit-input label + label { 214 | margin-top: 6px; 215 | } 216 | 217 | .edit-input input { 218 | width: 240px; 219 | } 220 | 221 | .navigate { 222 | margin-bottom: 10px; 223 | } 224 | 225 | .next { 226 | width: auto; 227 | } 228 | 229 | .modal .action { 230 | position: absolute; 231 | bottom: 16px; 232 | right: 16px; 233 | } 234 | 235 | .modal .error { 236 | display: inline-block; 237 | margin: 8px 48px 0 0; 238 | } 239 | -------------------------------------------------------------------------------- /getrequest.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // getRequest implements the ModelRequest and CollectionRequest interfaces. 10 | // Instead of sending the response to NATS (like Request), getRequest stores 11 | // the reply values in memory. 12 | type getRequest struct { 13 | *resource 14 | replied bool // Flag telling if a reply has been made 15 | value interface{} 16 | err error 17 | } 18 | 19 | func (r *getRequest) Value() (interface{}, error) { 20 | panic("Value() called within get request handler") 21 | } 22 | 23 | func (r *getRequest) RequireValue() interface{} { 24 | panic("RequireValue() called within get request handler") 25 | } 26 | 27 | func (r *getRequest) Model(model interface{}) { 28 | r.reply() 29 | r.value = model 30 | } 31 | 32 | func (r *getRequest) QueryModel(model interface{}, query string) { 33 | r.reply() 34 | r.value = model 35 | } 36 | 37 | func (r *getRequest) Collection(collection interface{}) { 38 | r.reply() 39 | r.value = collection 40 | } 41 | 42 | func (r *getRequest) QueryCollection(collection interface{}, query string) { 43 | r.reply() 44 | r.value = collection 45 | } 46 | 47 | func (r *getRequest) NotFound() { 48 | r.Error(ErrNotFound) 49 | } 50 | 51 | func (r *getRequest) InvalidQuery(message string) { 52 | if message == "" { 53 | r.Error(ErrInvalidQuery) 54 | } else { 55 | r.Error(&Error{Code: CodeInvalidQuery, Message: message}) 56 | } 57 | } 58 | 59 | func (r *getRequest) Error(err error) { 60 | r.reply() 61 | r.err = err 62 | } 63 | 64 | func (r *getRequest) Timeout(d time.Duration) { 65 | // Implement once an internal timeout for requests is implemented 66 | } 67 | 68 | func (r *getRequest) ForValue() bool { 69 | return true 70 | } 71 | 72 | func (r *getRequest) reply() { 73 | if r.replied { 74 | panic("res: response already sent on get request") 75 | } 76 | r.replied = true 77 | } 78 | 79 | func (r *getRequest) executeHandler() { 80 | // Recover from panics inside handlers 81 | defer func() { 82 | v := recover() 83 | if v == nil { 84 | return 85 | } 86 | 87 | var str string 88 | 89 | switch e := v.(type) { 90 | case *Error: 91 | if !r.replied { 92 | r.Error(e) 93 | // Return without logging as panicing with a *Error is considered 94 | // a valid way of sending an error response. 95 | return 96 | } 97 | str = e.Message 98 | case error: 99 | str = e.Error() 100 | if !r.replied { 101 | r.Error(ToError(e)) 102 | } 103 | case string: 104 | str = e 105 | if !r.replied { 106 | r.Error(ToError(errors.New(e))) 107 | } 108 | default: 109 | str = fmt.Sprintf("%v", e) 110 | if !r.replied { 111 | r.Error(ToError(errors.New(str))) 112 | } 113 | } 114 | 115 | r.s.errorf("Error handling get request %#v: %s", r.rname, str) 116 | }() 117 | 118 | h := r.h 119 | if h.Get == nil { 120 | r.Error(ErrNotFound) 121 | return 122 | } 123 | h.Get(r) 124 | 125 | if !r.replied { 126 | r.Error(InternalError(fmt.Errorf("missing response on get request for %#v", r.rname))) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jirenius/go-res 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/dgraph-io/badger v1.6.2 7 | github.com/jirenius/keylock v1.0.0 8 | github.com/jirenius/taskqueue v1.1.0 9 | github.com/jirenius/timerqueue v1.0.0 10 | github.com/nats-io/nats-server/v2 v2.1.8 11 | github.com/nats-io/nats.go v1.10.0 12 | github.com/rs/xid v1.2.1 13 | ) 14 | 15 | require ( 16 | github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect 17 | github.com/cespare/xxhash v1.1.0 // indirect 18 | github.com/dgraph-io/ristretto v0.0.2 // indirect 19 | github.com/dustin/go-humanize v1.0.0 // indirect 20 | github.com/golang/protobuf v1.4.0 // indirect 21 | github.com/nats-io/jwt v0.3.2 // indirect 22 | github.com/nats-io/nkeys v0.1.4 // indirect 23 | github.com/nats-io/nuid v1.0.1 // indirect 24 | github.com/pkg/errors v0.8.1 // indirect 25 | github.com/stretchr/testify v1.6.1 // indirect 26 | golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 // indirect 27 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect 28 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e // indirect 29 | google.golang.org/protobuf v1.22.0 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type group []gpart 9 | 10 | type gpart struct { 11 | str string 12 | idx int 13 | } 14 | 15 | // parseGroup takes a group name and parses it for ${tag} sequences, 16 | // verifying the tags exists as parameter tags in the pattern as well. 17 | // Panics if an error is encountered. 18 | func parseGroup(g, pattern string) group { 19 | if g == "" { 20 | return nil 21 | } 22 | 23 | tokens := splitPattern(pattern) 24 | 25 | var gr group 26 | var c byte 27 | l := len(g) 28 | i := 0 29 | start := 0 30 | 31 | StateDefault: 32 | if i == l { 33 | if i > start { 34 | gr = append(gr, gpart{str: g[start:i]}) 35 | } 36 | return gr 37 | } 38 | if g[i] == '$' { 39 | if i > start { 40 | gr = append(gr, gpart{str: g[start:i]}) 41 | } 42 | i++ 43 | if i == l { 44 | goto UnexpectedEnd 45 | } 46 | if g[i] != '{' { 47 | panic(fmt.Sprintf("expected character \"{\" at pos %d", i)) 48 | } 49 | i++ 50 | start = i 51 | goto StateTag 52 | } 53 | i++ 54 | goto StateDefault 55 | 56 | StateTag: 57 | if i == l { 58 | goto UnexpectedEnd 59 | } 60 | c = g[i] 61 | if c == '}' { 62 | if i == start { 63 | panic(fmt.Sprintf("empty group tag at pos %d", i)) 64 | } 65 | tag := "$" + g[start:i] 66 | for j, t := range tokens { 67 | if t == tag { 68 | gr = append(gr, gpart{idx: j}) 69 | goto TokenFound 70 | } 71 | } 72 | panic(fmt.Sprintf("group tag %s not found in pattern", tag)) 73 | TokenFound: 74 | i++ 75 | start = i 76 | goto StateDefault 77 | } 78 | if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '_' && c != '-' { 79 | panic(fmt.Sprintf("non alpha-numeric (a-z0-9_-) character in group tag at pos %d", i)) 80 | } 81 | i++ 82 | goto StateTag 83 | 84 | UnexpectedEnd: 85 | panic("unexpected end of group tag") 86 | } 87 | 88 | func (g group) toString(rname string, tokens []string) string { 89 | if g == nil { 90 | return rname 91 | } 92 | l := len(g) 93 | if l == 0 { 94 | return "" 95 | } 96 | if l == 1 && g[0].str != "" { 97 | return g[0].str 98 | } 99 | 100 | var b strings.Builder 101 | for _, gp := range g { 102 | if gp.str == "" { 103 | b.WriteString(tokens[gp.idx]) 104 | } else { 105 | b.WriteString(gp.str) 106 | } 107 | } 108 | 109 | return b.String() 110 | } 111 | -------------------------------------------------------------------------------- /group_test.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // Test parseGroup panics when expected 8 | func TestParseGroup(t *testing.T) { 9 | tbl := []struct { 10 | Group string 11 | Pattern string 12 | Panic bool 13 | }{ 14 | // Valid groups 15 | {"", "test", false}, 16 | {"test", "test", false}, 17 | {"test", "test.$foo", false}, 18 | {"test.${foo}", "test.$foo", false}, 19 | {"${foo}", "test.$foo", false}, 20 | {"${foo}.test", "test.$foo", false}, 21 | {"${foo}${bar}", "test.$foo.$bar", false}, 22 | {"${bar}${foo}", "test.$foo.$bar", false}, 23 | {"${foo}.${bar}", "test.$foo.$bar.>", false}, 24 | {"${foo}${foo}", "test.$foo.$bar", false}, 25 | 26 | // Invalid groups 27 | {"$", "test.$foo", true}, 28 | {"${", "test.$foo", true}, 29 | {"${foo", "test.$foo", true}, 30 | {"${}", "test.$foo", true}, 31 | {"${$foo}", "test.$foo", true}, 32 | {"${bar}", "test.$foo", true}, 33 | } 34 | 35 | for _, l := range tbl { 36 | func() { 37 | defer func() { 38 | if r := recover(); r != nil { 39 | if !l.Panic { 40 | t.Errorf("expected parseGroup not to panic, but it did:\n\tpanic : %s\n\tgroup : %s\n\tpattern : %s", r, l.Group, l.Pattern) 41 | } 42 | } else { 43 | if l.Panic { 44 | t.Errorf("expected parseGroup to panic, but it didn't\n\tgroup : %s\n\tpattern : %s", l.Group, l.Pattern) 45 | } 46 | } 47 | }() 48 | 49 | parseGroup(l.Group, l.Pattern) 50 | }() 51 | } 52 | } 53 | 54 | // Test group toString 55 | func TestGroupToString(t *testing.T) { 56 | tbl := []struct { 57 | Group string 58 | Pattern string 59 | ResourceName string 60 | Tokens []string 61 | Expected string 62 | }{ 63 | {"", "test", "test", []string{"test"}, "test"}, 64 | {"test", "test", "test", []string{"test"}, "test"}, 65 | {"foo", "test", "test", []string{"test"}, "foo"}, 66 | {"test", "test.$foo", "test.42", []string{"test", "42"}, "test"}, 67 | {"test.${foo}", "test.$foo", "test.42", []string{"test", "42"}, "test.42"}, 68 | {"bar.${foo}", "test.$foo", "test.42", []string{"test", "42"}, "bar.42"}, 69 | {"${foo}", "test.$foo", "test.42", []string{"test", "42"}, "42"}, 70 | {"${foo}.test", "test.$foo", "test.42", []string{"test", "42"}, "42.test"}, 71 | {"${foo}${bar}", "test.$foo.$bar", "test.42.baz", []string{"test", "42", "baz"}, "42baz"}, 72 | {"${bar}${foo}", "test.$foo.$bar", "test.42.baz", []string{"test", "42", "baz"}, "baz42"}, 73 | {"${foo}.${bar}", "test.$foo.$bar.>", "test.42.baz.extra.all", []string{"test", "42", "baz", "extra", "all"}, "42.baz"}, 74 | {"${foo}${foo}", "test.$foo.$bar", "test.42.baz", []string{"test", "42", "baz"}, "4242"}, 75 | {"${foo}.test.this.${bar}", "test.$foo.$bar", "test.42.baz", []string{"test", "42", "baz"}, "42.test.this.baz"}, 76 | } 77 | 78 | for _, l := range tbl { 79 | func() { 80 | gr := parseGroup(l.Group, l.Pattern) 81 | wid := gr.toString(l.ResourceName, l.Tokens) 82 | if wid != l.Expected { 83 | t.Errorf("expected parseGroup(%#v, %#v).toString(%#v, %#v) to return:\n\t%#v\nbut got:\n\t%#v", l.Group, l.Pattern, l.ResourceName, l.Tokens, l.Expected, wid) 84 | } 85 | }() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | // Logger is used to write log entries. 4 | // The interface is meant to be simple to wrap for other logger implementations. 5 | type Logger interface { 6 | // Infof logs service state, such as connects and reconnects to NATS. 7 | Infof(format string, v ...interface{}) 8 | 9 | // Errorf logs errors in the service, or incoming messages not complying 10 | // with the RES protocol. 11 | Errorf(format string, v ...interface{}) 12 | 13 | // Tracef all network traffic going to and from the service. 14 | Tracef(format string, v ...interface{}) 15 | } 16 | -------------------------------------------------------------------------------- /logger/memlogger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "sync" 8 | ) 9 | 10 | // MemLogger writes log messages to a bytes buffer. 11 | type MemLogger struct { 12 | log *log.Logger 13 | b *bytes.Buffer 14 | logInfo bool 15 | logErr bool 16 | logTrace bool 17 | mu sync.Mutex 18 | } 19 | 20 | // NewMemLogger returns a new logger that writes to a bytes buffer 21 | func NewMemLogger() *MemLogger { 22 | b := &bytes.Buffer{} 23 | 24 | return &MemLogger{ 25 | log: log.New(b, "", log.LstdFlags), 26 | b: b, 27 | logErr: true, 28 | logInfo: true, 29 | } 30 | } 31 | 32 | // SetFlags sets the output flags for the logger. 33 | func (l *MemLogger) SetFlags(flag int) *MemLogger { 34 | l.log.SetFlags(flag) 35 | return l 36 | } 37 | 38 | // SetInfo sets whether info entries should be logged. 39 | func (l *MemLogger) SetInfo(logInfo bool) *MemLogger { 40 | l.logInfo = logInfo 41 | return l 42 | } 43 | 44 | // SetErr sets whether error entries should be logged. 45 | func (l *MemLogger) SetErr(logErr bool) *MemLogger { 46 | l.logErr = logErr 47 | return l 48 | } 49 | 50 | // SetTrace sets whether trace entries should be logged. 51 | func (l *MemLogger) SetTrace(logTrace bool) *MemLogger { 52 | l.logTrace = logTrace 53 | return l 54 | } 55 | 56 | // Infof writes an info log entry. 57 | func (l *MemLogger) Infof(format string, v ...interface{}) { 58 | if l.logInfo { 59 | l.mu.Lock() 60 | l.log.Print("[INF] ", fmt.Sprintf(format, v...)) 61 | l.mu.Unlock() 62 | } 63 | } 64 | 65 | // Errorf writes an error log entry. 66 | func (l *MemLogger) Errorf(format string, v ...interface{}) { 67 | if l.logErr { 68 | l.mu.Lock() 69 | l.log.Print("[ERR] ", fmt.Sprintf(format, v...)) 70 | l.mu.Unlock() 71 | } 72 | } 73 | 74 | // Tracef writes a trace log entry. 75 | func (l *MemLogger) Tracef(format string, v ...interface{}) { 76 | if l.logTrace { 77 | l.mu.Lock() 78 | l.log.Print("[TRA] ", fmt.Sprintf(format, v...)) 79 | l.mu.Unlock() 80 | } 81 | } 82 | 83 | // String returns the log 84 | func (l *MemLogger) String() string { 85 | l.mu.Lock() 86 | defer l.mu.Unlock() 87 | return l.b.String() 88 | } 89 | -------------------------------------------------------------------------------- /logger/stdlogger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | // StdLogger writes log entries to os.Stderr 10 | type StdLogger struct { 11 | log *log.Logger 12 | logInfo bool 13 | logErr bool 14 | logTrace bool 15 | } 16 | 17 | // NewStdLogger returns a new logger that writes to os.Stderr 18 | // using the standard log package. 19 | // By default, it will log info and error entries, but not trace entries. 20 | func NewStdLogger() *StdLogger { 21 | return &StdLogger{ 22 | log: log.New(os.Stderr, "", log.LstdFlags), 23 | logErr: true, 24 | logInfo: true, 25 | } 26 | } 27 | 28 | // SetFlags sets the output flags for the logger. 29 | func (l *StdLogger) SetFlags(flag int) *StdLogger { 30 | l.log.SetFlags(flag) 31 | return l 32 | } 33 | 34 | // SetInfo sets whether info entries should be logged. 35 | func (l *StdLogger) SetInfo(logInfo bool) *StdLogger { 36 | l.logInfo = logInfo 37 | return l 38 | } 39 | 40 | // SetErr sets whether error entries should be logged. 41 | func (l *StdLogger) SetErr(logErr bool) *StdLogger { 42 | l.logErr = logErr 43 | return l 44 | } 45 | 46 | // SetTrace sets whether trace entries should be logged. 47 | func (l *StdLogger) SetTrace(logTrace bool) *StdLogger { 48 | l.logTrace = logTrace 49 | return l 50 | } 51 | 52 | // Infof writes an info log entry. 53 | func (l *StdLogger) Infof(format string, v ...interface{}) { 54 | if l.logInfo { 55 | l.log.Print("[INF] ", fmt.Sprintf(format, v...)) 56 | } 57 | } 58 | 59 | // Errorf writes an error log entry. 60 | func (l *StdLogger) Errorf(format string, v ...interface{}) { 61 | if l.logErr { 62 | l.log.Print("[ERR] ", fmt.Sprintf(format, v...)) 63 | } 64 | } 65 | 66 | // Tracef writes a trace log entry. 67 | func (l *StdLogger) Tracef(format string, v ...interface{}) { 68 | if l.logTrace { 69 | l.log.Print("[TRA] ", fmt.Sprintf(format, v...)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /middleware/README.md: -------------------------------------------------------------------------------- 1 |

Resgate logo

2 |

Middleware for Go RES Service
Synchronize Your Clients

3 |

4 | License 5 | Reference 6 | Status 7 |

8 | 9 | --- 10 | 11 | This package is deprecated, please use the [store package](../store/) instead. 12 | 13 | The *store* interface provides superior structure for building services that scales well. 14 | -------------------------------------------------------------------------------- /middleware/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package middleware provides middleware for the res package: 3 | 4 | https://github.com/jirenius/go-res 5 | 6 | Middleware can be used for adding handler functions to a res.Handler, 7 | to perform tasks such as: 8 | 9 | * storing, loading and updating persisted data 10 | * synchronize changes between multiple service instances 11 | * add additional logging 12 | * provide helpers for complex live queries 13 | 14 | Currently, only the BadgerDB middleware is created, to demonstrate 15 | database persistence. 16 | 17 | # Usage 18 | 19 | Add middleware to a resource: 20 | 21 | s.Handle("user.$id", 22 | middlware.BadgerDB{DB: db}, 23 | ) 24 | */ 25 | package middleware 26 | -------------------------------------------------------------------------------- /middleware/example_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "github.com/dgraph-io/badger" 5 | res "github.com/jirenius/go-res" 6 | "github.com/jirenius/go-res/middleware" 7 | ) 8 | 9 | func ExampleBadgerDB() { 10 | db := &badger.DB{} // Dummy. Use badger.Open 11 | 12 | s := res.NewService("directory") 13 | s.Handle("user.$id", 14 | res.Model, 15 | middleware.BadgerDB{DB: db}, 16 | /* ... */ 17 | ) 18 | } 19 | 20 | func ExampleBadgerDB_WithType() { 21 | db := &badger.DB{} // Dummy. Use badger.Open 22 | 23 | type User struct { 24 | ID int `json:"id"` 25 | Name string `json:"name"` 26 | } 27 | 28 | s := res.NewService("directory") 29 | badgerDB := middleware.BadgerDB{DB: db} 30 | s.Handle("user.$id", 31 | res.Model, 32 | badgerDB.WithType(User{}), 33 | res.Set(func(r res.CallRequest) { 34 | _ = r.RequireValue().(User) 35 | /* ... */ 36 | r.OK(nil) 37 | }), 38 | ) 39 | } 40 | 41 | func ExampleBadgerDB_WithDefault() { 42 | db := &badger.DB{} // Dummy. Use badger.Open 43 | 44 | s := res.NewService("directory") 45 | badgerDB := middleware.BadgerDB{DB: db} 46 | s.Handle("users", 47 | res.Collection, 48 | // Default to an empty slice of references 49 | badgerDB.WithType([]res.Ref{}).WithDefault([]res.Ref{}), 50 | /* ... */ 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /middleware/resbadger/README.md: -------------------------------------------------------------------------------- 1 |

Resgate logo

2 |

BadgerDB Middleware for Go RES Service
Synchronize Your Clients

3 |

4 | License 5 | Reference 6 | Status 7 |

8 | 9 | --- 10 | 11 | This package is deprecated, please use the [store package](../../store) together with the [badgerstore package](../../store/badgerstore) instead. 12 | 13 | The *store* interface provides superior structure for building services that scales well. 14 | -------------------------------------------------------------------------------- /middleware/resbadger/collection.go: -------------------------------------------------------------------------------- 1 | package resbadger 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | 7 | res "github.com/jirenius/go-res" 8 | ) 9 | 10 | // Collection represents a collection that is stored in the badger DB by its resource ID. 11 | type Collection struct { 12 | // BadgerDB middleware 13 | BadgerDB BadgerDB 14 | // Default resource value if not found in database. 15 | // Will return res.ErrNotFound if not set. 16 | Default interface{} 17 | // Type used to marshal into when calling r.Value() or r.RequireValue(). 18 | // Defaults to []interface{} if not set. 19 | Type interface{} 20 | } 21 | 22 | // WithDefault returns a new BadgerDB value with the Default resource value set to i. 23 | func (o Collection) WithDefault(i interface{}) Collection { 24 | o.Default = i 25 | return o 26 | } 27 | 28 | // WithType returns a new Collection value with the Type value set to v. 29 | func (o Collection) WithType(v interface{}) Collection { 30 | o.Type = v 31 | return o 32 | } 33 | 34 | // SetOption sets the res handler options, 35 | // and implements the res.Option interface. 36 | func (o Collection) SetOption(hs *res.Handler) { 37 | var err error 38 | 39 | if o.BadgerDB.DB == nil { 40 | panic("middleware: no badger DB set") 41 | } 42 | 43 | b := resourceHandler{ 44 | def: o.Default, 45 | BadgerDB: o.BadgerDB, 46 | } 47 | 48 | if o.Type != nil { 49 | b.t = reflect.TypeOf(o.Type) 50 | } else { 51 | b.t = reflect.TypeOf([]interface{}(nil)) 52 | } 53 | 54 | if b.def != nil { 55 | if !b.t.AssignableTo(reflect.TypeOf(b.def)) { 56 | panic("resbadger: default value not assignable to Type") 57 | } 58 | b.rawDefault, err = json.Marshal(b.def) 59 | if err != nil { 60 | panic(err) 61 | } 62 | } 63 | 64 | res.Collection.SetOption(hs) 65 | res.GetResource(b.getResource).SetOption(hs) 66 | res.ApplyAdd(b.applyAdd).SetOption(hs) 67 | res.ApplyRemove(b.applyRemove).SetOption(hs) 68 | res.ApplyCreate(b.applyCreate).SetOption(hs) 69 | res.ApplyDelete(b.applyDelete).SetOption(hs) 70 | } 71 | -------------------------------------------------------------------------------- /middleware/resbadger/index.go: -------------------------------------------------------------------------------- 1 | package resbadger 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/dgraph-io/badger" 8 | res "github.com/jirenius/go-res" 9 | ) 10 | 11 | // Index defines an index used for a resource. 12 | // 13 | // When used on Model resource, an index entry will be added for each model entry. 14 | // An index entry will have no value (nil), and the key will have the following structure: 15 | // 16 | // :? 17 | // 18 | // Where: 19 | // * is the name of the Index (so keep it rather short) 20 | // * is the index value as returned from the Key callback 21 | // * is the resource ID of the indexed model 22 | type Index struct { 23 | // Index name 24 | Name string 25 | // Key callback is called with a resource item of the type defined by Type, 26 | // and should return the string to use as index value. 27 | // It does not have to be unique. 28 | // 29 | // Example index by Country and lower case Name on a user model: 30 | // func(v interface{}) { 31 | // user := v.(UserModel) 32 | // return []byte(user.Country + "_" + strings.ToLower(user.Name)) 33 | // } 34 | Key func(interface{}) []byte 35 | } 36 | 37 | // IndexSet represents a set of indexes for a model resource. 38 | type IndexSet struct { 39 | // List of indexes 40 | Indexes []Index 41 | // Index listener callbacks to be called on changes in the index. 42 | listeners []indexListener 43 | } 44 | 45 | // IndexQuery represents a query towards an index. 46 | type IndexQuery struct { 47 | // Index used 48 | Index Index 49 | // KeyPrefix to match against the index key 50 | KeyPrefix []byte 51 | // FilterKeys for keys in the query collection. May be nil. 52 | FilterKeys func(key []byte) bool 53 | // Offset from which item to start. 54 | Offset int 55 | // Limit how many items to read. Negative means unlimited. 56 | Limit int 57 | // Reverse flag to tell if order is reversed 58 | Reverse bool 59 | } 60 | 61 | type indexListener struct { 62 | cb func(r res.Resource, before, after interface{}) 63 | name string 64 | } 65 | 66 | // Byte that separates the index key prefix from the resource ID. 67 | const ridSeparator = byte(0) 68 | 69 | // Max initial buffer size for results, and default size for limit set to -1. 70 | const resultBufSize = 256 71 | 72 | // Max int value. 73 | const maxInt = int(^uint(0) >> 1) 74 | 75 | // Listen adds a callback listening to the changes that have affected one or more index entries. 76 | // 77 | // The model before value will be nil if the model was created, or if previously not indexed. 78 | // The model after value will be nil if the model was deleted, or if no longer indexed. 79 | func (i *IndexSet) Listen(cb func(r res.Resource, before, after interface{})) { 80 | i.listeners = append(i.listeners, indexListener{cb: cb}) 81 | } 82 | 83 | // ListenIndex adds a callback listening to the changes of a specific index. 84 | // 85 | // The model before value will be nil if the model was created, or if previously not indexed. 86 | // The model after value will be nil if the model was deleted, or if no longer indexed. 87 | func (i *IndexSet) ListenIndex(name string, cb func(r res.Resource, before, after interface{})) { 88 | i.listeners = append(i.listeners, indexListener{cb: cb, name: name}) 89 | } 90 | 91 | // triggerListeners calls the callback of each registered listener. 92 | func (i *IndexSet) triggerListeners(name string, r res.Resource, before, after interface{}) { 93 | for _, il := range i.listeners { 94 | if il.name == name { 95 | il.cb(r, before, after) 96 | } 97 | } 98 | } 99 | 100 | // GetIndex returns an index by name, or an error if not found. 101 | func (i *IndexSet) GetIndex(name string) (Index, error) { 102 | for _, idx := range i.Indexes { 103 | if idx.Name == name { 104 | return idx, nil 105 | } 106 | } 107 | return Index{}, fmt.Errorf("index %s not found", name) 108 | } 109 | 110 | func (idx Index) getKey(rname []byte, value []byte) []byte { 111 | b := make([]byte, len(idx.Name)+len(value)+len(rname)+2) 112 | copy(b, idx.Name) 113 | offset := len(idx.Name) 114 | b[offset] = ':' 115 | offset++ 116 | copy(b[offset:], value) 117 | offset += len(value) 118 | b[offset] = ridSeparator 119 | copy(b[offset+1:], rname) 120 | return b 121 | } 122 | 123 | func (idx Index) getQuery(keyPrefix []byte) []byte { 124 | b := make([]byte, len(idx.Name)+len(keyPrefix)+1) 125 | copy(b, idx.Name) 126 | offset := len(idx.Name) 127 | b[offset] = ':' 128 | offset++ 129 | copy(b[offset:], keyPrefix) 130 | return b 131 | } 132 | 133 | // FetchCollection fetches a collection of resource references based on the query. 134 | func (iq *IndexQuery) FetchCollection(db *badger.DB) ([]res.Ref, error) { 135 | offset := iq.Offset 136 | limit := iq.Limit 137 | 138 | // Quick exit if we are fetching zero items 139 | if limit == 0 { 140 | return nil, nil 141 | } 142 | 143 | // Set "unlimited" limit to max int value 144 | if limit < 0 { 145 | limit = maxInt 146 | } 147 | 148 | // Prepare a slice to store the results in 149 | buf := resultBufSize 150 | if limit > 0 && limit < resultBufSize { 151 | buf = limit 152 | } 153 | result := make([]res.Ref, 0, buf) 154 | 155 | queryPrefix := iq.Index.getQuery(iq.KeyPrefix) 156 | qplen := len(queryPrefix) 157 | 158 | filter := iq.FilterKeys 159 | namelen := len(iq.Index.Name) + 1 160 | 161 | if err := db.View(func(txn *badger.Txn) error { 162 | opts := badger.DefaultIteratorOptions 163 | opts.PrefetchValues = false 164 | opts.Reverse = iq.Reverse 165 | it := txn.NewIterator(opts) 166 | defer it.Close() 167 | for it.Seek(queryPrefix); it.ValidForPrefix(queryPrefix); it.Next() { 168 | k := it.Item().Key() 169 | idx := bytes.LastIndexByte(k, ridSeparator) 170 | if idx < 0 { 171 | return fmt.Errorf("index entry [%s] is invalid", k) 172 | } 173 | // Validate that a query with ?-mark isn't mistaken for a hit 174 | // when matching the ? separator for the resource ID. 175 | if qplen > idx { 176 | continue 177 | } 178 | 179 | // If we have a key filter, validate against it 180 | if filter != nil { 181 | if !filter(k[namelen:idx]) { 182 | continue 183 | } 184 | } 185 | 186 | // Skip until we reach the offset we are searching from 187 | if offset > 0 { 188 | offset-- 189 | continue 190 | } 191 | 192 | // Add resource ID reference to result 193 | result = append(result, res.Ref(k[idx+1:])) 194 | 195 | limit-- 196 | if limit == 0 { 197 | return nil 198 | } 199 | } 200 | return nil 201 | }); err != nil { 202 | return nil, err 203 | } 204 | 205 | return result, nil 206 | } 207 | -------------------------------------------------------------------------------- /middleware/resbadger/model.go: -------------------------------------------------------------------------------- 1 | package resbadger 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "reflect" 7 | 8 | "github.com/dgraph-io/badger" 9 | res "github.com/jirenius/go-res" 10 | ) 11 | 12 | // Model represents a model that is stored in the badger DB by its resource ID. 13 | type Model struct { 14 | // BadgerDB middleware 15 | BadgerDB BadgerDB 16 | // Default resource value if not found in database. 17 | // Will return res.ErrNotFound if not set. 18 | Default interface{} 19 | // Type used to marshal into when calling r.Value() or r.RequireValue(). 20 | // Defaults to map[string]interface{} if not set. 21 | Type interface{} 22 | // IndexSet defines a set of indexes to be created for the model. 23 | IndexSet *IndexSet 24 | // Map defines a map callback to transform the model when 25 | // responding to get requests. 26 | Map func(interface{}) (interface{}, error) 27 | } 28 | 29 | // WithDefault returns a new BadgerDB value with the Default resource value set to i. 30 | func (o Model) WithDefault(i interface{}) Model { 31 | o.Default = i 32 | return o 33 | } 34 | 35 | // WithType returns a new Model value with the Type value set to v. 36 | func (o Model) WithType(v interface{}) Model { 37 | o.Type = v 38 | return o 39 | } 40 | 41 | // WithIndexSet returns a new Model value with the IndexSet set to idxs. 42 | func (o Model) WithIndexSet(idxs *IndexSet) Model { 43 | o.IndexSet = idxs 44 | return o 45 | } 46 | 47 | // WithMap returns a new Model value with the Map set to m. 48 | // 49 | // The m callback takes the model value v, with the type being Type, 50 | // and returns the value to send in response to the get request. 51 | func (o Model) WithMap(m func(interface{}) (interface{}, error)) Model { 52 | o.Map = m 53 | return o 54 | } 55 | 56 | // RebuildIndexes drops existing indexes and creates new entries for the 57 | // models with the given resource pattern. 58 | // 59 | // The resource pattern should be the full pattern, including 60 | // any service name. It may contain $tags, or end with a full wildcard (>). 61 | // 62 | // test.model.$id 63 | // test.resource.> 64 | func (o Model) RebuildIndexes(pattern string) error { 65 | // Quick exit in case no index exists 66 | if o.IndexSet == nil || len(o.IndexSet.Indexes) == 0 { 67 | return nil 68 | } 69 | 70 | p := res.Pattern(pattern) 71 | if !p.IsValid() { 72 | return errors.New("invalid pattern") 73 | } 74 | 75 | // Drop existing index entries 76 | for _, idx := range o.IndexSet.Indexes { 77 | err := o.BadgerDB.DB.DropPrefix([]byte(idx.Name)) 78 | if err != nil { 79 | return err 80 | } 81 | } 82 | 83 | t := reflect.TypeOf(o.Type) 84 | 85 | // Create a prefix to seek from 86 | ridPrefix := pattern 87 | i := p.IndexWildcard() 88 | if i >= 0 { 89 | ridPrefix = pattern[:i] 90 | } 91 | 92 | // Create new index entries in a single transaction 93 | return o.BadgerDB.DB.Update(func(txn *badger.Txn) error { 94 | it := txn.NewIterator(badger.DefaultIteratorOptions) 95 | defer it.Close() 96 | prefix := []byte(ridPrefix) 97 | for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { 98 | // Ensure the key matches the pattern 99 | if !p.Matches(string(it.Item().Key())) { 100 | continue 101 | } 102 | // Load item and unmarshal it 103 | item := it.Item() 104 | v := reflect.New(t) 105 | err := item.Value(func(dta []byte) error { 106 | return json.Unmarshal(dta, v.Interface()) 107 | }) 108 | if err != nil { 109 | return err 110 | } 111 | // Loop through indexes and generate a new entry per index 112 | for _, idx := range o.IndexSet.Indexes { 113 | rname := item.KeyCopy(nil) 114 | idxKey := idx.getKey(rname, idx.Key(v.Elem().Interface())) 115 | err = txn.SetEntry(&badger.Entry{Key: idxKey, Value: nil, UserMeta: typeIndex}) 116 | if err != nil { 117 | return err 118 | } 119 | } 120 | } 121 | return nil 122 | }) 123 | } 124 | 125 | // SetOption sets the res handler options, 126 | // and implements the res.Option interface. 127 | func (o Model) SetOption(hs *res.Handler) { 128 | var err error 129 | 130 | if o.BadgerDB.DB == nil { 131 | panic("middleware: no badger DB set") 132 | } 133 | 134 | b := resourceHandler{ 135 | def: o.Default, 136 | idxs: o.IndexSet, 137 | m: o.Map, 138 | BadgerDB: o.BadgerDB, 139 | } 140 | 141 | if o.Type != nil { 142 | b.t = reflect.TypeOf(o.Type) 143 | } else { 144 | b.t = reflect.TypeOf(map[string]interface{}(nil)) 145 | } 146 | 147 | if b.def != nil { 148 | if !b.t.AssignableTo(reflect.TypeOf(b.def)) { 149 | panic("resbadger: default value not assignable to Type") 150 | } 151 | b.rawDefault, err = json.Marshal(b.def) 152 | if err != nil { 153 | panic(err) 154 | } 155 | } 156 | 157 | res.Model.SetOption(hs) 158 | res.GetResource(b.getResource).SetOption(hs) 159 | res.ApplyChange(b.applyChange).SetOption(hs) 160 | res.ApplyCreate(b.applyCreate).SetOption(hs) 161 | res.ApplyDelete(b.applyDelete).SetOption(hs) 162 | } 163 | -------------------------------------------------------------------------------- /middleware/resbadger/querycollection.go: -------------------------------------------------------------------------------- 1 | package resbadger 2 | 3 | import ( 4 | "bytes" 5 | "net/url" 6 | 7 | res "github.com/jirenius/go-res" 8 | ) 9 | 10 | // QueryCollection represents a collection of indexed Models that may be queried. 11 | type QueryCollection struct { 12 | // BadgerDB middleware 13 | BadgerDB BadgerDB 14 | // IndexSet defines a set of indexes to be used with query requests. 15 | IndexSet *IndexSet 16 | // QueryCallback takes a query request and returns an IndexQuery used for searching. 17 | QueryCallback QueryCallback 18 | } 19 | 20 | // QueryCallback is called for each query request. 21 | // It returns an index query and a normalized query string, or an error. 22 | // 23 | // If the normalized query string is empty, the initial query string is used as normalized query. 24 | type QueryCallback func(idxs *IndexSet, rname string, params map[string]string, query url.Values) (*IndexQuery, string, error) 25 | 26 | type queryCollection struct { 27 | BadgerDB 28 | idxs *IndexSet 29 | queryCallback QueryCallback 30 | pattern string 31 | s *res.Service 32 | } 33 | 34 | // WithIndexSet returns a new QueryCollection value with the IndexSet set to idxs. 35 | func (o QueryCollection) WithIndexSet(idxs *IndexSet) QueryCollection { 36 | o.IndexSet = idxs 37 | return o 38 | } 39 | 40 | // WithQueryCallback returns a new QueryCollection value with the QueryCallback set to callback. 41 | func (o QueryCollection) WithQueryCallback(callback QueryCallback) QueryCollection { 42 | o.QueryCallback = callback 43 | return o 44 | } 45 | 46 | // SetOption sets the res handler options, 47 | // and implements the res.Option interface. 48 | func (o QueryCollection) SetOption(hs *res.Handler) { 49 | // var err error 50 | 51 | if o.BadgerDB.DB == nil { 52 | panic("middleware: no badger DB set") 53 | } 54 | 55 | if o.IndexSet == nil { 56 | panic("resbadger: no indexes set") 57 | } 58 | 59 | qc := queryCollection{ 60 | BadgerDB: o.BadgerDB, 61 | idxs: o.IndexSet, 62 | queryCallback: o.QueryCallback, 63 | } 64 | 65 | res.Collection.SetOption(hs) 66 | res.GetResource(qc.getQueryCollection).SetOption(hs) 67 | res.OnRegister(qc.onRegister).SetOption(hs) 68 | o.IndexSet.Listen(qc.onIndexUpdate) 69 | } 70 | 71 | func (qc *queryCollection) onRegister(service *res.Service, pattern res.Pattern, rh res.Handler) { 72 | qc.s = service 73 | qc.pattern = string(pattern) 74 | } 75 | 76 | // onIndexUpdate is a handler for changes to the indexes used 77 | // by the query collection. 78 | func (qc *queryCollection) onIndexUpdate(r res.Resource, before, after interface{}) { 79 | qcr, err := r.Service().Resource(qc.pattern) 80 | if err != nil { 81 | panic(err) 82 | } 83 | qcr.QueryEvent(func(qreq res.QueryRequest) { 84 | // Nil means end of query event. 85 | if qreq == nil { 86 | return 87 | } 88 | 89 | iq, _, err := qc.queryCallback(qc.idxs, qcr.ResourceName(), qcr.PathParams(), qreq.ParseQuery()) 90 | if err != nil { 91 | qreq.Error(res.InternalError(err)) 92 | return 93 | } 94 | var beforeKey, afterKey []byte 95 | if before != nil { 96 | beforeKey = iq.Index.Key(before) 97 | } 98 | if after != nil { 99 | afterKey = iq.Index.Key(after) 100 | } 101 | // No event if no change to the index 102 | if bytes.Equal(beforeKey, afterKey) { 103 | return 104 | } 105 | wasMatch := before != nil && bytes.HasPrefix(beforeKey, iq.KeyPrefix) 106 | isMatch := after != nil && bytes.HasPrefix(afterKey, iq.KeyPrefix) 107 | if iq.FilterKeys != nil { 108 | if wasMatch { 109 | wasMatch = iq.FilterKeys(beforeKey) 110 | } 111 | if isMatch { 112 | isMatch = iq.FilterKeys(afterKey) 113 | } 114 | } 115 | if wasMatch || isMatch { 116 | collection, err := iq.FetchCollection(qc.DB) 117 | if err != nil { 118 | qreq.Error(res.ToError(err)) 119 | } 120 | qreq.Collection(collection) 121 | } 122 | }) 123 | } 124 | 125 | // getQueryCollection is a get handler for a query request. 126 | func (qc *queryCollection) getQueryCollection(r res.GetRequest) { 127 | iq, normalizedQuery, err := qc.queryCallback(qc.idxs, r.ResourceName(), r.PathParams(), r.ParseQuery()) 128 | if err != nil { 129 | r.Error(res.ToError(err)) 130 | return 131 | } 132 | 133 | collection, err := iq.FetchCollection(qc.DB) 134 | if err != nil { 135 | r.Error(res.ToError(err)) 136 | return 137 | } 138 | 139 | // Get normalized query, or default to the initial query. 140 | if normalizedQuery == "" { 141 | normalizedQuery = r.Query() 142 | } 143 | r.QueryCollection(collection, normalizedQuery) 144 | } 145 | -------------------------------------------------------------------------------- /middleware/resbadger/resbadger.go: -------------------------------------------------------------------------------- 1 | package resbadger 2 | 3 | import ( 4 | "github.com/dgraph-io/badger" 5 | ) 6 | 7 | // BadgerDB provides persistence to BadgerDB for the res Handlers. 8 | // 9 | // It will set the GetResource and Apply* handlers to load, store, and update the resources 10 | // in the database, using the resource ID as key value. 11 | type BadgerDB struct { 12 | // BadgerDB database 13 | DB *badger.DB 14 | } 15 | 16 | // Model returns a middleware builder of type Model. 17 | func (o BadgerDB) Model() Model { 18 | return Model{BadgerDB: o} 19 | } 20 | 21 | // Collection returns a middleware builder of type Collection. 22 | func (o BadgerDB) Collection() Collection { 23 | return Collection{BadgerDB: o} 24 | } 25 | 26 | // QueryCollection returns a middleware builder of type QueryCollection. 27 | func (o BadgerDB) QueryCollection() QueryCollection { 28 | return QueryCollection{BadgerDB: o} 29 | } 30 | 31 | // WithDB returns a new BadgerDB value with the DB set to db. 32 | func (o BadgerDB) WithDB(db *badger.DB) BadgerDB { 33 | o.DB = db 34 | return o 35 | } 36 | -------------------------------------------------------------------------------- /pattern.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | // Pattern is a resource pattern that may contain wildcards and tags. 4 | // 5 | // Pattern("example.resource.>") // Full wild card (>) matches anything that follows 6 | // Pattern("example.item.*") // Wild card (*) matches a single part 7 | // Pattern("example.model.$id") // Tag (starting with $) matches a single part 8 | type Pattern string 9 | 10 | // IsValid returns true if the pattern is valid, otherwise false. 11 | func (p Pattern) IsValid() bool { 12 | if len(p) == 0 { 13 | return true 14 | } 15 | start := true 16 | alone := false 17 | emptytag := false 18 | for i, c := range p { 19 | if c == '.' { 20 | if start || emptytag { 21 | return false 22 | } 23 | alone = false 24 | start = true 25 | } else { 26 | if alone || c < 33 || c > 126 || c == '?' { 27 | return false 28 | } 29 | switch c { 30 | case '>': 31 | if !start || i < len(p)-1 { 32 | return false 33 | } 34 | case '*': 35 | if !start { 36 | return false 37 | } 38 | alone = true 39 | case '$': 40 | if start { 41 | emptytag = true 42 | } 43 | default: 44 | emptytag = false 45 | } 46 | start = false 47 | } 48 | } 49 | 50 | return !(start || emptytag) 51 | } 52 | 53 | // Matches tests if the resource name, s, matches the pattern. 54 | // 55 | // The resource name might in itself contain wild cards and tags. 56 | // 57 | // Behavior is undefined for an invalid pattern or an invalid resource name. 58 | func (p Pattern) Matches(s string) bool { 59 | pi := 0 60 | si := 0 61 | pl := len(p) 62 | sl := len(s) 63 | for pi < pl { 64 | if si == sl { 65 | return false 66 | } 67 | c := p[pi] 68 | pi++ 69 | switch c { 70 | case '$': 71 | fallthrough 72 | case '*': 73 | for pi < pl && p[pi] != '.' { 74 | pi++ 75 | } 76 | if s[si] == '>' { 77 | return false 78 | } 79 | for si < sl && s[si] != '.' { 80 | si++ 81 | } 82 | case '>': 83 | return pi == pl 84 | default: 85 | if c != s[si] { 86 | return false 87 | } 88 | si++ 89 | } 90 | } 91 | return si == sl 92 | } 93 | 94 | // ReplaceTags searches for tags and replaces them with 95 | // the map value for the key matching the tag (without $). 96 | // 97 | // Behavior is undefined for an invalid pattern. 98 | func (p Pattern) ReplaceTags(m map[string]string) Pattern { 99 | // Quick exit on empty map 100 | if len(m) == 0 { 101 | return p 102 | } 103 | return p.replace(func(t string) (string, bool) { 104 | v, ok := m[t] 105 | return v, ok 106 | }) 107 | } 108 | 109 | // ReplaceTag searches for a given tag (without $) and replaces 110 | // it with the value. 111 | // 112 | // Behavior is undefined for an invalid pattern. 113 | func (p Pattern) ReplaceTag(tag string, value string) Pattern { 114 | return p.replace(func(t string) (string, bool) { 115 | if tag == t { 116 | return value, true 117 | } 118 | return "", false 119 | }) 120 | } 121 | 122 | // replace replaces tags with a value. 123 | func (p Pattern) replace(replacer func(tag string) (string, bool)) Pattern { 124 | type rep struct { 125 | o int // tag offset (including $) 126 | e int // tag end 127 | v string // replace value 128 | } 129 | var rs []rep 130 | pi := 0 131 | pl := len(p) 132 | start := true 133 | var o int 134 | for pi < pl { 135 | c := p[pi] 136 | pi++ 137 | switch c { 138 | case '$': 139 | if start { 140 | // Temporarily store tag start offset 141 | o = pi 142 | // Find end of tag 143 | for pi < pl && p[pi] != '.' { 144 | pi++ 145 | } 146 | // Get the replacement value from the replacer callback. 147 | if v, ok := replacer(string(p[o:pi])); ok { 148 | rs = append(rs, rep{o: o - 1, e: pi, v: v}) 149 | } 150 | } 151 | case '.': 152 | start = true 153 | default: 154 | start = false 155 | } 156 | } 157 | // Quick exit on no replacements 158 | if len(rs) == 0 { 159 | return p 160 | } 161 | // Calculate length, nl, of resulting string 162 | nl := pl 163 | for _, r := range rs { 164 | nl += len(r.v) - r.e + r.o 165 | } 166 | // Create our result bytes 167 | result := make([]byte, nl) 168 | o = 0 // Reuse as result offset 169 | pi = 0 // Reuse as pattern index position 170 | for _, r := range rs { 171 | if r.o > 0 { 172 | seg := p[pi:r.o] 173 | copy(result[o:], seg) 174 | o += len(seg) 175 | } 176 | copy(result[o:], r.v) 177 | o += len(r.v) 178 | pi = r.e 179 | } 180 | if pi < pl { 181 | copy(result[o:], p[pi:]) 182 | } 183 | return Pattern(result) 184 | } 185 | 186 | // Values extracts the tag values from a resource name, s, matching the pattern. 187 | // 188 | // The returned bool flag is true if s matched the pattern, otherwise false with a nil map. 189 | // 190 | // Behavior is undefined for an invalid pattern or an invalid resource name. 191 | func (p Pattern) Values(s string) (map[string]string, bool) { 192 | pi := 0 193 | si := 0 194 | pl := len(p) 195 | sl := len(s) 196 | var m map[string]string 197 | for pi < pl { 198 | if si == sl { 199 | return nil, false 200 | } 201 | c := p[pi] 202 | pi++ 203 | switch c { 204 | case '$': 205 | po := pi 206 | for pi < pl && p[pi] != '.' { 207 | pi++ 208 | } 209 | so := si 210 | for si < sl && s[si] != '.' { 211 | si++ 212 | } 213 | if m == nil { 214 | m = make(map[string]string) 215 | } 216 | m[string(p[po:pi])] = s[so:si] 217 | case '*': 218 | for pi < pl && p[pi] != '.' { 219 | pi++ 220 | } 221 | for si < sl && s[si] != '.' { 222 | si++ 223 | } 224 | case '>': 225 | if pi == pl { 226 | return m, true 227 | } 228 | return nil, false 229 | default: 230 | for { 231 | if c != s[si] { 232 | return nil, false 233 | } 234 | si++ 235 | if c == '.' || pi == pl { 236 | break 237 | } 238 | c = p[pi] 239 | pi++ 240 | if si == sl { 241 | return nil, false 242 | } 243 | } 244 | } 245 | } 246 | if si != sl { 247 | return nil, false 248 | } 249 | return m, true 250 | } 251 | 252 | // IndexWildcard returns the index of the first instance of a wild card (*, >, or $tag) 253 | // in pattern, or -1 if no wildcard is present. 254 | // 255 | // Behavior is undefined for an invalid pattern. 256 | func (p Pattern) IndexWildcard() int { 257 | start := true 258 | for i, c := range p { 259 | if c == '.' { 260 | start = true 261 | } else { 262 | if start && ((c == '>' && i == len(p)-1) || 263 | c == '*' || 264 | c == '$') { 265 | return i 266 | } 267 | start = false 268 | } 269 | } 270 | return -1 271 | } 272 | -------------------------------------------------------------------------------- /queryevent.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "time" 9 | 10 | nats "github.com/nats-io/nats.go" 11 | ) 12 | 13 | const queryEventChannelSize = 10 14 | 15 | // QueryRequest has methods for responding to query requests. 16 | type QueryRequest interface { 17 | Resource 18 | Model(model interface{}) 19 | Collection(collection interface{}) 20 | NotFound() 21 | InvalidQuery(message string) 22 | Error(err error) 23 | Timeout(d time.Duration) 24 | } 25 | 26 | type queryRequest struct { 27 | resource 28 | msg *nats.Msg 29 | events []resEvent 30 | replied bool // Flag telling if a reply has been made 31 | } 32 | 33 | type queryEvent struct { 34 | r resource 35 | sub *nats.Subscription 36 | ch chan *nats.Msg 37 | cb func(r QueryRequest) 38 | } 39 | 40 | // Model sends a model response for the query request. 41 | // The model represents the current state of query model 42 | // for the given query. 43 | // Only valid for a query model resource. 44 | func (qr *queryRequest) Model(model interface{}) { 45 | if qr.h.Type == TypeCollection { 46 | panic("res: model response not allowed on query collections") 47 | } 48 | qr.success(modelResponse{Model: model}) 49 | } 50 | 51 | // Collection sends a collection response for the query request. 52 | // The collection represents the current state of query collection 53 | // for the given query. 54 | // Only valid for a query collection resource. 55 | func (qr *queryRequest) Collection(collection interface{}) { 56 | if qr.h.Type == TypeModel { 57 | panic("res: collection response not allowed on query models") 58 | } 59 | qr.success(collectionResponse{Collection: collection}) 60 | } 61 | 62 | // ChangeEvent adds a change event to the query response. 63 | // If ev is empty, no event is added. 64 | // Only valid for a query model resource. 65 | func (qr *queryRequest) ChangeEvent(ev map[string]interface{}) { 66 | if qr.h.Type == TypeCollection { 67 | panic("res: change event not allowed on query collections") 68 | } 69 | if len(ev) == 0 { 70 | return 71 | } 72 | qr.events = append(qr.events, resEvent{Event: "change", Data: changeEvent{Values: ev}}) 73 | } 74 | 75 | // AddEvent adds an add event to the query response, 76 | // adding the value v at index idx. 77 | // Only valid for a query collection resource. 78 | func (qr *queryRequest) AddEvent(v interface{}, idx int) { 79 | if qr.h.Type == TypeModel { 80 | panic("res: add event not allowed on query models") 81 | } 82 | if idx < 0 { 83 | panic("res: add event idx less than zero") 84 | } 85 | qr.events = append(qr.events, resEvent{Event: "add", Data: addEvent{Value: v, Idx: idx}}) 86 | } 87 | 88 | // RemoveEvent adds a remove event to the query response, 89 | // removing the value at index idx. 90 | // Only valid for a query collection resource. 91 | func (qr *queryRequest) RemoveEvent(idx int) { 92 | if qr.h.Type == TypeModel { 93 | panic("res: remove event not allowed on query models") 94 | } 95 | if idx < 0 { 96 | panic("res: remove event idx less than zero") 97 | } 98 | qr.events = append(qr.events, resEvent{Event: "remove", Data: removeEvent{Idx: idx}}) 99 | } 100 | 101 | // NotFound sends a system.notFound response for the query request. 102 | func (qr *queryRequest) NotFound() { 103 | qr.reply(responseNotFound) 104 | } 105 | 106 | // InvalidQuery sends a system.invalidQuery response for the query request. 107 | // An empty message will default to "Invalid query". 108 | func (qr *queryRequest) InvalidQuery(message string) { 109 | if message == "" { 110 | qr.reply(responseInvalidQuery) 111 | } else { 112 | qr.error(&Error{Code: CodeInvalidQuery, Message: message}) 113 | } 114 | } 115 | 116 | // Error sends a custom error response for the query request. 117 | func (qr *queryRequest) Error(err error) { 118 | qr.error(ToError(err)) 119 | } 120 | 121 | // Timeout attempts to set the timeout duration of the query request. 122 | // The call has no effect if the requester has already timed out the request. 123 | func (qr *queryRequest) Timeout(d time.Duration) { 124 | if d < 0 { 125 | panic("res: negative timeout duration") 126 | } 127 | out := []byte(`timeout:"` + strconv.FormatInt(int64(d/time.Millisecond), 10) + `"`) 128 | qr.s.rawEvent(qr.msg.Reply, out) 129 | } 130 | 131 | // startQueryListener listens for query requests and passes them on to a worker. 132 | func (qe *queryEvent) startQueryListener() { 133 | for m := range qe.ch { 134 | m := m 135 | qe.r.s.runWith(qe.r.Group(), func() { 136 | qe.handleQueryRequest(m) 137 | }) 138 | } 139 | } 140 | 141 | // handleQueryRequest is called by the query listener on incoming query requests. 142 | func (qe *queryEvent) handleQueryRequest(m *nats.Msg) { 143 | s := qe.r.s 144 | s.tracef("Q=> %s: %s", qe.r.rname, m.Data) 145 | 146 | qr := &queryRequest{ 147 | resource: qe.r, 148 | msg: m, 149 | } 150 | 151 | var rqr resQueryRequest 152 | var err error 153 | if len(m.Data) > 0 { 154 | err = json.Unmarshal(m.Data, &rqr) 155 | if err != nil { 156 | s.errorf("Error unmarshaling incoming query request: %s", err) 157 | qr.error(ToError(err)) 158 | return 159 | } 160 | } 161 | 162 | if rqr.Query == "" { 163 | s.errorf("Missing query on incoming query request: %s", m.Data) 164 | qr.reply(responseMissingQuery) 165 | return 166 | } 167 | 168 | qr.query = rqr.Query 169 | 170 | qr.executeCallback(qe.cb) 171 | if qr.replied { 172 | return 173 | } 174 | 175 | var data []byte 176 | if len(qr.events) == 0 { 177 | data = responseNoQueryEvents 178 | } else { 179 | data, err = json.Marshal(successResponse{Result: queryResponse{Events: qr.events}}) 180 | if err != nil { 181 | data = responseInternalError 182 | } 183 | } 184 | qr.reply(data) 185 | } 186 | 187 | func (qr *queryRequest) executeCallback(cb func(QueryRequest)) { 188 | // Recover from panics inside query event callback 189 | defer func() { 190 | v := recover() 191 | if v == nil { 192 | return 193 | } 194 | 195 | var str string 196 | 197 | switch e := v.(type) { 198 | case *Error: 199 | if !qr.replied { 200 | qr.error(e) 201 | // Return without logging, as panicing with an *Error is considered 202 | // a valid way of sending an error response. 203 | return 204 | } 205 | str = e.Message 206 | case error: 207 | str = e.Error() 208 | if !qr.replied { 209 | qr.error(ToError(e)) 210 | } 211 | case string: 212 | str = e 213 | if !qr.replied { 214 | qr.error(ToError(errors.New(e))) 215 | } 216 | default: 217 | str = fmt.Sprintf("%v", e) 218 | if !qr.replied { 219 | qr.error(ToError(errors.New(str))) 220 | } 221 | } 222 | 223 | qr.s.errorf("Error handling query request %s: %s", qr.rname, str) 224 | }() 225 | 226 | cb(qr) 227 | } 228 | 229 | // error sends an error response as a reply. 230 | func (qr *queryRequest) error(e *Error) { 231 | data, err := json.Marshal(errorResponse{Error: e}) 232 | if err != nil { 233 | data = responseInternalError 234 | } 235 | qr.reply(data) 236 | } 237 | 238 | // success sends a successful response as a reply. 239 | func (qr *queryRequest) success(result interface{}) { 240 | data, err := json.Marshal(successResponse{Result: result}) 241 | if err != nil { 242 | qr.error(ToError(err)) 243 | return 244 | } 245 | 246 | qr.reply(data) 247 | } 248 | 249 | // reply sends an encoded payload to as a reply. 250 | // If a reply is already sent, reply will log an error. 251 | func (qr *queryRequest) reply(payload []byte) { 252 | if qr.replied { 253 | qr.s.errorf("Response already sent on query request %s", qr.rname) 254 | return 255 | } 256 | qr.replied = true 257 | 258 | qr.s.tracef("<=Q %s: %s", qr.rname, payload) 259 | err := qr.s.nc.Publish(qr.msg.Reply, payload) 260 | if err != nil { 261 | qr.s.errorf("Error sending query reply %s: %s", qr.rname, err) 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /resprot/README.md: -------------------------------------------------------------------------------- 1 |

Resgate logo

2 |

Go utilities for communicating with RES Services
Synchronize Your Clients

3 |

4 | License 5 | Reference 6 |

7 | 8 | --- 9 | 10 | Package *resprot* provides low level structs and methods for communicating with 11 | services using the RES Service Protocol over NATS server. 12 | 13 | ## Installation 14 | 15 | ```bash 16 | go get github.com/jirenius/go-res/resprot 17 | ``` 18 | 19 | ## Example usage 20 | 21 | #### Make a request 22 | 23 | ```go 24 | conn, _ := nats.Connect("nats://127.0.0.1:4222") 25 | response := resprot.SendRequest(conn, "call.example.ping", nil, time.Second) 26 | ``` 27 | 28 | #### Get a model 29 | 30 | ```go 31 | response := resprot.SendRequest(conn, "get.example.model", nil, time.Second) 32 | 33 | var model struct { 34 | Message string `json:"message"` 35 | } 36 | _, err := response.ParseModel(&model) 37 | ``` 38 | 39 | #### Call a method 40 | 41 | ```go 42 | response := resprot.SendRequest(conn, "call.math.add", resprot.Request{Params: struct { 43 | A float64 `json:"a"` 44 | B float64 `json:"b"` 45 | }{5, 6}}, time.Second) 46 | 47 | var result struct { 48 | Sum float64 `json:"sum"` 49 | } 50 | err := response.ParseResult(&result) 51 | ``` 52 | -------------------------------------------------------------------------------- /resprot/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package resprot provides low level structs and methods for communicating with 3 | services using the RES Service Protocol over NATS server. 4 | 5 | https://github.com/resgateio/resgate/blob/master/docs/res-service-protocol.md 6 | 7 | # Usage 8 | 9 | Make a request: 10 | 11 | conn, _ := nats.Connect("nats://127.0.0.1:4222") 12 | response := resprot.SendRequest(conn, "call.example.ping", nil, time.Second) 13 | 14 | Get a model: 15 | 16 | response := resprot.SendRequest(conn, "get.example.model", nil, time.Second) 17 | 18 | var model struct { 19 | Message string `json:"message"` 20 | } 21 | _, err := response.ParseModel(&model) 22 | 23 | Call a method: 24 | 25 | response := resprot.SendRequest(conn, "call.math.add", resprot.Request{Params: struct { 26 | A float64 `json:"a"` 27 | B float64 `json:"b"` 28 | }{5, 6}}, time.Second) 29 | 30 | var result struct { 31 | Sum float64 `json:"sum"` 32 | } 33 | err := response.ParseResult(&result) 34 | */ 35 | package resprot 36 | -------------------------------------------------------------------------------- /restest/README.md: -------------------------------------------------------------------------------- 1 |

Resgate logo

2 |

Testing for Go RES Service
Synchronize Your Clients

3 |

4 | License 5 | Reference 6 |

7 | 8 | --- 9 | 10 | Package *restest* provides utilities for testing res services. 11 | 12 | ## Basic usage 13 | 14 | ```go 15 | func TestService(t *testing.T) { 16 | // Create service to test 17 | s := res.NewService("foo") 18 | s.Handle("bar.$id", 19 | res.Access(res.AccessGranted), 20 | res.GetModel(func(r res.ModelRequest) { 21 | r.Model(struct { 22 | Message string `json:"msg"` 23 | }{r.PathParam("id")}) 24 | }), 25 | ) 26 | 27 | // Create test session 28 | c := restest.NewSession(t, s) 29 | defer c.Close() 30 | 31 | // Test sending get request and validate response 32 | c.Get("foo.bar.42"). 33 | Response(). 34 | AssertModel(map[string]string{"msg": "42"}) 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /restest/assert.go: -------------------------------------------------------------------------------- 1 | package restest 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "testing" 8 | 9 | res "github.com/jirenius/go-res" 10 | ) 11 | 12 | // AssertEqualJSON expects that a and b json marshals into equal values, and 13 | // returns true if they do, otherwise logs a fatal error and returns false. 14 | func AssertEqualJSON(t *testing.T, name string, result, expected interface{}, ctx ...interface{}) bool { 15 | aa, aj := jsonMap(result) 16 | bb, bj := jsonMap(expected) 17 | 18 | if !reflect.DeepEqual(aa, bb) { 19 | t.Fatalf("expected %s to be:\n\t%s\nbut got:\n\t%s%s", name, bj, aj, ctxString(ctx)) 20 | return false 21 | } 22 | 23 | return true 24 | } 25 | 26 | // AssertTrue expects that a condition is true. 27 | func AssertTrue(t *testing.T, expectation string, isTrue bool, ctx ...interface{}) bool { 28 | if !isTrue { 29 | t.Fatalf("expected %s%s", expectation, ctxString(ctx)) 30 | return false 31 | } 32 | 33 | return true 34 | } 35 | 36 | // AssertNoError expects that err is nil, otherwise logs an error 37 | // with t.Fatalf 38 | func AssertNoError(t *testing.T, err error, ctx ...interface{}) { 39 | if err != nil { 40 | t.Fatalf("expected no error but got:\n%s%s", err, ctxString(ctx)) 41 | } 42 | } 43 | 44 | // AssertError expects that err is not nil, otherwise logs an error 45 | // with t.Fatalf 46 | func AssertError(t *testing.T, err error, ctx ...interface{}) { 47 | if err == nil { 48 | t.Fatalf("expected an error but got none%s", ctxString(ctx)) 49 | } 50 | } 51 | 52 | // AssertResError expects that err is of type *res.Error and matches rerr. 53 | func AssertResError(t *testing.T, err error, rerr *res.Error, ctx ...interface{}) { 54 | AssertError(t, err, ctx...) 55 | v, ok := err.(*res.Error) 56 | if !ok { 57 | t.Fatalf("expected error to be of type *res.Error%s", ctxString(ctx)) 58 | } 59 | AssertEqualJSON(t, "error", v, rerr, ctx...) 60 | } 61 | 62 | // AssertErrorCode expects that err is of type *res.Error with given code. 63 | func AssertErrorCode(t *testing.T, err error, code string, ctx ...interface{}) { 64 | AssertError(t, err, ctx...) 65 | v, ok := err.(*res.Error) 66 | if !ok { 67 | t.Fatalf("expected error to be of type *res.Error%s", ctxString(ctx)) 68 | } 69 | AssertEqualJSON(t, "error code", v.Code, code, ctx...) 70 | } 71 | 72 | // AssertPanic expects the callback function to panic, otherwise 73 | // logs an error with t.Errorf 74 | func AssertPanic(t *testing.T, cb func(), ctx ...interface{}) { 75 | defer func() { 76 | v := recover() 77 | if v == nil { 78 | t.Errorf("expected callback to panic, but it didn't%s", ctxString(ctx)) 79 | } 80 | }() 81 | cb() 82 | } 83 | 84 | // AssertPanicNoRecover expects the callback function to panic, otherwise 85 | // logs an error with t.Errorf. Does not recover from the panic 86 | func AssertPanicNoRecover(t *testing.T, cb func(), ctx ...interface{}) { 87 | panicking := true 88 | defer func() { 89 | if !panicking { 90 | t.Errorf(`expected callback to panic, but it didn't%s`, ctxString(ctx)) 91 | } 92 | }() 93 | cb() 94 | panicking = false 95 | } 96 | 97 | // AssertNil expects that a value is nil, otherwise it 98 | // logs an error with t.Fatalf. 99 | func AssertNil(t *testing.T, v interface{}, ctx ...interface{}) { 100 | if v != nil && !reflect.ValueOf(v).IsNil() { 101 | t.Fatalf("expected non-nil but got nil%s", ctxString(ctx)) 102 | } 103 | } 104 | 105 | // AssertNotNil expects that a value is non-nil, otherwise it 106 | // logs an error with t.Fatalf. 107 | func AssertNotNil(t *testing.T, v interface{}, ctx ...interface{}) { 108 | if v == nil || reflect.ValueOf(v).IsNil() { 109 | t.Fatalf("expected nil but got %+v%s", v, ctxString(ctx)) 110 | } 111 | } 112 | 113 | func ctxString(ctx []interface{}) string { 114 | if len(ctx) == 0 { 115 | return "" 116 | } 117 | return "\nin " + fmt.Sprint(ctx...) 118 | } 119 | 120 | func jsonMap(v interface{}) (interface{}, []byte) { 121 | var err error 122 | j, err := json.Marshal(v) 123 | if err != nil { 124 | panic("test: error marshaling value: " + err.Error()) 125 | } 126 | 127 | var m interface{} 128 | err = json.Unmarshal(j, &m) 129 | if err != nil { 130 | panic("test: error unmarshaling value: " + err.Error()) 131 | } 132 | 133 | return m, j 134 | } 135 | -------------------------------------------------------------------------------- /restest/codec.go: -------------------------------------------------------------------------------- 1 | package restest 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Request represents a request payload. 8 | type Request struct { 9 | CID string `json:"cid,omitempty"` 10 | Params json.RawMessage `json:"params,omitempty"` 11 | Token json.RawMessage `json:"token,omitempty"` 12 | Header map[string][]string `json:"header,omitempty"` 13 | Host string `json:"host,omitempty"` 14 | RemoteAddr string `json:"remoteAddr,omitempty"` 15 | URI string `json:"uri,omitempty"` 16 | Query string `json:"query,omitempty"` 17 | IsHTTP bool `json:"isHttp,omitempty"` 18 | } 19 | 20 | // DefaultCallRequest returns a default call request. 21 | func DefaultCallRequest() *Request { 22 | return &Request{CID: "testcid"} 23 | } 24 | 25 | // DefaultAccessRequest returns a default access request without token. 26 | func DefaultAccessRequest() *Request { 27 | return &Request{CID: "testcid"} 28 | } 29 | 30 | // DefaultAuthRequest returns a default auth request. 31 | func DefaultAuthRequest() *Request { 32 | return &Request{ 33 | CID: "testcid", 34 | Header: map[string][]string{ 35 | "Accept-Encoding": {"gzip, deflate, br"}, 36 | "Accept-Language": {"*"}, 37 | "Cache-Control": {"no-cache"}, 38 | "Connection": {"Upgrade"}, 39 | "Origin": {"http://localhost"}, 40 | "Pragma": {"no-cache"}, 41 | "Sec-Websocket-Extensions": {"permessage-deflate; client_max_window_bits"}, 42 | "Sec-Websocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="}, 43 | "Sec-Websocket-Version": {"13"}, 44 | "Upgrade": {"websocket"}, 45 | "User-Agent": {"GolangTest/1.0 (Test)"}, 46 | }, 47 | Host: "local", 48 | RemoteAddr: "127.0.0.1", 49 | URI: "/ws", 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /restest/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package restest provides utilities for testing res services: 3 | 4 | Usage 5 | 6 | func TestService(t *testing.T) { 7 | // Create service to test 8 | s := res.NewService("foo") 9 | s.Handle("bar.$id", 10 | res.Access(res.AccessGranted), 11 | res.GetModel(func(r res.ModelRequest) { 12 | r.Model(struct { 13 | Message string `json:"msg"` 14 | }{r.PathParam("id")}) 15 | }), 16 | ) 17 | 18 | // Create test session 19 | c := restest.NewSession(t, s) 20 | defer c.Close() 21 | 22 | // Test sending get request and validate response 23 | c.Get("foo.bar.42"). 24 | Response(). 25 | AssertModel(map[string]string{"msg": "42"}) 26 | } 27 | */ 28 | package restest 29 | -------------------------------------------------------------------------------- /restest/event.go: -------------------------------------------------------------------------------- 1 | package restest 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/jirenius/go-res/store" 7 | ) 8 | 9 | // Event represents an event. 10 | type Event struct { 11 | // Name of the event. 12 | Name string 13 | 14 | // Index position where the resource is added or removed from the query 15 | // result. 16 | // 17 | // Only valid for "add" and "remove" events. 18 | Idx int 19 | 20 | // ID of resource being added or removed from the query result. 21 | // 22 | // Only valid for "add" events. 23 | Value interface{} 24 | 25 | // Changed property values for the model emitting the event. 26 | // 27 | // Only valid for "change" events, and should marshal into a json object 28 | // with changed key/value properties. 29 | Changed interface{} 30 | 31 | // Payload of a custom event. 32 | Payload interface{} 33 | } 34 | 35 | // MarshalJSON marshals the event into json. 36 | func (ev Event) MarshalJSON() ([]byte, error) { 37 | switch ev.Name { 38 | case "change": 39 | return json.Marshal(struct { 40 | Values interface{} `json:"values"` 41 | }{ev.Changed}) 42 | case "add": 43 | return json.Marshal(struct { 44 | Value interface{} `json:"value"` 45 | Idx int `json:"idx"` 46 | }{ev.Value, ev.Idx}) 47 | case "remove": 48 | return json.Marshal(struct { 49 | Idx int `json:"idx"` 50 | }{ev.Idx}) 51 | case "delete": 52 | fallthrough 53 | case "create": 54 | fallthrough 55 | case "reaccess": 56 | return []byte("null"), nil 57 | default: 58 | return json.Marshal(ev.Payload) 59 | } 60 | } 61 | 62 | // ToResultEvents creates a slice of store result events from a slice of events. 63 | func ToResultEvents(evs []Event) []store.ResultEvent { 64 | if evs == nil { 65 | return nil 66 | } 67 | revs := make([]store.ResultEvent, len(evs)) 68 | 69 | for i, ev := range evs { 70 | var changed map[string]interface{} 71 | if ev.Changed != nil { 72 | dta, err := json.Marshal(ev.Changed) 73 | if err != nil { 74 | panic("failed to marshal changed value to a json object: " + err.Error()) 75 | } 76 | err = json.Unmarshal(dta, &changed) 77 | if err != nil { 78 | panic("failed to unmarshal changed alues to a map[string]interface{}: " + err.Error()) 79 | } 80 | } 81 | revs[i] = store.ResultEvent{ 82 | Name: ev.Name, 83 | Idx: ev.Idx, 84 | Value: ev.Value, 85 | Changed: changed, 86 | } 87 | } 88 | return revs 89 | } 90 | -------------------------------------------------------------------------------- /restest/natsrequest.go: -------------------------------------------------------------------------------- 1 | package restest 2 | 3 | import "strings" 4 | 5 | // NATSRequest represents a requests sent over NATS to the service. 6 | type NATSRequest struct { 7 | c *MockConn 8 | inb string 9 | } 10 | 11 | // NATSRequests represents a slice of requests sent over NATS to the service, 12 | // but that may get responses in undetermined order. 13 | type NATSRequests []*NATSRequest 14 | 15 | // Response gets the next pending message that is published to NATS by the 16 | // service. 17 | // 18 | // If no message is received within a set amount of time, or if the message is 19 | // not a response to the request, it will log it as a fatal error. 20 | func (nr *NATSRequest) Response() *Msg { 21 | m := nr.c.GetMsg() 22 | m.AssertSubject(nr.inb) 23 | return m 24 | } 25 | 26 | // Response gets the next pending message that is published to NATS by the 27 | // service, and matches it to one of the requests. 28 | // 29 | // If no message is received within a set amount of time, or if the message is 30 | // not a response to one of the requests, it will log it as a fatal error. 31 | // 32 | // The matching request will be set to nil. 33 | func (nrs NATSRequests) Response(c *MockConn) *Msg { 34 | m := c.GetMsg() 35 | for i := 0; i < len(nrs); i++ { 36 | nr := nrs[i] 37 | if nr != nil && nr.inb == m.Subject { 38 | nrs[i] = nil 39 | return m 40 | } 41 | } 42 | c.t.Fatalf("expected to find request matching response %s, but found none", m.Subject) 43 | return nil 44 | } 45 | 46 | // Get sends a get request to the service. 47 | // 48 | // The resource ID, rid, may contain a query part: 49 | // 50 | // test.model?q=foo 51 | func (c *MockConn) Get(rid string) *NATSRequest { 52 | rname, q := parseRID(rid) 53 | return c.Request("get."+rname, Request{Query: q}) 54 | } 55 | 56 | // Call sends a call request to the service. 57 | // 58 | // A nil req value sends a DefaultCallRequest. 59 | // 60 | // The resource ID, rid, may contain a query part: 61 | // 62 | // test.model?q=foo 63 | func (c *MockConn) Call(rid string, method string, req *Request) *NATSRequest { 64 | if req == nil { 65 | req = DefaultCallRequest() 66 | } 67 | r := *req 68 | rname, q := parseRID(rid) 69 | if q != "" { 70 | r.Query = q 71 | } 72 | return c.Request("call."+rname+"."+method, r) 73 | } 74 | 75 | // Auth sends an auth request to the service. 76 | // 77 | // A nil req value sends a DefaultAuthRequest. 78 | // 79 | // The resource ID, rid, may contain a query part: 80 | // 81 | // test.model?q=foo 82 | func (c *MockConn) Auth(rid string, method string, req *Request) *NATSRequest { 83 | if req == nil { 84 | req = DefaultAuthRequest() 85 | } 86 | r := *req 87 | rname, q := parseRID(rid) 88 | if q != "" { 89 | r.Query = q 90 | } 91 | return c.Request("auth."+rname+"."+method, r) 92 | } 93 | 94 | // Access sends an access request to the service. 95 | // 96 | // A nil req value sends a DefaultAccessRequest. 97 | // 98 | // The resource ID, rid, may contain a query part: 99 | // 100 | // test.model?q=foo 101 | func (c *MockConn) Access(rid string, req *Request) *NATSRequest { 102 | if req == nil { 103 | req = DefaultAccessRequest() 104 | } 105 | r := *req 106 | rname, q := parseRID(rid) 107 | if q != "" { 108 | r.Query = q 109 | } 110 | return c.Request("access."+rname, r) 111 | } 112 | 113 | func parseRID(rid string) (name string, query string) { 114 | i := strings.IndexByte(rid, '?') 115 | if i == -1 { 116 | return rid, "" 117 | } 118 | 119 | return rid[:i], rid[i+1:] 120 | } 121 | -------------------------------------------------------------------------------- /restest/session.go: -------------------------------------------------------------------------------- 1 | package restest 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | "time" 7 | 8 | res "github.com/jirenius/go-res" 9 | "github.com/jirenius/go-res/logger" 10 | ) 11 | 12 | // DefaultTimeoutDuration is the duration the session awaits any message before 13 | // timing out. 14 | const DefaultTimeoutDuration = 1 * time.Second 15 | 16 | // Session represents a test session with a res server 17 | type Session struct { 18 | *MockConn 19 | s *res.Service 20 | cfg *SessionConfig 21 | cl chan struct{} 22 | logPrinted bool 23 | } 24 | 25 | // SessionConfig represents the configuration for a session. 26 | type SessionConfig struct { 27 | TestName string 28 | KeepLogger bool 29 | NoReset bool 30 | ValidateReset bool 31 | ResetResources []string 32 | ResetAccess []string 33 | FailSubscription bool 34 | MockConnConfig 35 | } 36 | 37 | // NewSession creates a new Session and connects the service to a mock NATS 38 | // connection. 39 | // 40 | // A service logger will by default be set to a new MemLogger. To set any other 41 | // logger, add the option: 42 | // 43 | // WithLogger(logger) 44 | // 45 | // If the tests sends any query event, a real NATS instance is required, which 46 | // is slower than using the default mock connection. To use a real NATS 47 | // instance, add the option: 48 | // 49 | // WithGnatsd 50 | func NewSession(t *testing.T, service *res.Service, opts ...func(*SessionConfig)) *Session { 51 | cfg := &SessionConfig{ 52 | MockConnConfig: MockConnConfig{TimeoutDuration: DefaultTimeoutDuration}, 53 | } 54 | for _, opt := range opts { 55 | opt(cfg) 56 | } 57 | 58 | c := NewMockConn(t, &cfg.MockConnConfig) 59 | s := &Session{ 60 | MockConn: c, 61 | s: service, 62 | cl: make(chan struct{}), 63 | cfg: cfg, 64 | } 65 | 66 | if cfg.FailSubscription { 67 | c.FailNextSubscription() 68 | } 69 | 70 | if !cfg.KeepLogger { 71 | service.SetLogger(logger.NewMemLogger().SetTrace(true).SetFlags(log.Ltime)) 72 | } 73 | 74 | go func() { 75 | defer s.StopServer() 76 | defer close(s.cl) 77 | if err := s.s.Serve(c); err != nil { 78 | panic("test: failed to start service: " + err.Error()) 79 | } 80 | }() 81 | 82 | if !s.cfg.NoReset { 83 | msg := s.GetMsg() 84 | if msg == nil { 85 | // The channel is closed 86 | t.Fatal("expected a system.reset, but got no message") 87 | } 88 | if s.cfg.ValidateReset { 89 | msg.AssertSystemReset(cfg.ResetResources, cfg.ResetAccess) 90 | } else { 91 | msg.AssertSubject("system.reset") 92 | } 93 | } 94 | 95 | return s 96 | } 97 | 98 | // Service returns the associated res.Service. 99 | func (s *Session) Service() *res.Service { 100 | return s.s 101 | } 102 | 103 | // Close closes the session. 104 | func (s *Session) Close() error { 105 | // Check for panics 106 | e := recover() 107 | defer func() { 108 | // Re-panic 109 | if e != nil { 110 | panic(e) 111 | } 112 | }() 113 | // Output memlog if test failed or we are panicking 114 | if e != nil || s.t.Failed() { 115 | s.printLog() 116 | } 117 | 118 | // Try to shutdown the service 119 | ch := make(chan error) 120 | go func() { 121 | ch <- s.s.Shutdown() 122 | }() 123 | 124 | // Await the closing 125 | var err error 126 | select { 127 | case err = <-ch: 128 | case <-time.After(s.cfg.TimeoutDuration): 129 | s.t.Fatalf("failed to shutdown service: timeout") 130 | } 131 | return err 132 | } 133 | 134 | // WithKeepLogger sets the KeepLogger option, to prevent Session to override the 135 | // service logger with its own MemLogger. 136 | func WithKeepLogger(cfg *SessionConfig) { cfg.KeepLogger = true } 137 | 138 | // WithGnatsd sets the UseGnatsd option to use a real NATS instance. 139 | // 140 | // This option should be set if the test involves query events. 141 | func WithGnatsd(cfg *SessionConfig) { cfg.UseGnatsd = true } 142 | 143 | // WithTest sets the TestName option. 144 | // 145 | // The test name will be outputted when logging test errors. 146 | func WithTest(name string) func(*SessionConfig) { 147 | return func(cfg *SessionConfig) { cfg.TestName = name } 148 | } 149 | 150 | // WithoutReset sets the NoReset option to not expect an initial system.reset 151 | // event on server start. 152 | func WithoutReset(cfg *SessionConfig) { cfg.NoReset = true } 153 | 154 | // WithFailSubscription sets FailSubscription to make first subscription to fail. 155 | func WithFailSubscription(cfg *SessionConfig) { cfg.FailSubscription = true } 156 | 157 | // WithReset sets the ValidateReset option to validate that the system.reset 158 | // includes the specific access and resources strings. 159 | func WithReset(resources []string, access []string) func(*SessionConfig) { 160 | return func(cfg *SessionConfig) { 161 | cfg.ResetResources = resources 162 | cfg.ResetAccess = access 163 | cfg.ValidateReset = true 164 | } 165 | } 166 | 167 | func (s *Session) printLog() { 168 | if s.logPrinted { 169 | return 170 | } 171 | s.logPrinted = true 172 | if s.cfg.TestName != "" { 173 | s.t.Logf("Failed test %s", s.cfg.TestName) 174 | } 175 | // Print log if we have a MemLogger 176 | if l, ok := s.s.Logger().(*logger.MemLogger); ok { 177 | s.t.Logf("Trace log:\n%s", l) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /scripts/check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # Run from directory above via ./scripts/check.sh 3 | 4 | echo "Checking formatting..." 5 | if [ -n "$(gofmt -s -l .)" ]; then 6 | echo "Code is not formatted. Run 'gofmt -s -w .'" 7 | exit 1 8 | fi 9 | echo "Checking with go vet..." 10 | go vet ./... 11 | echo "Checking with staticcheck..." 12 | staticcheck -checks all,-ST1000 ./... 13 | echo "Checking with misspell..." 14 | misspell -error -locale US . 15 | -------------------------------------------------------------------------------- /scripts/cover.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # Run from directory above via ./scripts/cover.sh 3 | 4 | go test -v -covermode=atomic -coverprofile=./cover.out -coverpkg=. ./... 5 | go tool cover -html=cover.out 6 | -------------------------------------------------------------------------------- /scripts/install-checks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | go install honnef.co/go/tools/cmd/staticcheck@latest 4 | go install github.com/client9/misspell/cmd/misspell@latest 5 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # Run from directory above via ./scripts/lint.sh 3 | 4 | $(exit $(go fmt ./... | wc -l)) 5 | go mod tidy 6 | go vet ./... 7 | misspell -error -locale US ./... 8 | -------------------------------------------------------------------------------- /store/README.md: -------------------------------------------------------------------------------- 1 |

Resgate logo

2 |

Storage utilities for Go RES Service
Synchronize Your Clients

3 |

4 | License 5 | Reference 6 |

7 | 8 | --- 9 | 10 | Package *store* provides handlers and interfaces for working with database storage. 11 | 12 | For more details and comments on the interfaces, see the [go.dev reference](https://pkg.go.dev/github.com/jirenius/go-res/store). 13 | 14 | ## Store interface 15 | 16 | A *store* contains resources of a single type. It can be seen as a row in an Excel sheet or SQL table, or a document in a MongoDB collection. 17 | 18 | Any database can be used with a wrapper that implements the following interface: 19 | 20 | ```go 21 | // Store is a CRUD interface for storing resources of a specific type. 22 | type Store interface { 23 | Read(id string) ReadTxn 24 | Write(id string) WriteTxn 25 | OnChange(func(id string, before, after interface{})) 26 | } 27 | 28 | // ReadTxn represents a read transaction. 29 | type ReadTxn interface { 30 | ID() string 31 | Close() error 32 | Exists() bool 33 | Value() (interface{}, error) 34 | } 35 | 36 | // WriteTxn represents a write transaction. 37 | type WriteTxn interface { 38 | ReadTxn 39 | Create(interface{}) error 40 | Update(interface{}) error 41 | Delete() error 42 | } 43 | ``` 44 | 45 | ## QueryStore interface 46 | 47 | A *query store* provides the methods for making queries to an underlying database, and listen for changes that might affect the results. 48 | 49 | ```go 50 | // QueryStore is an interface for quering the resource in a store. 51 | type QueryStore interface { 52 | Query(query url.Values) (interface{}, error) 53 | OnQueryChange(func(QueryChange)) 54 | } 55 | 56 | // QueryChange represents a change to a resource that may affects queries. 57 | type QueryChange interface { 58 | ID() string 59 | Before() interface{} 60 | After() interface{} 61 | Events(q url.Values) (events []ResultEvent, reset bool, err error) 62 | } 63 | 64 | // ResultEvent represents an event on a query result. 65 | type ResultEvent struct { 66 | Name string 67 | Idx int 68 | Value interface{} 69 | Changed map[string]interface{} 70 | } 71 | ``` 72 | 73 | ## Implementations 74 | 75 | Use these examples as inspiration for your database implementation. 76 | 77 | | Name | Description | Documentation 78 | | --- | --- | --- 79 | | [mockstore](mockstore/) | Mock store implementation for testing | [![Reference][godev]](https://pkg.go.dev/github.com/jirenius/go-res/store/mockstore) 80 | | [badgerstore](badgerstore/) | BadgerDB store implementation | [![Reference][godev]](https://pkg.go.dev/github.com/jirenius/go-res/store/badgerstore) 81 | 82 | [godev]: https://img.shields.io/static/v1?label=reference&message=go.dev&color=5673ae "Reference" -------------------------------------------------------------------------------- /store/badgerstore/index.go: -------------------------------------------------------------------------------- 1 | package badgerstore 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/dgraph-io/badger" 8 | ) 9 | 10 | // Index defines an index used for a resource. 11 | // 12 | // When used on Model resource, an index entry will be added for each model entry. 13 | // An index entry will have no value (nil), and the key will have the following structure: 14 | // 15 | // :\x00 16 | // 17 | // Where: 18 | // * is the name of the Index (so keep it rather short) 19 | // * is the index value as returned from the Key callback 20 | // * is the resource ID of the indexed model 21 | type Index struct { 22 | // Index name 23 | Name string 24 | // Key callback is called with a resource item of the type defined by Type, 25 | // and should return the string to use as index value. 26 | // It does not have to be unique. 27 | // 28 | // Example index by Country and lower case Name on a user model: 29 | // func(v interface{}) { 30 | // user := v.(UserModel) 31 | // return []byte(user.Country + "_" + strings.ToLower(user.Name)) 32 | // } 33 | Key func(interface{}) []byte 34 | } 35 | 36 | // IndexQuery represents a query towards an index. 37 | type IndexQuery struct { 38 | // Index used 39 | Index Index 40 | // KeyPrefix to match against the index key 41 | KeyPrefix []byte 42 | // FilterKeys for keys in the query collection. May be nil. 43 | FilterKeys func(key []byte) bool 44 | // Offset from which item to start. 45 | Offset int 46 | // Limit how many items to read. Negative means unlimited. 47 | Limit int 48 | // Reverse flag to tell if order is reversed 49 | Reverse bool 50 | } 51 | 52 | // Byte that separates the index key prefix from the resource ID. 53 | const idSeparator = byte(0) 54 | 55 | // Max initial buffer size for results, and default size for limit set to -1. 56 | const resultBufSize = 256 57 | 58 | // Max int value. 59 | const maxInt = int(^uint(0) >> 1) 60 | 61 | func (idx Index) getKey(rname []byte, value []byte) []byte { 62 | b := make([]byte, len(idx.Name)+len(value)+len(rname)+2) 63 | copy(b, idx.Name) 64 | offset := len(idx.Name) 65 | b[offset] = ':' 66 | offset++ 67 | copy(b[offset:], value) 68 | offset += len(value) 69 | b[offset] = idSeparator 70 | copy(b[offset+1:], rname) 71 | return b 72 | } 73 | 74 | func (idx Index) getQuery(keyPrefix []byte) []byte { 75 | b := make([]byte, len(idx.Name)+len(keyPrefix)+1) 76 | copy(b, idx.Name) 77 | offset := len(idx.Name) 78 | b[offset] = ':' 79 | offset++ 80 | copy(b[offset:], keyPrefix) 81 | return b 82 | } 83 | 84 | // FetchCollection fetches a collection of resource references based on the query. 85 | func (iq *IndexQuery) FetchCollection(db *badger.DB) ([]string, error) { 86 | offset := iq.Offset 87 | limit := iq.Limit 88 | 89 | // Quick exit if we are fetching zero items 90 | if limit == 0 { 91 | return nil, nil 92 | } 93 | 94 | // Set "unlimited" limit to max int value 95 | if limit < 0 { 96 | limit = maxInt 97 | } 98 | 99 | // Prepare a slice to store the results in 100 | buf := resultBufSize 101 | if limit > 0 && limit < resultBufSize { 102 | buf = limit 103 | } 104 | result := make([]string, 0, buf) 105 | 106 | queryPrefix := iq.Index.getQuery(iq.KeyPrefix) 107 | qplen := len(queryPrefix) 108 | 109 | filter := iq.FilterKeys 110 | namelen := len(iq.Index.Name) + 1 111 | 112 | if err := db.View(func(txn *badger.Txn) error { 113 | opts := badger.DefaultIteratorOptions 114 | opts.PrefetchValues = false 115 | opts.Reverse = iq.Reverse 116 | it := txn.NewIterator(opts) 117 | defer it.Close() 118 | for it.Seek(queryPrefix); it.ValidForPrefix(queryPrefix); it.Next() { 119 | k := it.Item().Key() 120 | idx := bytes.LastIndexByte(k, idSeparator) 121 | if idx < 0 { 122 | return fmt.Errorf("index entry [%s] is invalid", k) 123 | } 124 | // Validate that a query with ?-mark isn't mistaken for a hit 125 | // when matching the ? separator for the resource ID. 126 | if qplen > idx { 127 | continue 128 | } 129 | 130 | // If we have a key filter, validate against it 131 | if filter != nil { 132 | if !filter(k[namelen:idx]) { 133 | continue 134 | } 135 | } 136 | 137 | // Skip until we reach the offset we are searching from 138 | if offset > 0 { 139 | offset-- 140 | continue 141 | } 142 | 143 | // Add resource ID reference to result 144 | result = append(result, string(k[idx+1:])) 145 | 146 | limit-- 147 | if limit == 0 { 148 | return nil 149 | } 150 | } 151 | return nil 152 | }); err != nil { 153 | return nil, err 154 | } 155 | 156 | return result, nil 157 | } 158 | -------------------------------------------------------------------------------- /store/mockstore/querystore.go: -------------------------------------------------------------------------------- 1 | package mockstore 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/jirenius/go-res/store" 7 | ) 8 | 9 | // QueryStore mocks a query store. 10 | // 11 | // It implements the store.QueryStore interface. 12 | type QueryStore struct { 13 | // OnQueryChangeCallbacks contains callbacks added with OnQueryChange. 14 | OnQueryChangeCallbacks []func(store.QueryChange) 15 | 16 | // OnQuery handles calls to Query. 17 | OnQuery func(q url.Values) (interface{}, error) 18 | } 19 | 20 | // Assert *QueryStore implements the store.QueryStore interface. 21 | var _ store.QueryStore = &QueryStore{} 22 | 23 | // Assert *QueryChange implements the store.QueryChange interface. 24 | var _ store.QueryChange = &QueryChange{} 25 | 26 | // QueryChange mocks a change in a resource that affects the query. 27 | // 28 | // It implements the store.QueryChange interface. 29 | type QueryChange struct { 30 | IDValue string 31 | BeforeValue interface{} 32 | AfterValue interface{} 33 | OnAffectsQuery func(q url.Values) bool 34 | OnEvents func(q url.Values) ([]store.ResultEvent, bool, error) 35 | } 36 | 37 | // Assert QueryChange implements the store.QueryChange interface. 38 | var _ store.QueryChange = QueryChange{} 39 | 40 | // NewQueryStore creates a new QueryStore and initializes it. 41 | func NewQueryStore(cb func(q url.Values) (interface{}, error)) *QueryStore { 42 | return &QueryStore{ 43 | OnQuery: cb, 44 | } 45 | } 46 | 47 | // Query returns a collection of references to store ID's matching 48 | // the query. If error is non-nil the reference slice is nil. 49 | func (qs *QueryStore) Query(q url.Values) (interface{}, error) { 50 | return qs.OnQuery(q) 51 | } 52 | 53 | // OnQueryChange adds a listener callback that is triggered using the 54 | // TriggerQueryChange. 55 | func (qs *QueryStore) OnQueryChange(cb func(store.QueryChange)) { 56 | qs.OnQueryChangeCallbacks = append(qs.OnQueryChangeCallbacks, cb) 57 | } 58 | 59 | // TriggerQueryChange call all OnQueryChange listeners with the QueryChange. 60 | func (qs *QueryStore) TriggerQueryChange(qc QueryChange) { 61 | for _, cb := range qs.OnQueryChangeCallbacks { 62 | cb(qc) 63 | } 64 | } 65 | 66 | // ID returns the IDValue string. 67 | func (qc QueryChange) ID() string { 68 | return qc.IDValue 69 | } 70 | 71 | // Before returns the BeforeValue. 72 | func (qc QueryChange) Before() interface{} { 73 | return qc.BeforeValue 74 | } 75 | 76 | // After returns the AfterValue. 77 | func (qc QueryChange) After() interface{} { 78 | return qc.AfterValue 79 | } 80 | 81 | // Events calls the OnEvents callback, or returns nil and false if OnEvents is 82 | // nil. 83 | func (qc QueryChange) Events(q url.Values) ([]store.ResultEvent, bool, error) { 84 | if qc.OnEvents == nil { 85 | return nil, false, nil 86 | } 87 | return qc.OnEvents(q) 88 | } 89 | -------------------------------------------------------------------------------- /store/mockstore/store.go: -------------------------------------------------------------------------------- 1 | package mockstore 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | 7 | "github.com/jirenius/go-res/store" 8 | ) 9 | 10 | // Store is an in-memory CRUD mock store implementation. 11 | // 12 | // It implements the store.Store interface. 13 | // 14 | // A Store must not be copied after first call to Read or Write. 15 | type Store struct { 16 | // OnChangeCallbacks contains callbacks added with OnChange. 17 | OnChangeCallbacks []func(id string, before, after interface{}) 18 | 19 | // RWMutex protects the Resources map. 20 | sync.RWMutex 21 | 22 | // Resources is a map of stored resources. 23 | Resources map[string]interface{} 24 | 25 | // NewID is a mock function returning an new ID when Create is called with 26 | // an empty ID. Default is that Create returns an error. 27 | NewID func() string 28 | 29 | // OnExists overrides the Exists call. Default behavior is to return true if 30 | // Resources contains the id. 31 | OnExists func(st *Store, id string) bool 32 | 33 | // OnValue overrides the Value call. Default behavior is to return the value 34 | // in Resources, or store.ErrNotFound if not found. 35 | OnValue func(st *Store, id string) (interface{}, error) 36 | 37 | // OnCreate overrides the Create call. Default behavior is to set Resources 38 | // with the value if the id does not exist, otherwise return a 39 | // store.ErrDuplicate error. 40 | OnCreate func(st *Store, id string, v interface{}) error 41 | 42 | // OnUpdate overrides the Update call. It should return the previous value, 43 | // or an error. Default behavior is to replace the Resources value if it 44 | // exists, or return store.ErrNotFound if not found. 45 | OnUpdate func(st *Store, id string, v interface{}) (interface{}, error) 46 | 47 | // OnDelete overrides the OnDelete call. It should return the deleted value, 48 | // or an error. Default behavior is to delete the Resources value if it 49 | // exists, or return store.ErrNotFound if not found. 50 | OnDelete func(st *Store, id string) (interface{}, error) 51 | } 52 | 53 | // Assert *Store implements the store.Store interface. 54 | var _ store.Store = &Store{} 55 | 56 | var errMissingID = errors.New("missing ID") 57 | 58 | type readTxn struct { 59 | st *Store 60 | id string 61 | closed bool 62 | } 63 | 64 | type writeTxn struct { 65 | readTxn 66 | } 67 | 68 | // NewStore creates a new empty Store. 69 | func NewStore() *Store { 70 | return &Store{} 71 | } 72 | 73 | // Add inserts a value into the Resources map. 74 | func (st *Store) Add(id string, v interface{}) *Store { 75 | if st.Resources == nil { 76 | st.Resources = make(map[string]interface{}, 1) 77 | } 78 | st.Resources[id] = v 79 | return st 80 | } 81 | 82 | // Read makes a read-lock for the resource that lasts until Close is called. 83 | func (st *Store) Read(id string) store.ReadTxn { 84 | st.RLock() 85 | return &readTxn{st: st, id: id} 86 | } 87 | 88 | // Write makes a write-lock for the resource that lasts until Close is called. 89 | func (st *Store) Write(id string) store.WriteTxn { 90 | st.Lock() 91 | return &writeTxn{readTxn{st: st, id: id}} 92 | } 93 | 94 | // Close closes the read transaction. 95 | func (rt *readTxn) Close() error { 96 | if rt.closed { 97 | return errors.New("already closed") 98 | } 99 | rt.closed = true 100 | rt.st.RUnlock() 101 | return nil 102 | } 103 | 104 | // Close closes the write transaction. 105 | func (wt *writeTxn) Close() error { 106 | if wt.closed { 107 | return errors.New("already closed") 108 | } 109 | wt.closed = true 110 | wt.st.Unlock() 111 | return nil 112 | } 113 | 114 | // Exists returns true if the value exists in the store, or false in case or 115 | // read error or value does not exist. 116 | func (rt readTxn) Exists() bool { 117 | if rt.id == "" { 118 | return false 119 | } 120 | 121 | if rt.st.OnExists != nil { 122 | return rt.st.OnExists(rt.st, rt.id) 123 | } 124 | 125 | _, ok := rt.st.Resources[rt.id] 126 | return ok 127 | } 128 | 129 | // Value gets an existing value in the store. 130 | // 131 | // If the value does not exist, store.ErrNotFound is returned. 132 | func (rt readTxn) Value() (interface{}, error) { 133 | if rt.id == "" { 134 | return nil, store.ErrNotFound 135 | } 136 | 137 | if rt.st.OnValue != nil { 138 | return rt.st.OnValue(rt.st, rt.id) 139 | } 140 | 141 | v, ok := rt.st.Resources[rt.id] 142 | if !ok { 143 | return nil, store.ErrNotFound 144 | } 145 | 146 | return v, nil 147 | } 148 | 149 | // ID returns the ID of the resource. 150 | func (rt readTxn) ID() string { 151 | return rt.id 152 | } 153 | 154 | // Create adds a new value to the store. 155 | // 156 | // If a value already exists for the resource ID, id, an error is returned. 157 | func (wt writeTxn) Create(v interface{}) error { 158 | if wt.id == "" { 159 | if wt.st.NewID == nil { 160 | return errMissingID 161 | } 162 | wt.id = wt.st.NewID() 163 | if wt.id == "" { 164 | panic("callback NewID returned empty string") 165 | } 166 | } 167 | 168 | var err error 169 | if wt.st.OnCreate != nil { 170 | err = wt.st.OnCreate(wt.st, wt.id, v) 171 | } else { 172 | _, ok := wt.st.Resources[wt.id] 173 | if ok { 174 | err = store.ErrDuplicate 175 | } else { 176 | if wt.st.Resources == nil { 177 | wt.st.Resources = make(map[string]interface{}) 178 | } 179 | wt.st.Resources[wt.id] = v 180 | } 181 | } 182 | 183 | if err != nil { 184 | return err 185 | } 186 | 187 | wt.st.callOnChange(wt.id, nil, v) 188 | 189 | return nil 190 | } 191 | 192 | // Update overwrites an existing value in the store with a new value, v. 193 | // 194 | // If the value does not exist, res.ErrNotFound is returned. 195 | func (wt writeTxn) Update(v interface{}) error { 196 | if wt.id == "" { 197 | return store.ErrNotFound 198 | } 199 | 200 | var err error 201 | var before interface{} 202 | var ok bool 203 | 204 | if wt.st.OnUpdate != nil { 205 | before, err = wt.st.OnUpdate(wt.st, wt.id, v) 206 | } else { 207 | before, ok = wt.st.Resources[wt.id] 208 | if !ok { 209 | err = store.ErrNotFound 210 | } else { 211 | wt.st.Resources[wt.id] = v 212 | } 213 | } 214 | 215 | if err != nil { 216 | return err 217 | } 218 | 219 | wt.st.callOnChange(wt.id, before, v) 220 | return nil 221 | } 222 | 223 | // Delete removes an existing value from the store. 224 | // 225 | // If the value does not exist, res.ErrNotFound is returned. 226 | func (wt writeTxn) Delete() error { 227 | if wt.id == "" { 228 | return store.ErrNotFound 229 | } 230 | 231 | var err error 232 | var before interface{} 233 | var ok bool 234 | 235 | if wt.st.OnDelete != nil { 236 | before, err = wt.st.OnDelete(wt.st, wt.id) 237 | } else { 238 | before, ok = wt.st.Resources[wt.id] 239 | if !ok { 240 | err = store.ErrNotFound 241 | } else { 242 | delete(wt.st.Resources, wt.id) 243 | } 244 | } 245 | 246 | if err != nil { 247 | return err 248 | } 249 | 250 | wt.st.callOnChange(wt.id, before, nil) 251 | return nil 252 | } 253 | 254 | // OnChange adds a listener callback that is called whenever a value is created, 255 | // updated, or deleted from the store. 256 | // 257 | // If a value is created, before will be set to nil. 258 | // 259 | // If a value is deleted, after will be set to nil. 260 | func (st *Store) OnChange(cb func(id string, before, after interface{})) { 261 | st.OnChangeCallbacks = append(st.OnChangeCallbacks, cb) 262 | } 263 | 264 | // callOnChange loops through OnChange listeners and calls them. 265 | func (st *Store) callOnChange(id string, before, after interface{}) { 266 | for _, cb := range st.OnChangeCallbacks { 267 | cb(id, before, after) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/jirenius/go-res" 7 | ) 8 | 9 | // Predefined errors 10 | var ( 11 | ErrNotFound = res.ErrNotFound 12 | ErrDuplicate = &res.Error{Code: res.CodeInvalidParams, Message: "Duplicate resource"} 13 | ) 14 | 15 | // Store is a CRUD interface for storing resources of a specific type. The 16 | // resources are identified by a unique ID string. 17 | // 18 | // The value type returned by ReadTxn.Value, and passed to the OnChange 19 | // callback, is determined by the Store, and remains the same for all calls. The 20 | // Store expects that the same type is also passed as values to the 21 | // WriteTxn.Create and WriteTxn.Update methods. 22 | type Store interface { 23 | // Read will return a read transaction once there are no open write 24 | // transaction for the same resource. The transaction will last until Close 25 | // is called. 26 | Read(id string) ReadTxn 27 | 28 | // Write will return a write transaction once there are no open read or 29 | // write transactions for the same resource. The transaction will last until 30 | // Close is called. 31 | // 32 | // If the Store implementation does not support the caller generating its 33 | // own ID for resource creation, the implementation's Write method may 34 | // accept an empty ID string. In such case, any call to WriteTxn.Value, 35 | // WriteTxn.Update, and WriteTxn.Delete returns ErrNotFound (or an error 36 | // that wraps ErrNotFound), until WriteTxn.Create has been called. After 37 | // Create is called, the ID method should returns the new ID. If the Store 38 | // implementation does not support generating new IDs, a call to 39 | // WriteTxn.Create with an empty ID returns an error. 40 | Write(id string) WriteTxn 41 | 42 | // OnChange registers a callback that is called whenever a resource has been 43 | // modified. The callback parameters describes the ID of the modified 44 | // resource, and the value before and after modification. 45 | // 46 | // If the before-value is nil, the resource was created. If the after-value 47 | // is nil, the resource was deleted. 48 | OnChange(func(id string, before, after interface{})) 49 | } 50 | 51 | // ReadTxn represents a read transaction. 52 | type ReadTxn interface { 53 | // ID returns the ID string of the resource. 54 | ID() string 55 | 56 | // Close closes the transaction, rendering it unusable for any subsequent 57 | // calls. Close will return an error if it has already been called. 58 | Close() error 59 | 60 | // Exists returns true if the value exists, or false on read error or if the 61 | // resource does not exist. 62 | Exists() bool 63 | 64 | // Value returns the stored value. Value returns ErrNotFound (or an error 65 | // that wraps ErrNotFound), if a resource with the provided ID does not 66 | // exist in the store. 67 | Value() (interface{}, error) 68 | } 69 | 70 | // WriteTxn represents a write transaction. 71 | type WriteTxn interface { 72 | ReadTxn 73 | 74 | // Create adds a new value to the store. 75 | // 76 | // If a resource with the same ID already exists in the store, or if a 77 | // unique index is violated, Create returns ErrDuplicate (or an error that 78 | // wraps ErrDuplicate). 79 | // 80 | // If the value is successfully created, the Store OnChange callbacks will 81 | // be triggered on the calling goroutine with the before-value set to nil. 82 | Create(interface{}) error 83 | 84 | // Update replaces an existing value in the store. 85 | // 86 | // If the value does not exist, Update returns ErrNotFound (or an error that 87 | // wraps ErrNotFound). 88 | // 89 | // If the value is successfully updated, the Store OnChange callbacks will 90 | // be triggered on the calling goroutine. 91 | Update(interface{}) error 92 | 93 | // Delete deletes an existing value from the store. 94 | // 95 | // If the value does not exist, Delete returns ErrNotFound (or an error that 96 | // wraps ErrNotFound). 97 | // 98 | // If the value is successfully deleted, the Store OnChange callbacks will 99 | // be triggered on the calling goroutine with the after-value set to nil. 100 | Delete() error 101 | } 102 | 103 | // QueryStore is an interface for quering the resource in a store. 104 | type QueryStore interface { 105 | // Query returns a result based on the provided query values. 106 | // 107 | // The result type is determined by the QueryStore implementation, and must 108 | // remain the same for all calls regardless of query values. If error is 109 | // non-nil the returned interface{} is nil. 110 | Query(query url.Values) (interface{}, error) 111 | 112 | // OnQueryChange registers a callback that is called whenever a change to a 113 | // reasource has occurred that may affect the results returned by Query. 114 | OnQueryChange(func(QueryChange)) 115 | } 116 | 117 | // QueryChange represents a change to a resource that may affects queries. 118 | type QueryChange interface { 119 | // ID returns the ID of the changed resource triggering the event. 120 | ID() string 121 | 122 | // Before returns the resource value before the change. The value type is 123 | // defined by the underlying store. If the resource was created, Before will 124 | // return nil. 125 | Before() interface{} 126 | 127 | // After returns the resource value after the change. The value type is 128 | // defined by the underlying store. If the resource was deleted, After will 129 | // return nil. 130 | After() interface{} 131 | 132 | // Events returns a list of events that describes mutations of the results, 133 | // caused by the change, for a given query. 134 | // 135 | // If the query result is a collection, where the change caused a value to 136 | // move position, the "remove" event should come prior to the "add" event. 137 | // 138 | // The QueryStore implementation may return zero or nil events, even if the 139 | // query may be affected by the change, but must then have the returned 140 | // reset flag set to true. 141 | Events(q url.Values) (events []ResultEvent, reset bool, err error) 142 | } 143 | 144 | // ResultEvent represents an event on a query result. 145 | // 146 | // See: https://resgate.io/docs/specification/res-service-protocol/#events 147 | type ResultEvent struct { 148 | // Name of the event. 149 | Name string 150 | 151 | // Index position where the resource is added or removed from the query 152 | // result. 153 | // 154 | // Only valid for "add" and "remove" events. 155 | Idx int 156 | 157 | // ID of resource being added or removed from the query result. 158 | // 159 | // Only valid for "add" and "remove" events. 160 | Value interface{} 161 | 162 | // Changed property values for the model emitting the event. 163 | // 164 | // Only valid for "change" events. 165 | Changed map[string]interface{} 166 | } 167 | 168 | // Transformer is an interface with methods to transform a stored resource into 169 | // a resource served by the service. 170 | type Transformer interface { 171 | // RIDToID transforms an external resource ID to the internal ID, used by 172 | // the store. An empty ID will be interpreted as resource not found. 173 | RIDToID(rid string, pathParams map[string]string) string 174 | 175 | // IDToRID transforms an internal ID, used by the store, to an external 176 | // resource ID. Pattern is the full pattern for the resource ID. 177 | // 178 | // An empty RID will be interpreted as resource not found. 179 | IDToRID(id string, v interface{}, pattern res.Pattern) string 180 | 181 | // Transform transforms an internal value, persisted in the store, to an 182 | // external resource to send to the requesting client. 183 | Transform(id string, v interface{}) (interface{}, error) 184 | } 185 | 186 | // QueryTransformer is an interface with methods to transform and validate an 187 | // incoming query so that it can be passed to a QueryStore. And transforming the 188 | // results so that it can be returned as an external resource. 189 | type QueryTransformer interface { 190 | // TransformResults transforms a query result into an external resource to 191 | // send to the requesting client. 192 | TransformResult(v interface{}) (interface{}, error) 193 | 194 | // TransformEvents transform events, as returned from QueryChange.Events 195 | // into events for the external resource. 196 | TransformEvents(events []ResultEvent) ([]ResultEvent, error) 197 | } 198 | -------------------------------------------------------------------------------- /store/transformer.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | res "github.com/jirenius/go-res" 8 | ) 9 | 10 | // TransformFuncs implements the Transformer interface by calling the functions 11 | // for transforming store requests. 12 | type transformer struct { 13 | ridToID func(rid string, pathParams map[string]string) string 14 | idToRID func(id string, v interface{}, p res.Pattern) string 15 | transform func(id string, v interface{}) (interface{}, error) 16 | } 17 | 18 | var _ Transformer = transformer{} 19 | 20 | // TransformFuncs returns a Transformer that uses the provided functions. Any 21 | // nil function will pass the value untransformed. 22 | func TransformFuncs(ridToID func(rid string, pathParams map[string]string) string, idToRID func(id string, v interface{}, p res.Pattern) string, transform func(id string, v interface{}) (interface{}, error)) Transformer { 23 | return transformer{ 24 | ridToID: ridToID, 25 | idToRID: idToRID, 26 | transform: transform, 27 | } 28 | } 29 | 30 | func (t transformer) RIDToID(rid string, pathParams map[string]string) string { 31 | if t.ridToID == nil { 32 | return rid 33 | } 34 | return t.ridToID(rid, pathParams) 35 | } 36 | 37 | func (t transformer) IDToRID(id string, v interface{}, p res.Pattern) string { 38 | if t.idToRID == nil { 39 | return id 40 | } 41 | return t.idToRID(id, v, p) 42 | } 43 | 44 | func (t transformer) Transform(id string, v interface{}) (interface{}, error) { 45 | if t.transform == nil { 46 | return v, nil 47 | } 48 | return t.transform(id, v) 49 | } 50 | 51 | // IDTransformer returns a transformer where the resource ID contains a single 52 | // tag that is the internal ID. 53 | // 54 | // // Assuming pattern is "library.book.$bookid" 55 | // IDTransformer("bookId", nil) // transforms "library.book.42" <=> "42" 56 | func IDTransformer(tagName string, transform func(id string, v interface{}) (interface{}, error)) Transformer { 57 | return TransformFuncs( 58 | func(_ string, pathParams map[string]string) string { 59 | return pathParams[string(tagName)] 60 | }, 61 | func(id string, _ interface{}, p res.Pattern) string { 62 | return string(p.ReplaceTag(string(tagName), id)) 63 | }, 64 | transform, 65 | ) 66 | } 67 | 68 | // IDToRIDCollectionTransformer is a QueryTransformer that handles the common 69 | // case of transforming a slice of id strings: 70 | // 71 | // []string{"1", "2"} 72 | // 73 | // into slice of resource references: 74 | // 75 | // []res.Ref{"library.book.1", "library.book.2"} 76 | // 77 | // The function converts a single ID returned by a the store into an external 78 | // resource ID. 79 | type IDToRIDCollectionTransformer func(id string) string 80 | 81 | // TransformResult transforms a slice of id strings into a slice of resource 82 | // references. 83 | func (t IDToRIDCollectionTransformer) TransformResult(v interface{}) (interface{}, error) { 84 | ids, ok := v.([]string) 85 | if !ok { 86 | return nil, fmt.Errorf("failed to transform results: expected value of type []string, but got %s", reflect.TypeOf(v)) 87 | } 88 | refs := make([]res.Ref, len(ids)) 89 | for i, id := range ids { 90 | refs[i] = res.Ref(t(id)) 91 | } 92 | return refs, nil 93 | } 94 | 95 | // TransformEvents transforms events for a []string collection into events for a 96 | // []res.Ref collection. 97 | func (t IDToRIDCollectionTransformer) TransformEvents(evs []ResultEvent) ([]ResultEvent, error) { 98 | for i, ev := range evs { 99 | if ev.Name == "add" { 100 | id, ok := ev.Value.(string) 101 | if !ok { 102 | return nil, fmt.Errorf("failed to transform add event: expected value of type string, but got %s", reflect.TypeOf(ev.Value)) 103 | } 104 | evs[i].Value = res.Ref(t(id)) 105 | } 106 | } 107 | return evs, nil 108 | } 109 | 110 | // IDToRIDModelTransformer is a QueryTransformer that handles the common case of 111 | // transforming a slice of unique id strings: 112 | // 113 | // []string{"1", "2"} 114 | // 115 | // into a map of resource references with id as key: 116 | // 117 | // map[string]res.Ref{"1": "library.book.1", "2": "library.book.2"} 118 | // 119 | // The function converts a single ID returned by a the store into an external 120 | // resource ID. 121 | // 122 | // The behavior is undefined for slices containing duplicate id string. 123 | type IDToRIDModelTransformer func(id string) string 124 | 125 | // TransformResult transforms a slice of id strings into a map of resource 126 | // references with id as key. 127 | func (t IDToRIDModelTransformer) TransformResult(v interface{}) (interface{}, error) { 128 | ids, ok := v.([]string) 129 | if !ok { 130 | return nil, fmt.Errorf("failed to transform results: expected value of type []string, but got %s", reflect.TypeOf(v)) 131 | } 132 | refs := make(map[string]res.Ref, len(ids)) 133 | for _, id := range ids { 134 | refs[id] = res.Ref(t(id)) 135 | } 136 | return refs, nil 137 | } 138 | 139 | // TransformEvents transforms events for a []string collection into events for a 140 | // map[string]res.Ref model. 141 | func (t IDToRIDModelTransformer) TransformEvents(evs []ResultEvent) ([]ResultEvent, error) { 142 | if len(evs) == 0 { 143 | return evs, nil 144 | } 145 | ch := make(map[string]interface{}, len(evs)) 146 | for _, ev := range evs { 147 | switch ev.Name { 148 | case "add": 149 | id, ok := ev.Value.(string) 150 | if !ok { 151 | return nil, fmt.Errorf("failed to transform add event: expected value of type string, but got %s", reflect.TypeOf(ev.Value)) 152 | } 153 | ch[id] = res.Ref(t(id)) 154 | case "remove": 155 | id, ok := ev.Value.(string) 156 | if !ok { 157 | return nil, fmt.Errorf("failed to transform remove event: expected value of type string, but got %s", reflect.TypeOf(ev.Value)) 158 | } 159 | ch[id] = res.DeleteAction 160 | } 161 | } 162 | return []ResultEvent{{ 163 | Name: "change", 164 | Changed: ch, 165 | }}, nil 166 | } 167 | -------------------------------------------------------------------------------- /store/value.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | 8 | res "github.com/jirenius/go-res" 9 | ) 10 | 11 | // ValueType is an enum reprenting the value type 12 | type ValueType byte 13 | 14 | // Value type constants 15 | const ( 16 | ValueTypeNone ValueType = iota 17 | ValueTypePrimitive 18 | ValueTypeReference 19 | ValueTypeSoftReference 20 | ValueTypeData 21 | ValueTypeDelete 22 | 23 | // Deprecated and replaced with ValueTypeReference. 24 | ValueTypeResource = ValueTypeReference 25 | ) 26 | 27 | // valueObject represents a resource reference or an action 28 | type valueObject struct { 29 | RID *string `json:"rid"` 30 | Soft bool `json:"soft"` 31 | Action *string `json:"action"` 32 | Data json.RawMessage `json:"data"` 33 | } 34 | 35 | // DeleteValue is a predeclared delete action value 36 | var DeleteValue = Value{ 37 | RawMessage: json.RawMessage(`{"action":"delete"}`), 38 | Type: ValueTypeDelete, 39 | } 40 | 41 | // Value represents a RES value 42 | // https://github.com/resgateio/resgate/blob/master/docs/res-protocol.md#values 43 | type Value struct { 44 | json.RawMessage 45 | Type ValueType 46 | RID string 47 | Inner json.RawMessage 48 | } 49 | 50 | var errInvalidValue = errors.New("invalid value") 51 | 52 | const ( 53 | actionDelete = "delete" 54 | ) 55 | 56 | // UnmarshalJSON sets *v to the RES value represented by the JSON encoded data 57 | func (v *Value) UnmarshalJSON(data []byte) error { 58 | err := v.RawMessage.UnmarshalJSON(data) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | // Get first non-whitespace character 64 | var c byte 65 | i := 0 66 | for { 67 | c = v.RawMessage[i] 68 | if c != 0x20 && c != 0x09 && c != 0x0A && c != 0x0D { 69 | break 70 | } 71 | i++ 72 | } 73 | 74 | switch c { 75 | case '{': 76 | var mvo valueObject 77 | err = json.Unmarshal(v.RawMessage, &mvo) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | switch { 83 | case mvo.RID != nil: 84 | // Invalid to have both RID and Action or Data set, or if RID is empty 85 | if mvo.Action != nil || mvo.Data != nil || *mvo.RID == "" { 86 | return errInvalidValue 87 | } 88 | v.RID = *mvo.RID 89 | if !res.Ref(v.RID).IsValid() { 90 | return errInvalidValue 91 | } 92 | if mvo.Soft { 93 | v.Type = ValueTypeSoftReference 94 | } else { 95 | v.Type = ValueTypeReference 96 | } 97 | 98 | case mvo.Action != nil: 99 | // Invalid to have both Action and Data set, or if action is not actionDelete 100 | if mvo.Data != nil || *mvo.Action != actionDelete { 101 | return errInvalidValue 102 | } 103 | v.Type = ValueTypeDelete 104 | 105 | case mvo.Data != nil: 106 | v.Inner = mvo.Data 107 | dc := mvo.Data[0] 108 | // Is data containing a primitive? 109 | if dc == '{' || dc == '[' { 110 | v.Type = ValueTypeData 111 | } else { 112 | v.RawMessage = mvo.Data 113 | v.Type = ValueTypePrimitive 114 | } 115 | 116 | default: 117 | return errInvalidValue 118 | } 119 | case '[': 120 | return errInvalidValue 121 | default: 122 | v.Type = ValueTypePrimitive 123 | } 124 | 125 | return nil 126 | } 127 | 128 | // MarshalJSON returns the embedded json.RawMessage as the JSON encoding. 129 | func (v Value) MarshalJSON() ([]byte, error) { 130 | if v.RawMessage == nil { 131 | return []byte("null"), nil 132 | } 133 | return v.RawMessage, nil 134 | } 135 | 136 | // Equal reports whether v and w is equal in type and value 137 | func (v Value) Equal(w Value) bool { 138 | if v.Type != w.Type { 139 | return false 140 | } 141 | 142 | switch v.Type { 143 | case ValueTypeData: 144 | return bytes.Equal(v.Inner, w.Inner) 145 | case ValueTypePrimitive: 146 | return bytes.Equal(v.RawMessage, w.RawMessage) 147 | case ValueTypeReference: 148 | fallthrough 149 | case ValueTypeSoftReference: 150 | return v.RID == w.RID 151 | } 152 | 153 | return true 154 | } 155 | -------------------------------------------------------------------------------- /test/01register_handler_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/jirenius/go-res" 7 | "github.com/jirenius/go-res/restest" 8 | ) 9 | 10 | // Test that the service can serve a handler without error 11 | func TestRegisterModelHandler(t *testing.T) { 12 | runTest(t, func(s *res.Service) { 13 | s.Handle("model", res.GetModel(func(r res.ModelRequest) { r.NotFound() })) 14 | }, func(s *restest.Session) { 15 | s.AssertQueueSubscription("get.test", "test") 16 | s.AssertQueueSubscription("get.test.>", "test") 17 | s.AssertQueueSubscription("call.test.>", "test") 18 | s.AssertNoSubscription("call.test") 19 | s.AssertQueueSubscription("auth.test.>", "test") 20 | s.AssertNoSubscription("auth.test") 21 | s.AssertNoSubscription("access.test.>") 22 | s.AssertNoSubscription("access.test") 23 | }, restest.WithReset([]string{"test", "test.>"}, nil)) 24 | } 25 | 26 | // Test that the access methods are subscribed to when handler 27 | // with an access handler function is registered 28 | func TestRegisterAccessHandler(t *testing.T) { 29 | runTest(t, func(s *res.Service) { 30 | s.Handle("model", res.Access(res.AccessGranted)) 31 | }, func(s *restest.Session) { 32 | s.AssertNoSubscription("get.test") 33 | s.AssertNoSubscription("get.test.>") 34 | s.AssertNoSubscription("call.test.>") 35 | s.AssertNoSubscription("call.test") 36 | s.AssertNoSubscription("auth.test.>") 37 | s.AssertNoSubscription("auth.test") 38 | s.AssertQueueSubscription("access.test.>", "test") 39 | s.AssertQueueSubscription("access.test", "test") 40 | }, restest.WithReset(nil, []string{"test", "test.>"})) 41 | } 42 | 43 | // Test that the resource and access methods are subscribed to when 44 | // both resource and access handler function is registered 45 | func TestRegisterModelAndAccessHandler(t *testing.T) { 46 | runTest(t, func(s *res.Service) { 47 | s.Handle("model", 48 | res.GetModel(func(r res.ModelRequest) { r.NotFound() }), 49 | res.Access(res.AccessGranted), 50 | ) 51 | }, func(s *restest.Session) { 52 | s.AssertQueueSubscription("get.test", "test") 53 | s.AssertQueueSubscription("get.test.>", "test") 54 | s.AssertQueueSubscription("call.test.>", "test") 55 | s.AssertNoSubscription("call.test") 56 | s.AssertQueueSubscription("auth.test.>", "test") 57 | s.AssertNoSubscription("auth.test") 58 | s.AssertQueueSubscription("access.test.>", "test") 59 | s.AssertQueueSubscription("access.test", "test") 60 | }, restest.WithReset([]string{"test", "test.>"}, []string{"test", "test.>"})) 61 | } 62 | 63 | // Test that registering both a model and collection handler results 64 | // in a panic 65 | func TestPanicOnMultipleGetHandlers(t *testing.T) { 66 | defer func() { 67 | v := recover() 68 | if v == nil { 69 | t.Fatalf("expected a panic, but nothing happened") 70 | } 71 | }() 72 | 73 | runTest(t, func(s *res.Service) { 74 | s.Handle("model", 75 | res.GetModel(func(r res.ModelRequest) { 76 | r.NotFound() 77 | }), 78 | res.GetCollection(func(r res.CollectionRequest) { 79 | r.NotFound() 80 | }), 81 | ) 82 | }, nil) 83 | } 84 | 85 | // Test that making invalid pattern registration causes panic 86 | func TestPanicOnInvalidPatternRegistration(t *testing.T) { 87 | 88 | tbl := [][]string{ 89 | {"model.$id.type.$id"}, 90 | {"model.foo", "model.foo"}, 91 | {"model..foo"}, 92 | {"model.$"}, 93 | {"model.$.foo"}, 94 | {"model.>.foo"}, 95 | {"model.foo.>bar"}, 96 | } 97 | 98 | for _, l := range tbl { 99 | runTest(t, func(s *res.Service) { 100 | defer func() { 101 | v := recover() 102 | if v == nil { 103 | t.Fatalf("expected a panic, but nothing happened") 104 | } 105 | }() 106 | 107 | for _, p := range l { 108 | s.Handle(p) 109 | } 110 | }, nil, restest.WithoutReset) 111 | } 112 | } 113 | 114 | func TestHandler_InvalidHandlerOption_CausesPanic(t *testing.T) { 115 | tbl := []func(){ 116 | func() { res.Call("foo.bar", func(r res.CallRequest) {}) }, 117 | func() { res.Auth("foo.bar", func(r res.AuthRequest) {}) }, 118 | } 119 | 120 | for _, l := range tbl { 121 | runTest(t, func(s *res.Service) { 122 | restest.AssertPanic(t, func() { 123 | l() 124 | }) 125 | }, nil, restest.WithoutReset) 126 | } 127 | } 128 | 129 | func TestHandler_InvalidHandlerOptions_CausesPanic(t *testing.T) { 130 | tbl := [][]res.Option{ 131 | {res.Model, res.Model}, 132 | {res.Collection, res.Collection}, 133 | {res.Model, res.Collection}, 134 | {res.Collection, res.Model}, 135 | {res.ApplyChange(func(r res.Resource, c map[string]interface{}) (map[string]interface{}, error) { return nil, nil }), res.ApplyChange(func(r res.Resource, c map[string]interface{}) (map[string]interface{}, error) { return nil, nil })}, 136 | {res.ApplyAdd(func(r res.Resource, v interface{}, idx int) error { return nil }), res.ApplyAdd(func(r res.Resource, v interface{}, idx int) error { return nil })}, 137 | {res.ApplyRemove(func(r res.Resource, idx int) (interface{}, error) { return nil, nil }), res.ApplyRemove(func(r res.Resource, idx int) (interface{}, error) { return nil, nil })}, 138 | {res.ApplyCreate(func(r res.Resource, v interface{}) error { return nil }), res.ApplyCreate(func(r res.Resource, v interface{}) error { return nil })}, 139 | {res.ApplyDelete(func(r res.Resource) (interface{}, error) { return nil, nil }), res.ApplyDelete(func(r res.Resource) (interface{}, error) { return nil, nil })}, 140 | } 141 | 142 | for _, l := range tbl { 143 | runTest(t, func(s *res.Service) { 144 | restest.AssertPanic(t, func() { 145 | s.Handle("model", l...) 146 | }) 147 | }, nil, restest.WithoutReset) 148 | } 149 | } 150 | 151 | func TestSetQueueGroup_WithDifferentGroup_RegisterModelAndAccessHandlerOnGroup(t *testing.T) { 152 | runTest(t, func(s *res.Service) { 153 | s.SetQueueGroup("foo"). 154 | Handle("model", 155 | res.GetModel(func(r res.ModelRequest) { r.NotFound() }), 156 | res.Access(res.AccessGranted), 157 | ) 158 | }, func(s *restest.Session) { 159 | s.AssertQueueSubscription("get.test", "foo") 160 | s.AssertQueueSubscription("get.test.>", "foo") 161 | s.AssertQueueSubscription("call.test.>", "foo") 162 | s.AssertNoSubscription("call.test") 163 | s.AssertQueueSubscription("auth.test.>", "foo") 164 | s.AssertNoSubscription("auth.test") 165 | s.AssertQueueSubscription("access.test.>", "foo") 166 | s.AssertQueueSubscription("access.test", "foo") 167 | }, restest.WithReset([]string{"test", "test.>"}, []string{"test", "test.>"})) 168 | } 169 | 170 | func TestSetQueueGroup_WithEmptyGroup_RegisterModelAndAccessHandlerWithoutGroup(t *testing.T) { 171 | runTest(t, func(s *res.Service) { 172 | s.SetQueueGroup(""). 173 | Handle("model", 174 | res.GetModel(func(r res.ModelRequest) { r.NotFound() }), 175 | res.Access(res.AccessGranted), 176 | ) 177 | }, func(s *restest.Session) { 178 | s.AssertQueueSubscription("get.test", "") 179 | s.AssertQueueSubscription("get.test.>", "") 180 | s.AssertQueueSubscription("call.test.>", "") 181 | s.AssertNoSubscription("call.test") 182 | s.AssertQueueSubscription("auth.test.>", "") 183 | s.AssertNoSubscription("auth.test") 184 | s.AssertQueueSubscription("access.test.>", "") 185 | s.AssertQueueSubscription("access.test", "") 186 | }, restest.WithReset([]string{"test", "test.>"}, []string{"test", "test.>"})) 187 | } 188 | -------------------------------------------------------------------------------- /test/07resource_request_methods_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | 8 | res "github.com/jirenius/go-res" 9 | "github.com/jirenius/go-res/restest" 10 | ) 11 | 12 | var resourceRequestTestTbl = []struct { 13 | Pattern string 14 | ResourceName string 15 | Query string 16 | }{ 17 | // Simple RID 18 | {"model", "test.model", ""}, 19 | {"model.foo", "test.model.foo", ""}, 20 | {"model.foo.bar", "test.model.foo.bar", ""}, 21 | // Pattern with placeholders 22 | {"model.$id", "test.model.42", ""}, 23 | {"model.$id.bar", "test.model.foo.bar", ""}, 24 | {"model.$id.bar.$type", "test.model.foo.bar.baz", ""}, 25 | // Pattern with full wild card 26 | {"model.>", "test.model.42", ""}, 27 | {"model.>", "test.model.foo.42", ""}, 28 | {"model.$id.>", "test.model.foo.bar", ""}, 29 | {"model.$id.>", "test.model.foo.bar.42", ""}, 30 | {"model.foo.>", "test.model.foo.bar", ""}, 31 | {"model.foo.>", "test.model.foo.bar.42", ""}, 32 | // RID with query 33 | {"model", "test.model", "foo=bar"}, 34 | {"model.foo", "test.model.foo", "bar.baz=zoo.42"}, 35 | {"model.foo.bar", "test.model.foo.bar", "foo=?bar*.>zoo"}, 36 | } 37 | 38 | var resourceRequestQueryTestTbl = []struct { 39 | Query string 40 | ExpectedQuery json.RawMessage 41 | }{ 42 | {"foo=bar", json.RawMessage(`{"foo":["bar"]}`)}, 43 | {"foo=bar&baz=42", json.RawMessage(`{"foo":["bar"],"baz":["42"]}`)}, 44 | {"foo=bar&foo=baz", json.RawMessage(`{"foo":["bar","baz"]}`)}, 45 | {"foo[0]=bar&foo[1]=baz", json.RawMessage(`{"foo[0]":["bar"],"foo[1]":["baz"]}`)}, 46 | } 47 | 48 | // Test Service method returns the service instance 49 | func TestServiceMethod(t *testing.T) { 50 | runTest(t, func(s *res.Service) { 51 | s.Handle("model", res.GetModel(func(r res.ModelRequest) { 52 | if r.Service() != s { 53 | t.Errorf("expected resource request Service() to return the service instance, but it didn't") 54 | } 55 | r.NotFound() 56 | })) 57 | }, func(s *restest.Session) { 58 | // Test getting the model 59 | s.Get("test.model").Response() 60 | }) 61 | } 62 | 63 | // Test Service method returns the service instance using With 64 | func TestServiceMethodUsingWith(t *testing.T) { 65 | runTestAsync(t, func(s *res.Service) { 66 | s.Handle("model", res.GetResource(func(r res.GetRequest) { r.NotFound() })) 67 | }, func(s *restest.Session, done func()) { 68 | restest.AssertNoError(t, s.Service().With("test.model", func(r res.Resource) { 69 | if r.Service() != s.Service() { 70 | t.Errorf("expected resource Service() to return the service instance, but it didn't") 71 | } 72 | done() 73 | })) 74 | }) 75 | } 76 | 77 | // Test Resource and Query method returns the resource name and query. 78 | func TestResourceNameAndQuery(t *testing.T) { 79 | for _, l := range resourceRequestTestTbl { 80 | runTest(t, func(s *res.Service) { 81 | s.Handle(l.Pattern, res.GetModel(func(r res.ModelRequest) { 82 | rid := l.ResourceName 83 | if l.Query != "" { 84 | rid += "?" + l.Query 85 | } 86 | rname := r.ResourceName() 87 | if rname != l.ResourceName { 88 | t.Errorf("expected ResourceName for RID %#v to be %#v, but got %#v", rid, l.ResourceName, rname) 89 | } 90 | q := r.Query() 91 | if q != l.Query { 92 | t.Errorf("expected Query for RID %#v to be %#v, but got %#v", rid, l.Query, q) 93 | } 94 | r.NotFound() 95 | })) 96 | }, func(s *restest.Session) { 97 | // Test getting the model 98 | s.Get(l.ResourceName + "?" + l.Query). 99 | Response(). 100 | AssertError(res.ErrNotFound) 101 | }) 102 | } 103 | } 104 | 105 | // Test Resource and Query method returns the resource name and query when using With 106 | func TestResourceNameAndQueryUsingWith(t *testing.T) { 107 | for _, l := range resourceRequestTestTbl { 108 | runTestAsync(t, func(s *res.Service) { 109 | s.Handle(l.Pattern, res.GetResource(func(r res.GetRequest) { r.NotFound() })) 110 | }, func(s *restest.Session, done func()) { 111 | rid := l.ResourceName 112 | if l.Query != "" { 113 | rid += "?" + l.Query 114 | } 115 | restest.AssertNoError(t, s.Service().With(rid, func(r res.Resource) { 116 | rname := r.ResourceName() 117 | if rname != l.ResourceName { 118 | t.Errorf("expected ResourceName for RID %#v to be %#v, but got %#v", rid, l.ResourceName, rname) 119 | } 120 | q := r.Query() 121 | if q != l.Query { 122 | t.Errorf("expected Query for RID %#v to be %#v, but got %#v", rid, l.Query, q) 123 | } 124 | done() 125 | })) 126 | }) 127 | } 128 | } 129 | 130 | // Test ParseQuery method parses the query and returns the corresponding values. 131 | func TestParseQuery(t *testing.T) { 132 | for _, l := range resourceRequestQueryTestTbl { 133 | runTestAsync(t, func(s *res.Service) { 134 | s.Handle("model", res.GetResource(func(r res.GetRequest) { r.NotFound() })) 135 | }, func(s *restest.Session, done func()) { 136 | rid := "test.model?" + l.Query 137 | restest.AssertNoError(t, s.Service().With(rid, func(r res.Resource) { 138 | pq := r.ParseQuery() 139 | restest.AssertEqualJSON(t, fmt.Sprintf("Query for %#v", rid), pq, l.ExpectedQuery) 140 | done() 141 | })) 142 | }) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/08resource_request_path_params_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | res "github.com/jirenius/go-res" 7 | "github.com/jirenius/go-res/restest" 8 | ) 9 | 10 | var resourceRequestPathParamsTestTbl = []struct { 11 | Pattern string 12 | ResourceName string 13 | Expected map[string]string 14 | }{ 15 | {"model", "test.model", nil}, 16 | {"model.$id", "test.model.42", map[string]string{"id": "42"}}, 17 | {"model.$type.$id.foo", "test.model.user.42.foo", map[string]string{"type": "user", "id": "42"}}, 18 | {"model.$id.bar", "test.model.foo.bar", map[string]string{"id": "foo"}}, 19 | } 20 | 21 | // Test PathParams method returns parameters derived from the resource ID. 22 | func TestPathParams(t *testing.T) { 23 | for _, l := range resourceRequestPathParamsTestTbl { 24 | runTest(t, func(s *res.Service) { 25 | s.Handle(l.Pattern, res.GetModel(func(r res.ModelRequest) { 26 | pp := r.PathParams() 27 | restest.AssertEqualJSON(t, "PathParams", pp, l.Expected) 28 | r.NotFound() 29 | })) 30 | }, func(s *restest.Session) { 31 | s.Get(l.ResourceName). 32 | Response(). 33 | AssertError(res.ErrNotFound) 34 | }) 35 | } 36 | } 37 | 38 | // Test PathParams method returns parameters derived from the resource ID using With. 39 | func TestPathParamsUsingWith(t *testing.T) { 40 | for _, l := range resourceRequestPathParamsTestTbl { 41 | runTestAsync(t, func(s *res.Service) { 42 | s.Handle(l.Pattern, res.GetResource(func(r res.GetRequest) { r.NotFound() })) 43 | }, func(s *restest.Session, done func()) { 44 | restest.AssertNoError(t, s.Service().With(l.ResourceName, func(r res.Resource) { 45 | pp := r.PathParams() 46 | restest.AssertEqualJSON(t, "PathParams", pp, l.Expected) 47 | done() 48 | })) 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/21error_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | res "github.com/jirenius/go-res" 8 | "github.com/jirenius/go-res/restest" 9 | ) 10 | 11 | // Test InternalError method coverts an unknown error to a system.internalError *Error. 12 | func TestInternalError(t *testing.T) { 13 | e := res.InternalError(errors.New("foo")) 14 | restest.AssertEqualJSON(t, "error code", e.Code, res.CodeInternalError) 15 | } 16 | 17 | // Test ToError method coverts an unknown error to a system.internalError *Error. 18 | func TestToErrorWithConversion(t *testing.T) { 19 | e := res.ToError(errors.New("foo")) 20 | restest.AssertEqualJSON(t, "error code", e.Code, res.CodeInternalError) 21 | } 22 | 23 | // Test ToError method does not alter an error of type *Error. 24 | func TestToErrorWithNoConversion(t *testing.T) { 25 | e := res.ToError(res.ErrMethodNotFound) 26 | restest.AssertEqualJSON(t, "Error", e, res.ErrMethodNotFound) 27 | } 28 | 29 | // Test Error method to return the error message string 30 | func TestErrorMethod(t *testing.T) { 31 | e := &res.Error{ 32 | Code: mock.CustomErrorCode, 33 | Message: mock.ErrorMessage, 34 | } 35 | restest.AssertEqualJSON(t, "Error", e.Error(), mock.ErrorMessage) 36 | } 37 | -------------------------------------------------------------------------------- /test/resources.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/url" 7 | 8 | res "github.com/jirenius/go-res" 9 | "github.com/jirenius/go-res/restest" 10 | ) 11 | 12 | type modelDto struct { 13 | ID int `json:"id"` 14 | Foo string `json:"foo"` 15 | } 16 | 17 | type mockData struct { 18 | // Request info 19 | CID string 20 | Host string 21 | RemoteAddr string 22 | URI string 23 | Header map[string][]string 24 | Params json.RawMessage 25 | Token json.RawMessage 26 | // Resources 27 | Model *modelDto 28 | ModelResponse json.RawMessage 29 | ModelResult json.RawMessage 30 | QueryModelResponse json.RawMessage 31 | Collection []interface{} 32 | CollectionResponse json.RawMessage 33 | CollectionResult json.RawMessage 34 | QueryCollectionResponse json.RawMessage 35 | Result json.RawMessage 36 | ResultResponse json.RawMessage 37 | CustomError *res.Error 38 | Error error 39 | AccessGrantedResponse json.RawMessage 40 | BrokenJSON []byte 41 | // Unserializables 42 | UnserializableValue interface{} 43 | UnserializableError *res.Error 44 | // Consts 45 | ErrorMessage string 46 | CustomErrorCode string 47 | Query string 48 | NormalizedQuery string 49 | QueryValues url.Values 50 | URLValues url.Values 51 | IntValue int 52 | } 53 | 54 | var mock = mockData{ 55 | // Request info 56 | "testcid", // CID 57 | "local", // Host 58 | "127.0.0.1", // RemoteAddr 59 | "/ws", // URI 60 | map[string][]string{ // Header 61 | "Accept-Encoding": {"gzip, deflate, br"}, 62 | "Accept-Language": {"*"}, 63 | "Cache-Control": {"no-cache"}, 64 | "Connection": {"Upgrade"}, 65 | "Origin": {"http://localhost"}, 66 | "Pragma": {"no-cache"}, 67 | "Sec-Websocket-Extensions": {"permessage-deflate; client_max_window_bits"}, 68 | "Sec-Websocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="}, 69 | "Sec-Websocket-Version": {"13"}, 70 | "Upgrade": {"websocket"}, 71 | "User-Agent": {"GolangTest/1.0 (Test)"}, 72 | }, 73 | json.RawMessage(`{"foo":"bar","baz":42}`), // Params 74 | json.RawMessage(`{"user":"foo","id":42}`), // Token 75 | // Resources 76 | &modelDto{ID: 42, Foo: "bar"}, // Model 77 | json.RawMessage(`{"result":{"model":{"id":42,"foo":"bar"}}}`), // ModelResponse 78 | json.RawMessage(`{"model":{"id":42,"foo":"bar"}}`), // ModelResult 79 | json.RawMessage(`{"result":{"model":{"id":42,"foo":"bar"},"query":"foo=bar&zoo=baz&limit=10"}}`), // QueryModelResponse 80 | []interface{}{42, "foo", nil}, // Collection 81 | json.RawMessage(`{"result":{"collection":[42,"foo",null]}}`), // CollectionResponse 82 | json.RawMessage(`{"collection":[42,"foo",null]}`), // CollectionResult 83 | json.RawMessage(`{"result":{"collection":[42,"foo",null],"query":"foo=bar&zoo=baz&limit=10"}}`), // QueryCollectionResponse 84 | json.RawMessage(`{"foo":"bar","zoo":42}`), // Result 85 | json.RawMessage(`{"result":{"foo":"bar","zoo":42}}`), // ResultResponse 86 | &res.Error{Code: "test.custom", Message: "Custom error", Data: map[string]string{"foo": "bar"}}, // CustomError 87 | errors.New("custom error"), // Error 88 | json.RawMessage(`{"result":{"get":true,"call":"*"}}`), // AccessGrantedResponse 89 | []byte(`{]`), // BrokenJSON 90 | // Unserializables 91 | func() {}, // UnserializableValue 92 | &res.Error{Code: "test.unserializable", Message: "Unserializable", Data: func() {}}, // UnserializableError 93 | // Consts 94 | "Custom error", // ErrorMessage 95 | "test.custom", // CustomErrorCode 96 | "zoo=baz&foo=bar", // Query 97 | "foo=bar&zoo=baz&limit=10", // NormalizedQuery 98 | url.Values{"zoo": {"baz"}, "foo": {"bar"}}, // QueryValues 99 | url.Values{"id": {"42"}, "foo": {"bar", "baz"}}, // URLValues 100 | 42, // IntValue 101 | } 102 | 103 | func (m *mockData) DefaultRequest() *restest.Request { 104 | return &restest.Request{ 105 | CID: m.CID, 106 | } 107 | } 108 | 109 | func (m *mockData) QueryRequest() *restest.Request { 110 | return &restest.Request{ 111 | Query: m.Query, 112 | } 113 | } 114 | 115 | func (m *mockData) Request() *restest.Request { 116 | return &restest.Request{} 117 | } 118 | 119 | func (m *mockData) AuthRequest() *restest.Request { 120 | return &restest.Request{ 121 | CID: m.CID, 122 | Header: m.Header, 123 | Host: m.Host, 124 | RemoteAddr: m.RemoteAddr, 125 | URI: m.URI, 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | res "github.com/jirenius/go-res" 8 | "github.com/jirenius/go-res/restest" 9 | ) 10 | 11 | const timeoutDuration = 1 * time.Second 12 | 13 | func syncCallback(cb func(*restest.Session)) func(s *restest.Session, done func()) { 14 | return func(s *restest.Session, done func()) { 15 | if cb != nil { 16 | cb(s) 17 | } 18 | done() 19 | } 20 | } 21 | 22 | func runTest(t *testing.T, precb func(*res.Service), cb func(*restest.Session), opts ...func(*restest.SessionConfig)) { 23 | runTestAsync(t, precb, syncCallback(cb), opts...) 24 | } 25 | 26 | func runTestAsync(t *testing.T, precb func(*res.Service), cb func(*restest.Session, func()), opts ...func(*restest.SessionConfig)) { 27 | rs := res.NewService("test") 28 | 29 | if precb != nil { 30 | precb(rs) 31 | } 32 | 33 | s := restest.NewSession(t, rs, opts...) 34 | defer s.Close() 35 | 36 | acl := make(chan struct{}) 37 | if cb != nil { 38 | cb(s, func() { 39 | close(acl) 40 | }) 41 | } else { 42 | close(acl) 43 | } 44 | 45 | select { 46 | case <-acl: 47 | case <-time.After(timeoutDuration): 48 | panic("test: async test failed by never calling done: timeout") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import "encoding/json" 4 | 5 | // Ref is a resource reference to another resource ID. 6 | // 7 | // It marshals into a reference object, eg.: 8 | // 9 | // {"rid":"userService.user.42"} 10 | type Ref string 11 | 12 | // SoftRef is a soft resource reference to another resource ID which will not 13 | // automatically be followed by Resgate. 14 | // 15 | // It marshals into a soft reference object, eg.: 16 | // 17 | // {"rid":"userService.user.42","soft":true} 18 | type SoftRef string 19 | 20 | // DataValue is a wrapper for values that may marshal into any type of json 21 | // value, including objects, arrays, or nested structures. 22 | // 23 | // If a value marshals into a json object or array, it must be wrapped with 24 | // DataValue or similar, or else the value will be considered invalid. 25 | // 26 | // Example: 27 | // 28 | // s.Handle("timezones", res.GetCollection(func(r res.CollectionRequest) { 29 | // type tz struct { 30 | // Abbr string `json:"abbr"` 31 | // Offset int `json:"offset"` 32 | // } 33 | // r.Collection([]res.DataValue{ 34 | // res.DataValue{tz{"GMT", 0}}, 35 | // res.DataValue{tz{"CET", 1}}, 36 | // }) 37 | // })) 38 | // 39 | // For objects and arrays, it marshals into a data value object, eg.: 40 | // 41 | // json.Marshal(res.DataValue{[]int{1, 2, 3}}) // Result: {"data":[1,2,3]} 42 | // 43 | // For strings, numbers, booleans, and null values, it marshals into a primitive value, eg.: 44 | // 45 | // json.Marshal(res.DataValue{nil}) // Result: null 46 | type DataValue[T any] struct { 47 | Data T `json:"data"` 48 | } 49 | 50 | // NewDataValue creates a new DataValue with the given data. 51 | func NewDataValue[T any](data T) DataValue[T] { return DataValue[T]{Data: data} } 52 | 53 | const ( 54 | refPrefix = `{"rid":` 55 | softRefSuffix = `,"soft":true}` 56 | refSuffix = '}' 57 | ) 58 | 59 | // ResourceType enum 60 | type ResourceType byte 61 | 62 | // Resource type enum values 63 | const ( 64 | TypeUnset ResourceType = iota 65 | TypeModel 66 | TypeCollection 67 | ) 68 | 69 | // DeleteAction is used for deleted properties in "change" events 70 | var DeleteAction = &struct{ json.RawMessage }{RawMessage: json.RawMessage(`{"action":"delete"}`)} 71 | 72 | // MarshalJSON makes Ref implement the json.Marshaler interface. 73 | func (r Ref) MarshalJSON() ([]byte, error) { 74 | rid, err := json.Marshal(string(r)) 75 | if err != nil { 76 | return nil, err 77 | } 78 | o := make([]byte, len(rid)+8) 79 | copy(o, refPrefix) 80 | copy(o[7:], rid) 81 | o[len(o)-1] = refSuffix 82 | return o, nil 83 | } 84 | 85 | // UnmarshalJSON makes Ref implement the json.Unmarshaler interface. 86 | func (r *Ref) UnmarshalJSON(b []byte) error { 87 | var p struct { 88 | RID string `json:"rid"` 89 | } 90 | err := json.Unmarshal(b, &p) 91 | if err != nil { 92 | return err 93 | } 94 | *r = Ref(p.RID) 95 | return nil 96 | } 97 | 98 | // IsValid returns true if the reference RID is valid, otherwise false. 99 | func (r Ref) IsValid() bool { 100 | return IsValidRID(string(r)) 101 | } 102 | 103 | // MarshalJSON makes SoftRef implement the json.Marshaler interface. 104 | func (r SoftRef) MarshalJSON() ([]byte, error) { 105 | rid, err := json.Marshal(string(r)) 106 | if err != nil { 107 | return nil, err 108 | } 109 | o := make([]byte, len(rid)+20) 110 | copy(o, refPrefix) 111 | copy(o[7:], rid) 112 | copy(o[len(o)-13:], softRefSuffix) 113 | return o, nil 114 | } 115 | 116 | // UnmarshalJSON makes SoftRef implement the json.Unmarshaler interface. 117 | func (r *SoftRef) UnmarshalJSON(b []byte) error { 118 | var p struct { 119 | RID string `json:"rid"` 120 | } 121 | err := json.Unmarshal(b, &p) 122 | if err != nil { 123 | return err 124 | } 125 | *r = SoftRef(p.RID) 126 | return nil 127 | } 128 | 129 | // IsValid returns true if the soft reference RID is valid, otherwise false. 130 | func (r SoftRef) IsValid() bool { 131 | return IsValidRID(string(r)) 132 | } 133 | 134 | // IsValidRID returns true if the resource ID is valid, otherwise false. 135 | func IsValidRID(rid string) bool { 136 | start := true 137 | for _, c := range rid { 138 | if c == '?' { 139 | return !start 140 | } 141 | if c < 33 || c > 126 || c == '*' || c == '>' { 142 | return false 143 | } 144 | if c == '.' { 145 | if start { 146 | return false 147 | } 148 | start = true 149 | } else { 150 | start = false 151 | } 152 | } 153 | 154 | return !start 155 | } 156 | -------------------------------------------------------------------------------- /worker.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | type work struct { 4 | s *Service 5 | wid string // Worker ID for the work queue 6 | single [1]func() 7 | queue []func() // Callback queue 8 | } 9 | 10 | // startWorker starts a new resource worker that will listen for resources to 11 | // process requests on. 12 | func (s *Service) startWorker() { 13 | s.mu.Lock() 14 | defer s.mu.Unlock() 15 | defer s.wg.Done() 16 | // workqueue being nil signals we the service is closing 17 | for s.workqueue != nil { 18 | for len(s.workqueue) == 0 { 19 | s.workcond.Wait() 20 | if s.workqueue == nil { 21 | return 22 | } 23 | } 24 | w := s.workqueue[0] 25 | if len(s.workqueue) == 1 { 26 | s.workqueue = s.workbuf[:0] 27 | } else { 28 | s.workqueue = s.workqueue[1:] 29 | } 30 | w.processQueue() 31 | } 32 | } 33 | 34 | func (w *work) processQueue() { 35 | var f func() 36 | idx := 0 37 | 38 | for len(w.queue) > idx { 39 | f = w.queue[idx] 40 | w.s.mu.Unlock() 41 | idx++ 42 | f() 43 | w.s.mu.Lock() 44 | } 45 | // Work complete. Delete if it has a work ID. 46 | if w.wid != "" { 47 | delete(w.s.rwork, w.wid) 48 | } 49 | } 50 | --------------------------------------------------------------------------------