├── .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 |
18 |
19 |
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 |
18 |
19 |
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 |
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 |
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 | 
2 | Middleware for Go RES Service
Synchronize Your Clients
3 |
4 |
5 |
6 |
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 | 
2 | BadgerDB Middleware for Go RES Service
Synchronize Your Clients
3 |
4 |
5 |
6 |
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 | 
2 | Go utilities for communicating with RES Services
Synchronize Your Clients
3 |
4 |
5 |
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 | 
2 | Testing for Go RES Service
Synchronize Your Clients
3 |
4 |
5 |
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 | 
2 | Storage utilities for Go RES Service
Synchronize Your Clients
3 |
4 |
5 |
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 |
--------------------------------------------------------------------------------