├── .gitignore
├── examples
└── clock.config.json
├── service
├── path_test.go
├── config.go
├── value.go
├── path.go
├── service.go
└── endpoint.go
├── LICENSE
├── main.go
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | /rest-to-res
4 |
5 | # debug files
6 | /debug
7 |
8 | # Local configuration
9 | /*.json
10 |
11 | # Test binary, build with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool
15 | *.out
16 |
17 | # Editor specifics
18 | .vscode/*
19 |
--------------------------------------------------------------------------------
/examples/clock.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "natsUrl": "nats://127.0.0.1:4222",
3 | "fullAccess": true,
4 | "serviceName": "clock",
5 | "endpoints": [
6 | {
7 | "url": "http://worldclockapi.com/api/json/${timezone}/now",
8 | "refreshTime": 5000,
9 | "refreshCount": 6,
10 | "type": "model",
11 | "pattern": "$timezone.now"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/service/path_test.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import "testing"
4 |
5 | func TestAddPath(t *testing.T) {
6 | root := node{}
7 | urlParams := []string{"version", "stationId"}
8 |
9 | AssertNoError(t, root.addPath("", "$version.stations.$stationId", urlParams, "model", ""))
10 | AssertNoError(t, root.addPath("station", "$version.stations.$stationId.station", urlParams, "model", ""))
11 | AssertNoError(t, root.addPath("station.transfers", "$version.stations.$stationId.station.transfers", urlParams, "model", ""))
12 | AssertNoError(t, root.addPath("station.transfers.transfer", "$version.stations.$stationId.station.transfers.transfer", urlParams, "collection", ""))
13 | AssertNoError(t, root.addPath("station.transfers.transfer.$transferId", "$version.stations.$stationId.station.transfers.transfer.$transferId", urlParams, "model", "id"))
14 | }
15 |
16 | func AssertNoError(t *testing.T, err error) {
17 | if err != nil {
18 | t.Fatalf("Error: %s", err)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 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.
--------------------------------------------------------------------------------
/service/config.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import res "github.com/jirenius/go-res"
4 |
5 | // Config holds server configuration
6 | type Config struct {
7 | ServiceName string `json:"serviceName"`
8 | Endpoints []EndpointCfg `json:"endpoints"`
9 | }
10 |
11 | type EndpointCfg struct {
12 | URL string `json:"url"`
13 | RefreshTime int `json:"refreshTime"`
14 | RefreshCount int `json:"refreshCount"`
15 | Timeout int `json:"timeout"`
16 | Access res.AccessHandler
17 | ResourceCfg
18 | }
19 |
20 | type ResourceCfg struct {
21 | Type string `json:"type,omitempty"`
22 | Pattern string `json:"pattern,omitempty"`
23 | Path string `json:"path,omitempty"`
24 | IDProp string `json:"idProp,omitempty"`
25 | Resources []ResourceCfg `json:"resources,omitempty"`
26 | }
27 |
28 | // SetDefault sets the default values
29 | func (c *Config) SetDefault() {
30 | if c.ServiceName == "" {
31 | c.ServiceName = "rest2res"
32 | }
33 | if c.Endpoints == nil {
34 | c.Endpoints = []EndpointCfg{}
35 | }
36 | for i := range c.Endpoints {
37 | ep := &c.Endpoints[i]
38 | if ep.RefreshTime == 0 {
39 | ep.RefreshTime = 5000
40 | }
41 | if ep.RefreshCount == 0 {
42 | ep.RefreshCount = 12
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/service/value.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | )
7 |
8 | type valueType byte
9 |
10 | const (
11 | valueTypeString valueType = iota
12 | valueTypeNumber
13 | valueTypeObject
14 | valueTypeArray
15 | valueTypeTrue
16 | valueTypeFalse
17 | valueTypeNull
18 | )
19 |
20 | var (
21 | valueObject = []byte(`{}`)
22 | valueArray = []byte(`[]`)
23 | valueTrue = []byte(`true`)
24 | valueFalse = []byte(`false`)
25 | valueNull = []byte(`null`)
26 | )
27 |
28 | type value struct {
29 | typ valueType
30 | raw json.RawMessage
31 | arr []value
32 | obj map[string]value
33 | }
34 |
35 | func (v *value) UnmarshalJSON(b []byte) error {
36 | switch b[0] {
37 | case '{':
38 | v.typ = valueTypeObject
39 | var obj map[string]value
40 | err := json.Unmarshal(b, &obj)
41 | if err != nil {
42 | return err
43 | }
44 | v.obj = obj
45 | case '[':
46 | v.typ = valueTypeArray
47 | var arr []value
48 | err := json.Unmarshal(b, &arr)
49 | if err != nil {
50 | return err
51 | }
52 | v.arr = arr
53 | case 't':
54 | v.typ = valueTypeTrue
55 | case 'f':
56 | v.typ = valueTypeFalse
57 | case 'n':
58 | v.typ = valueTypeNull
59 | case '"':
60 | v.typ = valueTypeString
61 | v.raw = make(json.RawMessage, len(b))
62 | copy(v.raw, b)
63 | default: // number
64 | v.typ = valueTypeNumber
65 | v.raw = make(json.RawMessage, len(b))
66 | copy(v.raw, b)
67 | }
68 | return nil
69 | }
70 |
71 | func (v value) MarshalJSON() ([]byte, error) {
72 | switch v.typ {
73 | case valueTypeObject:
74 | return valueObject, nil
75 | case valueTypeArray:
76 | return valueArray, nil
77 | case valueTypeTrue:
78 | return valueTrue, nil
79 | case valueTypeFalse:
80 | return valueFalse, nil
81 | case valueTypeNull:
82 | return valueNull, nil
83 | case valueTypeString:
84 | fallthrough
85 | case valueTypeNumber:
86 | return v.raw, nil
87 | }
88 | return nil, errors.New("invalid value type")
89 | }
90 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "flag"
6 | "fmt"
7 | "io/ioutil"
8 | "os"
9 | "os/signal"
10 | "time"
11 |
12 | "./service"
13 | res "github.com/jirenius/go-res"
14 | "github.com/jirenius/resgate/logger"
15 | )
16 |
17 | var stopTimeout = 10 * time.Second
18 |
19 | var usageStr = `
20 | Usage: rest2res [options]
21 |
22 | Service Options:
23 | -n, --nats NATS Server URL (default: nats://127.0.0.1:4222)
24 | -c, --config Configuration file (required)
25 |
26 | Common Options:
27 | -h, --help Show this message
28 | `
29 |
30 | // Config holds server configuration
31 | type Config struct {
32 | NatsURL string `json:"natsUrl"`
33 | ExternalAccess bool `json:"externalAccess"`
34 | Debug bool `json:"debug,omitempty"`
35 | service.Config
36 | }
37 |
38 | // SetDefault sets the default values
39 | func (c *Config) SetDefault() {
40 | if c.NatsURL == "" {
41 | c.NatsURL = "nats://127.0.0.1:4222"
42 | }
43 | c.Config.SetDefault()
44 | }
45 |
46 | // Init takes a path to a json encoded file and loads the config
47 | // If no file exists, a new file with default settings is created
48 | func (c *Config) Init(fs *flag.FlagSet, args []string) error {
49 | var (
50 | showHelp bool
51 | configFile string
52 | )
53 |
54 | fs.BoolVar(&showHelp, "h", false, "Show this message.")
55 | fs.BoolVar(&showHelp, "help", false, "Show this message.")
56 | fs.StringVar(&configFile, "c", "", "Configuration file.")
57 | fs.StringVar(&configFile, "config", "", "Configuration file.")
58 | fs.StringVar(&c.NatsURL, "n", "", "NATS Server URL.")
59 | fs.StringVar(&c.NatsURL, "nats", "", "NATS Server URL.")
60 |
61 | if err := fs.Parse(args); err != nil {
62 | printAndDie(fmt.Sprintf("error parsing arguments: %s", err.Error()), true)
63 | }
64 |
65 | if showHelp {
66 | usage()
67 | }
68 |
69 | if configFile == "" {
70 | printAndDie(fmt.Sprintf("missing config file"), true)
71 | }
72 |
73 | fin, err := ioutil.ReadFile(configFile)
74 | if err != nil {
75 | if !os.IsNotExist(err) {
76 | return fmt.Errorf("error loading config file: %s", err)
77 | }
78 |
79 | c.SetDefault()
80 |
81 | fout, err := json.MarshalIndent(c, "", "\t")
82 | if err != nil {
83 | return fmt.Errorf("error encoding config: %s", err)
84 | }
85 |
86 | ioutil.WriteFile(configFile, fout, os.FileMode(0664))
87 | } else {
88 | err = json.Unmarshal(fin, c)
89 | if err != nil {
90 | return fmt.Errorf("error parsing config file: %s", err)
91 | }
92 |
93 | // Overwrite configFile options with command line options
94 | fs.Parse(args)
95 | }
96 |
97 | // Any value not set, set it now
98 | c.SetDefault()
99 |
100 | // Set access granted access handler if no external authorization is used
101 | if !c.ExternalAccess {
102 | for i := range c.Config.Endpoints {
103 | c.Config.Endpoints[i].Access = res.AccessGranted
104 | }
105 | }
106 |
107 | return nil
108 | }
109 |
110 | // usage will print out the flag options for the server.
111 | func usage() {
112 | fmt.Printf("%s\n", usageStr)
113 | os.Exit(0)
114 | }
115 |
116 | func printAndDie(msg string, showUsage bool) {
117 | fmt.Fprintf(os.Stderr, "%s\n", msg)
118 | if showUsage {
119 | fmt.Fprintf(os.Stderr, "%s\n", usageStr)
120 | }
121 | os.Exit(1)
122 | }
123 |
124 | func main() {
125 | fs := flag.NewFlagSet("rest2res", flag.ExitOnError)
126 | fs.Usage = usage
127 |
128 | var cfg Config
129 | err := cfg.Init(fs, os.Args[1:])
130 | if err != nil {
131 | printAndDie(err.Error(), false)
132 | }
133 |
134 | s, err := service.NewService(cfg.Config)
135 | if err != nil {
136 | printAndDie(err.Error(), false)
137 | }
138 |
139 | s.SetLogger(logger.NewStdLogger(cfg.Debug, cfg.Debug))
140 |
141 | // Start service in separate goroutine
142 | stop := make(chan bool)
143 | go func() {
144 | defer close(stop)
145 | if err := s.ListenAndServe("nats://localhost:4222"); err != nil {
146 | fmt.Printf("%s\n", err.Error())
147 | }
148 | }()
149 |
150 | // Wait for interrupt signal
151 | c := make(chan os.Signal, 1)
152 | signal.Notify(c, os.Interrupt)
153 | select {
154 | case <-c:
155 | // Graceful stop
156 | s.Shutdown()
157 | case <-stop:
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/service/path.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 | )
8 |
9 | const (
10 | pmark = '$'
11 | btsep = "."
12 | )
13 |
14 | var errInvalidPath = errors.New("invalid path")
15 | var errInvalidPattern = errors.New("invalid pattern")
16 |
17 | // A node represents one part of the path, and has pointers
18 | // to the next nodes.
19 | // Only one instance of handlers may exist per node.
20 | type node struct {
21 | typ resourceType
22 | nodes map[string]*node
23 | param *node
24 | pattern string
25 | params []patternParam // pattern parameters
26 | ptyp pathType
27 | idProp string
28 | }
29 |
30 | // A pattern represent a parameter part of the resource name pattern.
31 | type patternParam struct {
32 | typ paramType
33 | name string // name of the parameter
34 | idx int // token index of the parameter for paramTypePath
35 | }
36 |
37 | type paramType byte
38 |
39 | const (
40 | paramTypeUnset paramType = iota
41 | paramTypeURL
42 | paramTypePath
43 | )
44 |
45 | type pathType byte
46 |
47 | const (
48 | pathTypeRoot pathType = iota
49 | pathTypeDefault
50 | pathTypeProperty
51 | )
52 |
53 | func (rn *node) addPath(path string, pattern string, urlParams []string, typStr string, idProp string) error {
54 | ptyp := pathTypeRoot
55 | var typ resourceType
56 | switch typStr {
57 | case "model":
58 | typ = resourceTypeModel
59 | case "collection":
60 | typ = resourceTypeCollection
61 | default:
62 | return fmt.Errorf("invalid resource type: %s", typStr)
63 | }
64 |
65 | // Parse the pattern to see what parameters we need to cover
66 | parsedPattern, params, err := parsePattern(pattern)
67 | if err != nil {
68 | return err
69 | }
70 | // Validate all URL parameters are covered, and set them
71 | for _, urlParam := range urlParams {
72 | j := patternParamsContain(params, urlParam)
73 | if j == -1 {
74 | return fmt.Errorf("param %s found in url but not in pattern:\n\t%s", urlParam, pattern)
75 | }
76 | params[j].typ = paramTypeURL
77 | }
78 |
79 | var tokens []string
80 | if path != "" {
81 | tokens = strings.Split(path, btsep)
82 | }
83 |
84 | l := rn
85 |
86 | var n *node
87 |
88 | for i, t := range tokens {
89 | ptyp = pathTypeDefault
90 |
91 | lt := len(t)
92 | if lt == 0 {
93 | return errInvalidPath
94 | }
95 |
96 | if t[0] == pmark {
97 | if lt == 1 {
98 | return errInvalidPath
99 | }
100 | name := t[1:]
101 | j := patternParamsContain(params, name)
102 | if j == -1 {
103 | return fmt.Errorf("param %s found in path:\n\t%s\nbut not in pattern:\n\t%s", name, path, pattern)
104 | }
105 |
106 | if params[j].typ != paramTypeUnset {
107 | return fmt.Errorf("param %s covered more than once in pattern:\n\t%s", name, pattern)
108 | }
109 |
110 | // Is it the last token?
111 | if i == len(tokens)-1 {
112 | switch l.typ {
113 | case resourceTypeModel:
114 | case resourceTypeCollection:
115 | // No ID property means we use index instead
116 | if idProp != "" {
117 | if typ != resourceTypeModel {
118 | return fmt.Errorf("idProp must only be used on model resources")
119 | }
120 | ptyp = pathTypeProperty
121 | }
122 | default:
123 | return fmt.Errorf("no parent resource set for path:\n\t%s", path)
124 | }
125 | }
126 |
127 | params[j].typ = paramTypePath
128 | params[j].idx = i
129 |
130 | if l.param == nil {
131 | l.param = &node{}
132 | }
133 | n = l.param
134 | } else {
135 | if l.nodes == nil {
136 | l.nodes = make(map[string]*node)
137 | n = &node{}
138 | l.nodes[t] = n
139 | } else {
140 | n = l.nodes[t]
141 | if n == nil {
142 | n = &node{}
143 | l.nodes[t] = n
144 | }
145 | }
146 | }
147 |
148 | l = n
149 | }
150 |
151 | if l.typ != resourceTypeUnset {
152 | return fmt.Errorf("registration already done for path:\n\t%s", path)
153 | }
154 |
155 | // Validate all pattern parameters are covered by path
156 | for _, p := range params {
157 | if p.typ == paramTypeUnset {
158 | return fmt.Errorf("missing pattern parameter %s in path:\n\t%s", p.name, path)
159 | }
160 | }
161 |
162 | l.typ = typ
163 | l.pattern = parsedPattern
164 | l.params = params
165 | l.ptyp = ptyp
166 | l.idProp = idProp
167 |
168 | return nil
169 | }
170 |
171 | func parsePattern(pattern string) (string, []patternParam, error) {
172 | var tokens []string
173 | if pattern != "" {
174 | tokens = strings.Split(pattern, btsep)
175 | }
176 |
177 | var params []patternParam
178 | for i, t := range tokens {
179 | lt := len(t)
180 | if lt == 0 {
181 | return "", nil, errInvalidPattern
182 | }
183 |
184 | if t[0] == pmark {
185 | if lt == 1 {
186 | return "", nil, errInvalidPattern
187 | }
188 | params = append(params, patternParam{
189 | typ: paramTypeUnset,
190 | name: t[1:],
191 | })
192 | tokens[i] = "%s"
193 | }
194 | }
195 |
196 | return strings.Join(tokens, "."), params, nil
197 | }
198 |
199 | func containsString(a []string, s string) bool {
200 | for _, w := range a {
201 | if w == s {
202 | return true
203 | }
204 | }
205 | return false
206 | }
207 |
208 | // patternParamsContain searches for the first pattern param that contains
209 | // the param name, and returns the index, or -1 if it was not found.
210 | func patternParamsContain(pps []patternParam, name string) int {
211 | for i, pp := range pps {
212 | if pp.name == name {
213 | return i
214 | }
215 | }
216 | return -1
217 | }
218 |
--------------------------------------------------------------------------------
/service/service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | res "github.com/jirenius/go-res"
8 | "github.com/jirenius/resgate/logger"
9 | nats "github.com/nats-io/go-nats"
10 | )
11 |
12 | // A Service handles incoming requests from NATS Server and calls the
13 | // appropriate callback on the resource handlers.
14 | type Service struct {
15 | res *res.Service
16 | nc res.Conn // NATS Server connection
17 | logger logger.Logger // Logger
18 | cfg Config
19 | }
20 |
21 | // NewService creates a new rest2res service.
22 | func NewService(cfg Config) (*Service, error) {
23 | s := &Service{
24 | res: res.NewService(cfg.ServiceName),
25 | cfg: cfg,
26 | logger: logger.NewStdLogger(false, false),
27 | }
28 | if err := s.addResources(); err != nil {
29 | return nil, err
30 | }
31 |
32 | return s, nil
33 | }
34 |
35 | // SetLogger sets the logger.
36 | // Panics if service is already started.
37 | func (s *Service) SetLogger(l logger.Logger) *Service {
38 | s.res.SetLogger(l)
39 | s.logger = l
40 | return s
41 | }
42 |
43 | // Logger returns the logger.
44 | func (s *Service) Logger() logger.Logger {
45 | return s.logger
46 | }
47 |
48 | // Logf writes a formatted log message
49 | func (s *Service) Logf(format string, v ...interface{}) {
50 | if s.logger == nil {
51 | return
52 | }
53 | s.logger.Logf("[Service] ", format, v...)
54 | }
55 |
56 | // Debugf writes a formatted debug message
57 | func (s *Service) Debugf(format string, v ...interface{}) {
58 | if s.logger == nil {
59 | return
60 | }
61 | s.logger.Debugf("[Service] ", format, v...)
62 | }
63 |
64 | // Tracef writes a formatted trace message
65 | func (s *Service) Tracef(format string, v ...interface{}) {
66 | if s.logger == nil {
67 | return
68 | }
69 | s.logger.Tracef("[Service] ", format, v...)
70 | }
71 |
72 | // ListenAndServe connects to the NATS server at the url. Once connected,
73 | // it subscribes to incoming requests and serves them on a single goroutine
74 | // in the order they are received. For each request, it calls the appropriate
75 | // handler, or replies with the appropriate error if no handler is available.
76 | //
77 | // In case of disconnect, it will try to reconnect until Close is called,
78 | // or until successfully reconnecting, upon which Reset will be called.
79 | //
80 | // ListenAndServe returns an error if failes to connect or subscribe.
81 | // Otherwise, nil is returned once the connection is closed using Close.
82 | func (s *Service) ListenAndServe(url string, options ...nats.Option) error {
83 | return s.res.ListenAndServe(url, options...)
84 | }
85 |
86 | // Serve subscribes to incoming requests on the *Conn nc, serving them on
87 | // a single goroutine in the order they are received. For each request,
88 | // it calls the appropriate handler, or replies with the appropriate
89 | // error if no handler is available.
90 | //
91 | // Serve returns an error if failes to subscribe. Otherwise, nil is
92 | // returned once the *Conn is closed.
93 | func (s *Service) Serve(nc res.Conn) error {
94 | return s.res.Serve(nc)
95 | }
96 |
97 | // Shutdown closes any existing connection to NATS Server.
98 | // Returns an error if service is not started.
99 | func (s *Service) Shutdown() error {
100 | return s.res.Shutdown()
101 | }
102 |
103 | func (s *Service) addResources() error {
104 | for i := range s.cfg.Endpoints {
105 | cep := &s.cfg.Endpoints[i]
106 |
107 | ep, err := newEndpoint(s, cep)
108 | if err != nil {
109 | return fmt.Errorf("endpoint #%d is invalid: %s", i+1, err)
110 | }
111 | err = s.addResource(ep, cep.ResourceCfg, "", "")
112 | if err != nil {
113 | return fmt.Errorf("endpoint #%d has invalid config: %s", i+1, err)
114 | }
115 | }
116 |
117 | return nil
118 | }
119 |
120 | func (s *Service) addResource(ep *endpoint, r ResourceCfg, pattern, path string) error {
121 | if r.Pattern != "" {
122 | pattern = r.Pattern
123 | } else if r.Path != "" {
124 | pattern += "." + r.Path
125 | }
126 |
127 | if r.Path != "" {
128 | if path == "" {
129 | path = r.Path
130 | } else {
131 | path = "." + r.Path
132 | }
133 | }
134 |
135 | rid := s.cfg.ServiceName
136 | if pattern != "" {
137 | rid += "." + pattern
138 | }
139 |
140 | if err := ep.addPath(path, rid, ep.urlParams, r.Type, r.IDProp); err != nil {
141 | return err
142 | }
143 |
144 | ep.resetPatterns = append(ep.resetPatterns, resetPattern(rid, ep.urlParams))
145 | s.res.AddHandler(pattern, ep.handler())
146 |
147 | // Recursively add child resources
148 | for _, nr := range r.Resources {
149 | if err := s.addResource(ep, nr, pattern, path); err != nil {
150 | return err
151 | }
152 | }
153 |
154 | return nil
155 | }
156 |
157 | func urlParams(u string) ([]string, error) {
158 | var params []string
159 | var tagStart int
160 | var c byte
161 | l := len(u)
162 | i := 0
163 |
164 | StateDefault:
165 | if i == l {
166 | return params, nil
167 | }
168 | if u[i] == '$' {
169 | i++
170 | if i == l {
171 | goto UnexpectedEnd
172 | }
173 | if u[i] != '{' {
174 | return nil, fmt.Errorf("expected character \"{\" at pos %d", i)
175 | }
176 | i++
177 | tagStart = i
178 | goto StateTag
179 | }
180 | i++
181 | goto StateDefault
182 |
183 | StateTag:
184 | if i == l {
185 | goto UnexpectedEnd
186 | }
187 | c = u[i]
188 | if c == '}' {
189 | if i == tagStart {
190 | return nil, fmt.Errorf("empty tag at pos %d", i)
191 | }
192 | params = append(params, u[tagStart:i])
193 | i++
194 | goto StateDefault
195 | }
196 | if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') {
197 | return nil, fmt.Errorf("non alpha-numeric (a-z or 0-9) character in tag at pos %d", i)
198 | }
199 | i++
200 | goto StateTag
201 |
202 | UnexpectedEnd:
203 | return nil, fmt.Errorf("unexpected end of tag")
204 | }
205 |
206 | func resetPattern(pattern string, urlParams []string) string {
207 | var tokens []string
208 | if pattern != "" {
209 | tokens = strings.Split(pattern, btsep)
210 | }
211 | for i, t := range tokens {
212 | if len(t) > 0 && t[0] == pmark {
213 | tt := t[1:]
214 | if containsString(urlParams, tt) {
215 | tokens[i] = "${" + tt + "}"
216 | } else {
217 | tokens[i] = "*"
218 | }
219 | }
220 | }
221 | return strings.Join(tokens, ".")
222 | }
223 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 |
4 | REST to RES service
Synchronize Your Clients
5 |
6 |
7 |
8 |
9 |
10 |
11 | ---
12 |
13 | *REST to RES* (*rest2res* for short) is a service for [Resgate](https://resgate.io) and [NATS Server](https://nats.io) that can turn old JSON based legacy REST APIs into live APIs.
14 |
15 | Visit [Resgate.io](https://resgate.io) for more information on Resgate.
16 |
17 | ## Polling madness
18 | Do you have lots of clients polling every X second from your REST API, to keep their views updated? This is madness :scream:!
19 |
20 | By placing *rest2res/resgate* in between as a cache, you can reduce thousands of polling clients to a single *rest2res* service doing the polling on their behalf.
21 |
22 | The clients will instead fetch the same data from *Resgate*, whose cache is efficiently updated by *rest2res* whenever there is a modification. No changes has to be made to the legacy REST API, nor the clients (except perhaps changing which URL to poll from).
23 |
24 | Your clients can later be improved to use [ResClient](https://www.npmjs.com/package/resclient), which uses WebSocket, to reliably get the updates as soon as they are detected, without having to do any polling at all. This will majorly decrease the bandwidth required serving your clients.
25 |
26 | ## Quickstart
27 |
28 | ### Download Resgate/NATS Server
29 | This service uses *Resgate* and *NATS Server*. You can just download one of the pre-built binaries:
30 | * [Download](https://nats.io/download/nats-io/gnatsd/) and run NATS Server
31 | * [Download](https://github.com/jirenius/resgate/releases/latest) and run Resgate
32 |
33 | > **Tip**
34 | >
35 | > If you run Resgate with: `resgate --apiencoding=jsonflat`
36 | > the web resource JSON served by Resgate will look the same as the legacy REST API for nested JSON structures, without href meta data.
37 |
38 | ### Build rest2res
39 |
40 | First make sure you have [installed Go](https://golang.org/doc/install). Then you can download and compile the service:
41 |
42 | ```text
43 | git clone github.com/jirenius/rest2res
44 | cd rest2res
45 | go build
46 | ```
47 |
48 | ### Try it out
49 |
50 | Run it with one of the example configs, such as for *worldclockapi.com*:
51 | ```text
52 | rest2res --config=examples/clock.config.json
53 | ```
54 |
55 | Access the data through Resgate:
56 |
57 | ```text
58 | http://localhost:8080/api/clock/utc/now
59 | ```
60 |
61 | Or get live data using [ResClient](https://resgate.io/docs/writing-clients/resclient/):
62 |
63 | ```javascript
64 | import ResClient from 'resclient';
65 |
66 | let client = new ResClient('ws://localhost:8080');
67 | client.get('clock.utc.now').then(model => {
68 | console.log(model.currentDateTime);
69 | model.on('change', () => {
70 | console.log(model.currentDateTime);
71 | });
72 | });
73 | ```
74 |
75 | ## Usage
76 | ```text
77 | rest2res [options]
78 | ```
79 |
80 | | Option | Description | Default value
81 | |---|---|---
82 | | `-n, --nats ` | NATS Server URL | `nats://127.0.0.1:4222`
83 | | `-c, --config ` | Configuration file (required) |
84 | | `-h, --help` | Show usage message |
85 |
86 |
87 | ## Configuration
88 |
89 | Configuration is a JSON encoded file. It is a json object containing the following available settings:
90 |
91 | **natsUrl** *(string)*
92 | NATS Server URL. Must be a valid URI using `nats://` as schema.
93 | *Default:* `"nats://127.0.0.1:4222"`
94 |
95 | **serviceName** *(string)*
96 | Name of the service, used as the first part of all resource IDs.
97 | *Default:* `rest2res`
98 |
99 | **externalAccess** *(boolean)*
100 | Flag telling if access requests are handled by another service. If false, rest2res will handle access requests by granting full access to all endpoints.
101 | *Default:* `false`
102 |
103 | **endpoints** *(array of endpoints)*
104 | List of endpoints handled by rest2res. See below for [endpoint configuration](#endpoint).
105 | *Default:* `[]`
106 |
107 | > **Tip**
108 | >
109 | > A new configuration file with default settings can be created by using the `--config` option, specifying a file path that does not yet exist.
110 | >
111 | > ```text
112 | > rest2res --config myconfig.json
113 | > ```
114 |
115 | ### Endpoint
116 |
117 | An endpoint is a REST endpoint to be mapped to RES. It is a json object with the following available settings:
118 |
119 | **url** *(string)*
120 | URL to the legacy REST API endpoint. May contain `${tags}` as placeholders for URL parameters.
121 | *Example:* `"http://worldclockapi.com/api/json/${timezone}/now"`
122 |
123 | **refreshTime** *(number)*
124 | The duration in milliseconds between each poll to the legacy endpoint.
125 | *Default:* `5000`
126 |
127 | **refreshCount** *(number)*
128 | Number of times *rest2res* should poll a resource before asking Resgate(s) if any client is still interested in the data.
129 | A high number may cause unnecessary polling, while a low number may cause additional traffic between rest2res and Resgate.
130 | *Default:* `6`
131 |
132 | **timeout** *(number)*
133 | Time in milliseconds before client requests should timeout, in case the endpoint is slow to respond. If `0`, or not set, the default request timeout of Resgate is used.
134 | *Example:* `5000`
135 |
136 | **type** *(string)*
137 | Type of data for the legacy endpoint. The setting tells *rest2res* if it should expect the legacy endpoint to return an *object* or an *array*.
138 |
139 | * `model` - used for a JSON objects
140 | * `collection` - used for a JSON arrays
141 |
142 | *Example:* `"model"`
143 |
144 | **pattern** *(string)*
145 | The resource ID pattern for the endpoint resource.
146 | The pattern often follows a similar structure as the URL path, but is dot-separated instead of slash-separated. A part starting with a dollar sign is considered a placeholder (eg. `$tags`). The pattern must contain placeholders matching the placeholder names used in the endpoint *url* setting.
147 | *Example:* `"$timezone.now"`
148 |
149 | **resources** *(array of resources)*
150 | List of nested resources (objects and array) within the endpoint root data. See below for [resource configuration](#resource).
151 | *Example:* `[{ "type":"model", "path":"foo" }]`
152 |
153 | ### Resource
154 |
155 | A resource, in this context, is an object or array nested within the endpoint data. It is called *resource* as it will be mapped to its own [RES resource](https://resgate.io/docs/writing-services/02basic-concepts/#resources) with a unique *resource ID*. The configuration is a JSON object with following available settings:
156 |
157 | **type** *(string)*
158 | Type of data for the sub-resource.
159 |
160 | * `model` - used for a JSON objects
161 | * `collection` - used for a JSON arrays
162 |
163 | *Example:* `"model"`
164 |
165 | **pattern** *(string)*
166 | The resource ID pattern for the sub-resource.
167 | May be omitted. It omitted, the pattern will be the same as the parent resource pattern suffixed by the *path* setting.
168 | *Example:* `"station.$stationId.transfers"`
169 |
170 | **path** *(string)*
171 | The path to the resource relative to the parent resource.
172 | If the parent resource is an object/model, the *path* is either:
173 |
174 | * name of the parent property key (eg. `"foo"`)
175 | * a placeholder starting with `$`, acting as a wildcard for all parent property keys (eg. `"$property"`).
176 |
177 | If the parent resource is an array/collection, the *path* is a placeholder starting with `$` (eg. `$userId`). The placeholder represents either:
178 |
179 | * index in the parent array
180 | * model id, in case `idProp` is set (see below).
181 |
182 | **idProp** *(string)*
183 | ID property in an object, used to identify it within a parent array/collection.
184 | Only valid for *object* types.
185 | *Example:* `"_id"`
186 |
187 | **resources** *(array of resources)*
188 | List of nested [resources](#resource) (objects and array) within the sub-resource.
189 | *Example:* `[{ "type":"model", "path":"bar" }]`
190 |
191 | > **Tip**
192 | >
193 | > Does configuring an endpoint seem complicated?
194 | > Check out the example configs in the [`/examples`](examples/) folder.
195 |
196 | ## Caveats
197 |
198 | The data fetched by *rest2res* will be shared through Resgate's cache with all clients requesting the same data. This means that the legacy REST API endpoints must be completely open for *rest2res* to access, as it will never access a legacy REST endpoint on behalf of a specific client.
199 |
200 | **Authorization**
201 | If client authorization is needed, this can be handled by creating a separate authentication and authorization service. Read more about [access control on Resgate.io](https://resgate.io/docs/writing-services/07access-control/).
202 |
203 | **User specific data**
204 | While it is possible to have user specific resources, *rest2res* does not support having a single endpoint URL returning different data depending on which user accesses it.
205 | But seriously, no proper REST resource should return different results on the same URL.
206 |
207 |
208 | ## Contribution
209 |
210 | If you find any issues with the service, feel free to report them. This project caters to some specific needs, and may yet lack features to make it viable for all use cases.
211 |
212 | If you lack a feature, feel free to create an issue for it to be discussed. If you like coding in Go, maybe you can make a pull request with the solution concluded from the discussion.
--------------------------------------------------------------------------------
/service/endpoint.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "io/ioutil"
8 | "net/http"
9 | "reflect"
10 | "strconv"
11 | "strings"
12 | "sync"
13 | "time"
14 |
15 | res "github.com/jirenius/go-res"
16 | "github.com/jirenius/timerqueue"
17 | )
18 |
19 | type endpoint struct {
20 | s *Service
21 | url string
22 | urlParams []string
23 | refreshCount int
24 | cachedURLs map[string]*cachedResponse
25 | access res.AccessHandler
26 | timeout time.Duration
27 | group string
28 | resetPatterns []string
29 | tq *timerqueue.Queue
30 | mu sync.RWMutex
31 | node
32 | }
33 |
34 | type cachedResponse struct {
35 | reloads int
36 | reqParams map[string]string
37 | crs map[string]cachedResource
38 | rerr *res.Error
39 | }
40 |
41 | type cachedResource struct {
42 | typ resourceType
43 | model map[string]interface{}
44 | collection []interface{}
45 | }
46 |
47 | type resourceType byte
48 |
49 | const defaultRefreshDuration = time.Second * 3
50 |
51 | const (
52 | resourceTypeUnset resourceType = iota
53 | resourceTypeModel
54 | resourceTypeCollection
55 | )
56 |
57 | func newEndpoint(s *Service, cep *EndpointCfg) (*endpoint, error) {
58 | if cep.URL == "" {
59 | return nil, errors.New("missing url")
60 | }
61 | if cep.Pattern == "" {
62 | return nil, errors.New("missing pattern")
63 | }
64 |
65 | urlParams, err := urlParams(cep.URL)
66 | if err != nil {
67 | return nil, err
68 | }
69 |
70 | ep := &endpoint{
71 | s: s,
72 | url: cep.URL,
73 | urlParams: urlParams,
74 | refreshCount: cep.RefreshCount,
75 | cachedURLs: make(map[string]*cachedResponse),
76 | access: cep.Access,
77 | timeout: time.Millisecond * time.Duration(cep.Timeout),
78 | }
79 | ep.tq = timerqueue.New(ep.handleRefresh, time.Millisecond*time.Duration(cep.RefreshTime))
80 |
81 | return ep, nil
82 | }
83 |
84 | func (ep *endpoint) handler() res.Handler {
85 | return res.Handler{
86 | Access: ep.access,
87 | GetResource: ep.getResource,
88 | Group: ep.url,
89 | }
90 | }
91 |
92 | func (ep *endpoint) handleRefresh(i interface{}) {
93 | ep.s.Debugf("Refreshing %s", i)
94 |
95 | url := i.(string)
96 |
97 | // Check if url is cached
98 | ep.mu.RLock()
99 | cresp, ok := ep.cachedURLs[url]
100 | ep.mu.RUnlock()
101 | if !ok {
102 | ep.s.Logf("Url %s not found in cache on refresh", url)
103 | return
104 | }
105 |
106 | params := cresp.reqParams
107 |
108 | ep.s.res.WithGroup(url, func(s *res.Service) {
109 | cresp.reloads++
110 | if cresp.rerr != nil || cresp.reloads > ep.refreshCount {
111 | // Reset resources
112 | ep.mu.Lock()
113 | delete(ep.cachedURLs, url)
114 | ep.mu.Unlock()
115 |
116 | resetResources := make([]string, len(ep.resetPatterns))
117 | for i, rp := range ep.resetPatterns {
118 | for _, param := range ep.urlParams {
119 | rp = strings.Replace(rp, "${"+param+"}", params[param], 1)
120 | }
121 | resetResources[i] = rp
122 | }
123 | ep.s.res.Reset(resetResources, nil)
124 | return
125 | }
126 |
127 | defer ep.tq.Add(i)
128 |
129 | ncresp := ep.getURL(url, params)
130 | if ncresp.rerr != nil {
131 | ep.s.Logf("Error refreshing url %s:\n\t%s", url, ncresp.rerr.Message)
132 | return
133 | }
134 |
135 | for rid, nv := range ncresp.crs {
136 | v, ok := cresp.crs[rid]
137 | if ok {
138 | r, err := ep.s.res.Resource(rid)
139 | if err != nil {
140 | // This shouldn't be possible. Let's panic.
141 | panic(fmt.Sprintf("error getting res resource %s:\n\t%s", rid, err))
142 | }
143 |
144 | updateResource(v, nv, r)
145 | delete(cresp.crs, rid)
146 | }
147 | }
148 |
149 | // for rid := range cresp.crs {
150 | // r, err := ep.s.res.Resource(rid)
151 | // r.DeleteEvent()
152 | // }
153 |
154 | // Replacing the old cachedResources with the new ones
155 | cresp.crs = ncresp.crs
156 | })
157 | }
158 |
159 | func updateResource(v, nv cachedResource, r res.Resource) {
160 | switch v.typ {
161 | case resourceTypeModel:
162 | updateModel(v.model, nv.model, r)
163 | case resourceTypeCollection:
164 | updateCollection(v.collection, nv.collection, r)
165 | }
166 | }
167 |
168 | func updateModel(a, b map[string]interface{}, r res.Resource) {
169 | ch := make(map[string]interface{})
170 | for k := range a {
171 | if _, ok := b[k]; !ok {
172 | ch[k] = res.DeleteAction
173 | }
174 | }
175 |
176 | for k, v := range b {
177 | ov, ok := a[k]
178 | if !(ok && reflect.DeepEqual(v, ov)) {
179 | ch[k] = v
180 | }
181 | }
182 |
183 | r.ChangeEvent(ch)
184 | }
185 |
186 | func updateCollection(a, b []interface{}, r res.Resource) {
187 | var i, j int
188 | // Do a LCS matric calculation
189 | // https://en.wikipedia.org/wiki/Longest_common_subsequence_problem
190 | s := 0
191 | m := len(a)
192 | n := len(b)
193 |
194 | // Trim of matches at the start and end
195 | for s < m && s < n && reflect.DeepEqual(a[s], b[s]) {
196 | s++
197 | }
198 |
199 | if s == m && s == n {
200 | return
201 | }
202 |
203 | for s < m && s < n && reflect.DeepEqual(a[m-1], b[n-1]) {
204 | m--
205 | n--
206 | }
207 |
208 | var aa, bb []interface{}
209 | if s > 0 || m < len(a) {
210 | aa = a[s:m]
211 | m = m - s
212 | } else {
213 | aa = a
214 | }
215 | if s > 0 || n < len(b) {
216 | bb = b[s:n]
217 | n = n - s
218 | } else {
219 | bb = b
220 | }
221 |
222 | // Create matrix and initialize it
223 | w := m + 1
224 | c := make([]int, w*(n+1))
225 |
226 | for i = 0; i < m; i++ {
227 | for j = 0; j < n; j++ {
228 | if reflect.DeepEqual(aa[i], bb[j]) {
229 | c[(i+1)+w*(j+1)] = c[i+w*j] + 1
230 | } else {
231 | v1 := c[(i+1)+w*j]
232 | v2 := c[i+w*(j+1)]
233 | if v2 > v1 {
234 | c[(i+1)+w*(j+1)] = v2
235 | } else {
236 | c[(i+1)+w*(j+1)] = v1
237 | }
238 | }
239 | }
240 | }
241 |
242 | idx := m + s
243 | i = m
244 | j = n
245 | rm := 0
246 |
247 | var adds [][3]int
248 | addCount := n - c[w*(n+1)-1]
249 | if addCount > 0 {
250 | adds = make([][3]int, 0, addCount)
251 | }
252 | Loop:
253 | for {
254 | m = i - 1
255 | n = j - 1
256 | switch {
257 | case i > 0 && j > 0 && reflect.DeepEqual(aa[m], bb[n]):
258 | idx--
259 | i--
260 | j--
261 | case j > 0 && (i == 0 || c[i+w*n] >= c[m+w*j]):
262 | adds = append(adds, [3]int{n, idx, rm})
263 | j--
264 | case i > 0 && (j == 0 || c[i+w*n] < c[m+w*j]):
265 | idx--
266 | r.RemoveEvent(idx)
267 | rm++
268 | i--
269 | default:
270 | break Loop
271 | }
272 | }
273 |
274 | // Do the adds
275 | l := len(adds) - 1
276 | for i := l; i >= 0; i-- {
277 | add := adds[i]
278 | r.AddEvent(bb[add[0]], add[1]-rm+add[2]+l-i)
279 | }
280 | }
281 |
282 | func (ep *endpoint) getResource(r res.GetRequest) {
283 | // Replace param placeholders
284 | url := ep.url
285 | for _, param := range ep.urlParams {
286 | url = strings.Replace(url, "${"+param+"}", r.PathParam(param), 1)
287 | }
288 |
289 | // Check if url is cached
290 | ep.mu.RLock()
291 | cresp, ok := ep.cachedURLs[url]
292 | ep.mu.RUnlock()
293 | if !ok {
294 | if ep.timeout > 0 {
295 | r.Timeout(ep.timeout)
296 | }
297 | cresp = ep.cacheURL(url, r.PathParams())
298 | }
299 |
300 | // Return any encountered error when getting the endpoint
301 | if cresp.rerr != nil {
302 | r.Error(cresp.rerr)
303 | return
304 | }
305 |
306 | // Check if resource exists
307 | cr, ok := cresp.crs[r.ResourceName()]
308 | if !ok {
309 | r.NotFound()
310 | return
311 | }
312 |
313 | switch cr.typ {
314 | case resourceTypeModel:
315 | r.Model(cr.model)
316 | case resourceTypeCollection:
317 | r.Collection(cr.collection)
318 | }
319 | }
320 |
321 | func (ep *endpoint) cacheURL(url string, reqParams map[string]string) *cachedResponse {
322 | cresp := ep.getURL(url, reqParams)
323 | ep.mu.Lock()
324 | ep.cachedURLs[url] = cresp
325 | ep.mu.Unlock()
326 | ep.tq.Add(url)
327 |
328 | return cresp
329 | }
330 |
331 | func (ep *endpoint) getURL(url string, reqParams map[string]string) *cachedResponse {
332 | cr := cachedResponse{reqParams: reqParams}
333 | // Make HTTP request
334 | resp, err := http.Get(url)
335 | if err != nil {
336 | ep.s.Debugf("Error fetching endpoint: %s\n\t%s", url, err)
337 | cr.rerr = res.InternalError(err)
338 | return &cr
339 | }
340 | defer resp.Body.Close()
341 |
342 | // Handle non-2XX status codes
343 | if resp.StatusCode == 404 {
344 | cr.rerr = res.ErrNotFound
345 | return &cr
346 | }
347 | if resp.StatusCode < 200 || resp.StatusCode >= 300 {
348 | cr.rerr = res.InternalError(fmt.Errorf("unexpected response code: %d", resp.StatusCode))
349 | return &cr
350 | }
351 |
352 | // Read body
353 | body, err := ioutil.ReadAll(resp.Body)
354 | if err != nil {
355 | cr.rerr = res.InternalError(err)
356 | return &cr
357 | }
358 | // Unmarshal body
359 | var v value
360 | if err = json.Unmarshal(body, &v); err != nil {
361 | cr.rerr = res.InternalError(err)
362 | return &cr
363 | }
364 |
365 | // Traverse the data
366 | crs := make(map[string]cachedResource)
367 | err = ep.traverse(crs, v, nil, reqParams)
368 | if err != nil {
369 | cr.rerr = res.InternalError(fmt.Errorf("invalid data structure for %s: %s", url, err))
370 | return &cr
371 | }
372 |
373 | cr.crs = crs
374 | return &cr
375 | }
376 |
377 | func (ep *endpoint) traverse(crs map[string]cachedResource, v value, path []string, reqParams map[string]string) error {
378 | var err error
379 | switch v.typ {
380 | case valueTypeObject:
381 | _, err = traverseModel(crs, v, path, &ep.node, reqParams, "")
382 | case valueTypeArray:
383 | _, err = traverseCollection(crs, v, path, &ep.node, reqParams, "")
384 | default:
385 | return errors.New("endpoint didn't respond with a json object or array")
386 | }
387 | if err != nil {
388 | return err
389 | }
390 | return nil
391 | }
392 |
393 | func traverseModel(crs map[string]cachedResource, v value, path []string, n *node, reqParams map[string]string, pathPart string) (res.Ref, error) {
394 | if n.typ != resourceTypeModel {
395 | return "", fmt.Errorf("expected a model at %s", pathStr(path))
396 | }
397 |
398 | // Append path part
399 | switch n.ptyp {
400 | case pathTypeDefault:
401 | path = append(path, pathPart)
402 | case pathTypeProperty:
403 | idv, ok := v.obj[n.idProp]
404 | if !ok {
405 | return "", fmt.Errorf("missing id property %s at:\n\t%s", n.idProp, pathStr(path))
406 | }
407 | switch idv.typ {
408 | case valueTypeString:
409 | var idstr string
410 | err := json.Unmarshal(idv.raw, &idstr)
411 | if err != nil {
412 | return "", err
413 | }
414 | path = append(path, idstr)
415 | case valueTypeNumber:
416 | path = append(path, string(idv.raw))
417 | default:
418 | return "", fmt.Errorf("invalid id value for property %s at:\n\t%s", n.idProp, pathStr(path))
419 | }
420 | path = append(path)
421 | }
422 |
423 | model := make(map[string]interface{})
424 | for k, kv := range v.obj {
425 | // Get next node
426 | next := n.nodes[k]
427 | if next == nil {
428 | next = n.param
429 | }
430 |
431 | switch kv.typ {
432 | case valueTypeObject:
433 | if next != nil {
434 | ref, err := traverseModel(crs, kv, path, next, reqParams, k)
435 | if err != nil {
436 | return "", err
437 | }
438 | model[k] = ref
439 | }
440 | case valueTypeArray:
441 | if next != nil {
442 | ref, err := traverseCollection(crs, kv, path, next, reqParams, k)
443 | if err != nil {
444 | return "", err
445 | }
446 | model[k] = ref
447 | }
448 | default:
449 | if next != nil {
450 | return "", fmt.Errorf("unexpected primitive value for property %s at %s", k, pathStr(path))
451 | }
452 | model[k] = kv
453 | }
454 | }
455 |
456 | // Create rid
457 | p := make([]interface{}, len(n.params))
458 | for j, pp := range n.params {
459 | switch pp.typ {
460 | case paramTypeURL:
461 | p[j] = reqParams[pp.name]
462 | case paramTypePath:
463 | p[j] = path[pp.idx]
464 | }
465 | }
466 | rid := fmt.Sprintf(n.pattern, p...)
467 |
468 | crs[rid] = cachedResource{
469 | typ: resourceTypeModel,
470 | model: model,
471 | }
472 | return res.Ref(rid), nil
473 | }
474 |
475 | func traverseCollection(crs map[string]cachedResource, v value, path []string, n *node, reqParams map[string]string, pathPart string) (res.Ref, error) {
476 | if n.typ != resourceTypeCollection {
477 | return "", fmt.Errorf("expected a collection at %s", pathStr(path))
478 | }
479 |
480 | if n.ptyp != pathTypeRoot {
481 | // Append path part
482 | path = append(path, pathPart)
483 | }
484 |
485 | collection := make([]interface{}, len(v.arr))
486 | for j, kv := range v.arr {
487 | next := n.param
488 |
489 | switch kv.typ {
490 | case valueTypeObject:
491 | if next != nil {
492 | ref, err := traverseModel(crs, kv, path, next, reqParams, strconv.Itoa(j))
493 | if err != nil {
494 | return "", err
495 | }
496 | collection[j] = ref
497 | }
498 | case valueTypeArray:
499 | if next != nil {
500 | ref, err := traverseCollection(crs, kv, path, next, reqParams, strconv.Itoa(j))
501 | if err != nil {
502 | return "", err
503 | }
504 | collection[j] = ref
505 | }
506 | default:
507 | if next != nil {
508 | return "", fmt.Errorf("unexpected primitive value for element %d at %s", j, pathStr(path))
509 | }
510 | collection[j] = kv
511 | }
512 | }
513 |
514 | // Create rid
515 | p := make([]interface{}, len(n.params))
516 | for k, pp := range n.params {
517 | switch pp.typ {
518 | case paramTypeURL:
519 | p[k] = reqParams[pp.name]
520 | case paramTypePath:
521 | p[k] = path[pp.idx]
522 | }
523 | }
524 | rid := fmt.Sprintf(n.pattern, p...)
525 |
526 | crs[rid] = cachedResource{
527 | typ: resourceTypeCollection,
528 | collection: collection,
529 | }
530 | return res.Ref(rid), nil
531 | }
532 |
533 | func pathStr(path []string) string {
534 | if len(path) == 0 {
535 | return "endpoint root"
536 | }
537 | return strings.Join(path, ".")
538 | }
539 |
--------------------------------------------------------------------------------