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  }