├── .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 |

Resgate logo

2 | 3 | 4 |

REST to RES service
Synchronize Your Clients

5 |

6 |

7 | License 8 | Report Card 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 | --------------------------------------------------------------------------------