github.com/viant/toolbox@v0.34.5/url/resource.go (about) 1 package url 2 3 import ( 4 "bytes" 5 "encoding/base64" 6 "fmt" 7 "github.com/viant/toolbox" 8 "github.com/viant/toolbox/storage" 9 "gopkg.in/yaml.v2" 10 "io/ioutil" 11 "net/url" 12 "os" 13 "path" 14 "strings" 15 "time" 16 ) 17 18 //Resource represents a URL based resource, with enriched meta info 19 type Resource struct { 20 URL string `description:"resource URL or relative or absolute path" required:"true"` //URL of resource 21 Credentials string `description:"credentials file"` //name of credential file or credential key depending on implementation 22 ParsedURL *url.URL `json:"-"` //parsed URL resource 23 Cache string `description:"local cache path"` //Cache path for the resource, if specified resource will be cached in the specified path 24 CustomKey *AES256Key `description:" content encryption key"` 25 CacheExpiryMs int //CacheExpiryMs expiry time in ms 26 modificationTag int64 27 init string 28 } 29 30 //Clone creates a clone of the resource 31 func (r *Resource) Clone() *Resource { 32 return &Resource{ 33 init: r.init, 34 URL: r.URL, 35 Credentials: r.Credentials, 36 ParsedURL: r.ParsedURL, 37 Cache: r.Cache, 38 CacheExpiryMs: r.CacheExpiryMs, 39 } 40 } 41 42 var defaultSchemePorts = map[string]int{ 43 "ssh": 22, 44 "scp": 22, 45 "http": 80, 46 "https": 443, 47 } 48 49 //Host returns url's host name with user name if user name is part of url 50 func (r *Resource) Host() string { 51 result := r.ParsedURL.Hostname() + ":" + r.Port() 52 if r.ParsedURL.User != nil { 53 result = r.ParsedURL.User.Username() + "@" + result 54 } 55 return result 56 } 57 58 //CredentialURL returns url's with provided credential 59 func (r *Resource) CredentialURL(username, password string) string { 60 var urlCredential = "" 61 if username != "" { 62 urlCredential = username 63 if password != "" { 64 urlCredential += ":" + password 65 } 66 urlCredential += "@" 67 } 68 result := r.ParsedURL.Scheme + "://" + urlCredential + r.ParsedURL.Hostname() + ":" + r.Port() + r.ParsedURL.Path 69 if r.ParsedURL.RawQuery != "" { 70 result += "?" + r.ParsedURL.RawQuery 71 } 72 73 return result 74 } 75 76 //Path returns url's path directory, assumption is that directory does not have extension, if path ends with '/' it is being stripped. 77 func (r *Resource) DirectoryPath() string { 78 if r.ParsedURL == nil { 79 return "" 80 } 81 var result = r.ParsedURL.Path 82 83 parent, name := path.Split(result) 84 if path.Ext(name) != "" { 85 result = parent 86 } 87 if strings.HasSuffix(result, "/") { 88 result = string(result[:len(result)-1]) 89 } 90 return result 91 } 92 93 //Port returns url's port 94 func (r *Resource) Port() string { 95 port := r.ParsedURL.Port() 96 if port == "" && r.ParsedURL != nil { 97 if value, ok := defaultSchemePorts[r.ParsedURL.Scheme]; ok { 98 port = toolbox.AsString(value) 99 } 100 } 101 return port 102 } 103 104 //Download downloads data from URL, it returns data as []byte, or error, if resource is cacheable it first look into cache 105 func (r *Resource) Download() ([]byte, error) { 106 if r == nil { 107 return nil, fmt.Errorf("Fail to download content on empty resource") 108 } 109 if r.Cachable() { 110 content := r.readFromCache() 111 if content != nil { 112 return content, nil 113 } 114 } 115 service, err := storage.NewServiceForURL(r.URL, r.Credentials) 116 if err != nil { 117 return nil, err 118 } 119 object, err := service.StorageObject(r.URL) 120 if err != nil { 121 return nil, err 122 } 123 reader, err := service.Download(object) 124 if err != nil { 125 return nil, err 126 } 127 defer reader.Close() 128 content, err := ioutil.ReadAll(reader) 129 if err != nil { 130 return nil, err 131 } 132 if r.Cachable() { 133 _ = ioutil.WriteFile(r.Cache, content, 0666) 134 } 135 return content, err 136 } 137 138 //DownloadText returns a text downloaded from url 139 func (r *Resource) DownloadText() (string, error) { 140 var result, err = r.Download() 141 if err != nil { 142 return "", err 143 } 144 return string(result), err 145 } 146 147 //Decode decodes url's data into target, it support JSON and YAML exp. 148 func (r *Resource) Decode(target interface{}) (err error) { 149 defer func() { 150 if err != nil { 151 err = fmt.Errorf("failed to decode: %v, %v", r.URL, err) 152 } 153 }() 154 if r.ParsedURL == nil { 155 if r.ParsedURL, err = url.Parse(r.URL); err != nil { 156 return err 157 } 158 } 159 ext := path.Ext(r.ParsedURL.Path) 160 switch ext { 161 case ".yaml", ".yml": 162 err = r.YAMLDecode(target) 163 default: 164 err = r.JSONDecode(target) 165 } 166 return err 167 } 168 169 //DecoderFactory returns new decoder factory for resource 170 func (r *Resource) DecoderFactory() toolbox.DecoderFactory { 171 ext := path.Ext(r.ParsedURL.Path) 172 switch ext { 173 case ".yaml", ".yml": 174 return toolbox.NewYamlDecoderFactory() 175 default: 176 return toolbox.NewJSONDecoderFactory() 177 } 178 } 179 180 //Decode decodes url's data into target, it takes decoderFactory which decodes data into target 181 func (r *Resource) DecodeWith(target interface{}, decoderFactory toolbox.DecoderFactory) error { 182 if r == nil { 183 return fmt.Errorf("fail to %T decode on empty resource", decoderFactory) 184 } 185 if decoderFactory == nil { 186 return fmt.Errorf("fail to decode %v, decoderFactory was empty", r.URL) 187 } 188 var content, err = r.Download() 189 if err != nil { 190 return err 191 } 192 193 text := string(content) 194 if toolbox.IsNewLineDelimitedJSON(text) { 195 if aSlice, err := toolbox.NewLineDelimitedJSON(text); err == nil { 196 return toolbox.DefaultConverter.AssignConverted(target, aSlice) 197 } 198 } 199 err = decoderFactory.Create(bytes.NewReader(content)).Decode(target) 200 if err != nil { 201 return fmt.Errorf("failed to decode: %v, payload: %s", err, content) 202 } 203 return err 204 } 205 206 //Rename renames URI name of this resource 207 func (r *Resource) Rename(name string) (err error) { 208 var _, currentName = toolbox.URLSplit(r.URL) 209 if currentName == "" && strings.HasSuffix(r.URL, "/") { 210 _, currentName = toolbox.URLSplit(r.URL[:len(r.URL)-1]) 211 currentName += "/" 212 } 213 214 r.URL = strings.Replace(r.URL, currentName, name, 1) 215 r.ParsedURL, err = url.Parse(r.URL) 216 return err 217 } 218 219 //JSONDecode decodes json resource into target 220 func (r *Resource) JSONDecode(target interface{}) error { 221 return r.DecodeWith(target, toolbox.NewJSONDecoderFactory()) 222 } 223 224 //JSONDecode decodes yaml resource into target 225 func (r *Resource) YAMLDecode(target interface{}) error { 226 if interfacePrt, ok := target.(*interface{}); ok { 227 var data interface{} 228 if err := r.DecodeWith(&data, toolbox.NewYamlDecoderFactory()); err != nil { 229 return err 230 } 231 if toolbox.IsSlice(data) { 232 *interfacePrt = data 233 return nil 234 } 235 } 236 var mapSlice = yaml.MapSlice{} 237 if err := r.DecodeWith(&mapSlice, toolbox.NewYamlDecoderFactory()); err != nil { 238 return err 239 } 240 if !toolbox.IsMap(target) { 241 return toolbox.DefaultConverter.AssignConverted(target, mapSlice) 242 } 243 resultMap := toolbox.AsMap(target) 244 for _, v := range mapSlice { 245 resultMap[toolbox.AsString(v.Key)] = v.Value 246 } 247 return nil 248 } 249 250 func (r *Resource) readFromCache() []byte { 251 if toolbox.FileExists(r.Cache) { 252 info, err := os.Stat(r.Cache) 253 var isExpired = false 254 if err == nil && r.CacheExpiryMs > 0 { 255 elapsed := time.Now().Sub(info.ModTime()) 256 isExpired = elapsed > time.Second*time.Duration(r.CacheExpiryMs) 257 } 258 content, err := ioutil.ReadFile(r.Cache) 259 if err == nil && !isExpired { 260 return content 261 } 262 } 263 return nil 264 } 265 266 //Cachable returns true if resource is cachable 267 func (r *Resource) Cachable() bool { 268 return r.Cache != "" 269 } 270 271 func computeResourceModificationTag(resource *Resource) (int64, error) { 272 service, err := storage.NewServiceForURL(resource.URL, resource.Credentials) 273 if err != nil { 274 return 0, err 275 } 276 object, err := service.StorageObject(resource.URL) 277 if err != nil { 278 return 0, err 279 } 280 var fileInfo = object.FileInfo() 281 282 if object.IsContent() { 283 return fileInfo.Size() + fileInfo.ModTime().UnixNano(), nil 284 } 285 var result int64 = 0 286 objects, err := service.List(resource.URL) 287 if err != nil { 288 return 0, err 289 } 290 for _, object := range objects { 291 objectResource := NewResource(object.URL()) 292 if objectResource.ParsedURL.Path == resource.ParsedURL.Path { 293 continue 294 } 295 modificationTag, err := computeResourceModificationTag(NewResource(object.URL(), resource.Credentials)) 296 if err != nil { 297 return 0, err 298 } 299 result += modificationTag 300 301 } 302 return result, nil 303 } 304 305 func (r *Resource) HasChanged() (changed bool, err error) { 306 if r.modificationTag == 0 { 307 r.modificationTag, err = computeResourceModificationTag(r) 308 return false, err 309 } 310 var recentModificationTag int64 311 recentModificationTag, err = computeResourceModificationTag(r) 312 if err != nil { 313 return false, err 314 } 315 if recentModificationTag != r.modificationTag { 316 changed = true 317 r.modificationTag = recentModificationTag 318 } 319 return changed, err 320 } 321 322 func normalizeURL(URL string) string { 323 if strings.Contains(URL, "://") { 324 var protoPosition = strings.Index(URL, "://") 325 if protoPosition != -1 { 326 var urlSuffix = string(URL[protoPosition+3:]) 327 urlSuffix = strings.Replace(urlSuffix, "//", "/", len(urlSuffix)) 328 URL = string(URL[:protoPosition+3]) + urlSuffix 329 } 330 return URL 331 } 332 if !strings.HasPrefix(URL, "/") { 333 currentDirectory, _ := os.Getwd() 334 335 if strings.Contains(URL, "..") { 336 fragments := strings.Split(URL, "/") 337 var index = 0 338 var offset = 0 339 if fragments[0] == "." { 340 offset = 1 341 } 342 343 for index = offset; index < len(fragments); index++ { 344 var fragment = fragments[index] 345 if fragment == ".." { 346 currentDirectory, _ = path.Split(currentDirectory) 347 if strings.HasSuffix(currentDirectory, "/") { 348 currentDirectory = string(currentDirectory[:len(currentDirectory)-1]) 349 } 350 continue 351 } 352 break 353 } 354 return toolbox.FileSchema + path.Join(currentDirectory, strings.Join(fragments[index:], "/")) 355 } 356 357 currentDirectory, err := os.Getwd() 358 if err == nil { 359 candidate := path.Join(currentDirectory, URL) 360 URL = candidate 361 } 362 } 363 return toolbox.FileSchema + URL 364 } 365 366 func (r *Resource) Init() (err error) { 367 if r.init == r.URL { 368 return nil 369 } 370 r.init = r.URL 371 r.URL = normalizeURL(r.URL) 372 r.ParsedURL, err = url.Parse(r.URL) 373 return err 374 } 375 376 //DownloadBase64 loads base64 resource content 377 func (r *Resource) DownloadBase64() (string, error) { 378 storageService, err := storage.NewServiceForURL(r.URL, r.Credentials) 379 if err != nil { 380 return "", err 381 } 382 reader, err := storage.Download(storageService, r.URL) 383 if err != nil { 384 return "", err 385 } 386 defer func() { 387 _ = reader.Close() 388 }() 389 data, err := ioutil.ReadAll(reader) 390 if err != nil { 391 return "", err 392 } 393 _, err = base64.StdEncoding.DecodeString(string(data)) 394 if err == nil { 395 return string(data), nil 396 } 397 return base64.StdEncoding.EncodeToString(data), nil 398 } 399 400 //NewResource returns a new resource for provided URL, followed by optional credential, cache and cache expiryMs. 401 func NewResource(Params ...interface{}) *Resource { 402 if len(Params) == 0 { 403 return nil 404 } 405 var URL = toolbox.AsString(Params[0]) 406 URL = normalizeURL(URL) 407 408 var credential string 409 if len(Params) > 1 { 410 credential = toolbox.AsString(Params[1]) 411 } 412 var cache string 413 if len(Params) > 2 { 414 cache = toolbox.AsString(Params[2]) 415 } 416 var cacheExpiryMs int 417 if len(Params) > 3 { 418 cacheExpiryMs = toolbox.AsInt(Params[3]) 419 } 420 parsedURL, _ := url.Parse(URL) 421 return &Resource{ 422 init: URL, 423 ParsedURL: parsedURL, 424 URL: URL, 425 Credentials: credential, 426 Cache: cache, 427 CacheExpiryMs: cacheExpiryMs, 428 } 429 }