github.com/pslzym/go-ethereum@v1.8.17-0.20180926104442-4b6824e07b1b/swarm/api/client/client.go (about)

     1  // Copyright 2017 The go-ethereum Authors
     2  // This file is part of the go-ethereum library.
     3  //
     4  // The go-ethereum library is free software: you can redistribute it and/or modify
     5  // it under the terms of the GNU Lesser General Public License as published by
     6  // the Free Software Foundation, either version 3 of the License, or
     7  // (at your option) any later version.
     8  //
     9  // The go-ethereum library is distributed in the hope that it will be useful,
    10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    12  // GNU Lesser General Public License for more details.
    13  //
    14  // You should have received a copy of the GNU Lesser General Public License
    15  // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
    16  
    17  package client
    18  
    19  import (
    20  	"archive/tar"
    21  	"bytes"
    22  	"encoding/json"
    23  	"errors"
    24  	"fmt"
    25  	"io"
    26  	"io/ioutil"
    27  	"mime"
    28  	"mime/multipart"
    29  	"net/http"
    30  	"net/textproto"
    31  	"os"
    32  	"path/filepath"
    33  	"regexp"
    34  	"strconv"
    35  	"strings"
    36  
    37  	"github.com/ethereum/go-ethereum/swarm/api"
    38  	"github.com/ethereum/go-ethereum/swarm/storage/mru"
    39  )
    40  
    41  var (
    42  	DefaultGateway = "http://localhost:8500"
    43  	DefaultClient  = NewClient(DefaultGateway)
    44  )
    45  
    46  var (
    47  	ErrUnauthorized = errors.New("unauthorized")
    48  )
    49  
    50  func NewClient(gateway string) *Client {
    51  	return &Client{
    52  		Gateway: gateway,
    53  	}
    54  }
    55  
    56  // Client wraps interaction with a swarm HTTP gateway.
    57  type Client struct {
    58  	Gateway string
    59  }
    60  
    61  // UploadRaw uploads raw data to swarm and returns the resulting hash. If toEncrypt is true it
    62  // uploads encrypted data
    63  func (c *Client) UploadRaw(r io.Reader, size int64, toEncrypt bool) (string, error) {
    64  	if size <= 0 {
    65  		return "", errors.New("data size must be greater than zero")
    66  	}
    67  	addr := ""
    68  	if toEncrypt {
    69  		addr = "encrypt"
    70  	}
    71  	req, err := http.NewRequest("POST", c.Gateway+"/bzz-raw:/"+addr, r)
    72  	if err != nil {
    73  		return "", err
    74  	}
    75  	req.ContentLength = size
    76  	res, err := http.DefaultClient.Do(req)
    77  	if err != nil {
    78  		return "", err
    79  	}
    80  	defer res.Body.Close()
    81  	if res.StatusCode != http.StatusOK {
    82  		return "", fmt.Errorf("unexpected HTTP status: %s", res.Status)
    83  	}
    84  	data, err := ioutil.ReadAll(res.Body)
    85  	if err != nil {
    86  		return "", err
    87  	}
    88  	return string(data), nil
    89  }
    90  
    91  // DownloadRaw downloads raw data from swarm and it returns a ReadCloser and a bool whether the
    92  // content was encrypted
    93  func (c *Client) DownloadRaw(hash string) (io.ReadCloser, bool, error) {
    94  	uri := c.Gateway + "/bzz-raw:/" + hash
    95  	res, err := http.DefaultClient.Get(uri)
    96  	if err != nil {
    97  		return nil, false, err
    98  	}
    99  	if res.StatusCode != http.StatusOK {
   100  		res.Body.Close()
   101  		return nil, false, fmt.Errorf("unexpected HTTP status: %s", res.Status)
   102  	}
   103  	isEncrypted := (res.Header.Get("X-Decrypted") == "true")
   104  	return res.Body, isEncrypted, nil
   105  }
   106  
   107  // File represents a file in a swarm manifest and is used for uploading and
   108  // downloading content to and from swarm
   109  type File struct {
   110  	io.ReadCloser
   111  	api.ManifestEntry
   112  }
   113  
   114  // Open opens a local file which can then be passed to client.Upload to upload
   115  // it to swarm
   116  func Open(path string) (*File, error) {
   117  	f, err := os.Open(path)
   118  	if err != nil {
   119  		return nil, err
   120  	}
   121  	stat, err := f.Stat()
   122  	if err != nil {
   123  		f.Close()
   124  		return nil, err
   125  	}
   126  	return &File{
   127  		ReadCloser: f,
   128  		ManifestEntry: api.ManifestEntry{
   129  			ContentType: mime.TypeByExtension(filepath.Ext(path)),
   130  			Mode:        int64(stat.Mode()),
   131  			Size:        stat.Size(),
   132  			ModTime:     stat.ModTime(),
   133  		},
   134  	}, nil
   135  }
   136  
   137  // Upload uploads a file to swarm and either adds it to an existing manifest
   138  // (if the manifest argument is non-empty) or creates a new manifest containing
   139  // the file, returning the resulting manifest hash (the file will then be
   140  // available at bzz:/<hash>/<path>)
   141  func (c *Client) Upload(file *File, manifest string, toEncrypt bool) (string, error) {
   142  	if file.Size <= 0 {
   143  		return "", errors.New("file size must be greater than zero")
   144  	}
   145  	return c.TarUpload(manifest, &FileUploader{file}, "", toEncrypt)
   146  }
   147  
   148  // Download downloads a file with the given path from the swarm manifest with
   149  // the given hash (i.e. it gets bzz:/<hash>/<path>)
   150  func (c *Client) Download(hash, path string) (*File, error) {
   151  	uri := c.Gateway + "/bzz:/" + hash + "/" + path
   152  	res, err := http.DefaultClient.Get(uri)
   153  	if err != nil {
   154  		return nil, err
   155  	}
   156  	if res.StatusCode != http.StatusOK {
   157  		res.Body.Close()
   158  		return nil, fmt.Errorf("unexpected HTTP status: %s", res.Status)
   159  	}
   160  	return &File{
   161  		ReadCloser: res.Body,
   162  		ManifestEntry: api.ManifestEntry{
   163  			ContentType: res.Header.Get("Content-Type"),
   164  			Size:        res.ContentLength,
   165  		},
   166  	}, nil
   167  }
   168  
   169  // UploadDirectory uploads a directory tree to swarm and either adds the files
   170  // to an existing manifest (if the manifest argument is non-empty) or creates a
   171  // new manifest, returning the resulting manifest hash (files from the
   172  // directory will then be available at bzz:/<hash>/path/to/file), with
   173  // the file specified in defaultPath being uploaded to the root of the manifest
   174  // (i.e. bzz:/<hash>/)
   175  func (c *Client) UploadDirectory(dir, defaultPath, manifest string, toEncrypt bool) (string, error) {
   176  	stat, err := os.Stat(dir)
   177  	if err != nil {
   178  		return "", err
   179  	} else if !stat.IsDir() {
   180  		return "", fmt.Errorf("not a directory: %s", dir)
   181  	}
   182  	if defaultPath != "" {
   183  		if _, err := os.Stat(filepath.Join(dir, defaultPath)); err != nil {
   184  			if os.IsNotExist(err) {
   185  				return "", fmt.Errorf("the default path %q was not found in the upload directory %q", defaultPath, dir)
   186  			}
   187  			return "", fmt.Errorf("default path: %v", err)
   188  		}
   189  	}
   190  	return c.TarUpload(manifest, &DirectoryUploader{dir}, defaultPath, toEncrypt)
   191  }
   192  
   193  // DownloadDirectory downloads the files contained in a swarm manifest under
   194  // the given path into a local directory (existing files will be overwritten)
   195  func (c *Client) DownloadDirectory(hash, path, destDir, credentials string) error {
   196  	stat, err := os.Stat(destDir)
   197  	if err != nil {
   198  		return err
   199  	} else if !stat.IsDir() {
   200  		return fmt.Errorf("not a directory: %s", destDir)
   201  	}
   202  
   203  	uri := c.Gateway + "/bzz:/" + hash + "/" + path
   204  	req, err := http.NewRequest("GET", uri, nil)
   205  	if err != nil {
   206  		return err
   207  	}
   208  	if credentials != "" {
   209  		req.SetBasicAuth("", credentials)
   210  	}
   211  	req.Header.Set("Accept", "application/x-tar")
   212  	res, err := http.DefaultClient.Do(req)
   213  	if err != nil {
   214  		return err
   215  	}
   216  	defer res.Body.Close()
   217  	switch res.StatusCode {
   218  	case http.StatusOK:
   219  	case http.StatusUnauthorized:
   220  		return ErrUnauthorized
   221  	default:
   222  		return fmt.Errorf("unexpected HTTP status: %s", res.Status)
   223  	}
   224  	tr := tar.NewReader(res.Body)
   225  	for {
   226  		hdr, err := tr.Next()
   227  		if err == io.EOF {
   228  			return nil
   229  		} else if err != nil {
   230  			return err
   231  		}
   232  		// ignore the default path file
   233  		if hdr.Name == "" {
   234  			continue
   235  		}
   236  
   237  		dstPath := filepath.Join(destDir, filepath.Clean(strings.TrimPrefix(hdr.Name, path)))
   238  		if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
   239  			return err
   240  		}
   241  		var mode os.FileMode = 0644
   242  		if hdr.Mode > 0 {
   243  			mode = os.FileMode(hdr.Mode)
   244  		}
   245  		dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
   246  		if err != nil {
   247  			return err
   248  		}
   249  		n, err := io.Copy(dst, tr)
   250  		dst.Close()
   251  		if err != nil {
   252  			return err
   253  		} else if n != hdr.Size {
   254  			return fmt.Errorf("expected %s to be %d bytes but got %d", hdr.Name, hdr.Size, n)
   255  		}
   256  	}
   257  }
   258  
   259  // DownloadFile downloads a single file into the destination directory
   260  // if the manifest entry does not specify a file name - it will fallback
   261  // to the hash of the file as a filename
   262  func (c *Client) DownloadFile(hash, path, dest, credentials string) error {
   263  	hasDestinationFilename := false
   264  	if stat, err := os.Stat(dest); err == nil {
   265  		hasDestinationFilename = !stat.IsDir()
   266  	} else {
   267  		if os.IsNotExist(err) {
   268  			// does not exist - should be created
   269  			hasDestinationFilename = true
   270  		} else {
   271  			return fmt.Errorf("could not stat path: %v", err)
   272  		}
   273  	}
   274  
   275  	manifestList, err := c.List(hash, path, credentials)
   276  	if err != nil {
   277  		return err
   278  	}
   279  
   280  	switch len(manifestList.Entries) {
   281  	case 0:
   282  		return fmt.Errorf("could not find path requested at manifest address. make sure the path you've specified is correct")
   283  	case 1:
   284  		//continue
   285  	default:
   286  		return fmt.Errorf("got too many matches for this path")
   287  	}
   288  
   289  	uri := c.Gateway + "/bzz:/" + hash + "/" + path
   290  	req, err := http.NewRequest("GET", uri, nil)
   291  	if err != nil {
   292  		return err
   293  	}
   294  	if credentials != "" {
   295  		req.SetBasicAuth("", credentials)
   296  	}
   297  	res, err := http.DefaultClient.Do(req)
   298  	if err != nil {
   299  		return err
   300  	}
   301  	defer res.Body.Close()
   302  	switch res.StatusCode {
   303  	case http.StatusOK:
   304  	case http.StatusUnauthorized:
   305  		return ErrUnauthorized
   306  	default:
   307  		return fmt.Errorf("unexpected HTTP status: expected 200 OK, got %d", res.StatusCode)
   308  	}
   309  	filename := ""
   310  	if hasDestinationFilename {
   311  		filename = dest
   312  	} else {
   313  		// try to assert
   314  		re := regexp.MustCompile("[^/]+$") //everything after last slash
   315  
   316  		if results := re.FindAllString(path, -1); len(results) > 0 {
   317  			filename = results[len(results)-1]
   318  		} else {
   319  			if entry := manifestList.Entries[0]; entry.Path != "" && entry.Path != "/" {
   320  				filename = entry.Path
   321  			} else {
   322  				// assume hash as name if there's nothing from the command line
   323  				filename = hash
   324  			}
   325  		}
   326  		filename = filepath.Join(dest, filename)
   327  	}
   328  	filePath, err := filepath.Abs(filename)
   329  	if err != nil {
   330  		return err
   331  	}
   332  
   333  	if err := os.MkdirAll(filepath.Dir(filePath), 0777); err != nil {
   334  		return err
   335  	}
   336  
   337  	dst, err := os.Create(filename)
   338  	if err != nil {
   339  		return err
   340  	}
   341  	defer dst.Close()
   342  
   343  	_, err = io.Copy(dst, res.Body)
   344  	return err
   345  }
   346  
   347  // UploadManifest uploads the given manifest to swarm
   348  func (c *Client) UploadManifest(m *api.Manifest, toEncrypt bool) (string, error) {
   349  	data, err := json.Marshal(m)
   350  	if err != nil {
   351  		return "", err
   352  	}
   353  	return c.UploadRaw(bytes.NewReader(data), int64(len(data)), toEncrypt)
   354  }
   355  
   356  // DownloadManifest downloads a swarm manifest
   357  func (c *Client) DownloadManifest(hash string) (*api.Manifest, bool, error) {
   358  	res, isEncrypted, err := c.DownloadRaw(hash)
   359  	if err != nil {
   360  		return nil, isEncrypted, err
   361  	}
   362  	defer res.Close()
   363  	var manifest api.Manifest
   364  	if err := json.NewDecoder(res).Decode(&manifest); err != nil {
   365  		return nil, isEncrypted, err
   366  	}
   367  	return &manifest, isEncrypted, nil
   368  }
   369  
   370  // List list files in a swarm manifest which have the given prefix, grouping
   371  // common prefixes using "/" as a delimiter.
   372  //
   373  // For example, if the manifest represents the following directory structure:
   374  //
   375  // file1.txt
   376  // file2.txt
   377  // dir1/file3.txt
   378  // dir1/dir2/file4.txt
   379  //
   380  // Then:
   381  //
   382  // - a prefix of ""      would return [dir1/, file1.txt, file2.txt]
   383  // - a prefix of "file"  would return [file1.txt, file2.txt]
   384  // - a prefix of "dir1/" would return [dir1/dir2/, dir1/file3.txt]
   385  //
   386  // where entries ending with "/" are common prefixes.
   387  func (c *Client) List(hash, prefix, credentials string) (*api.ManifestList, error) {
   388  	req, err := http.NewRequest(http.MethodGet, c.Gateway+"/bzz-list:/"+hash+"/"+prefix, nil)
   389  	if err != nil {
   390  		return nil, err
   391  	}
   392  	if credentials != "" {
   393  		req.SetBasicAuth("", credentials)
   394  	}
   395  	res, err := http.DefaultClient.Do(req)
   396  	if err != nil {
   397  		return nil, err
   398  	}
   399  	defer res.Body.Close()
   400  	switch res.StatusCode {
   401  	case http.StatusOK:
   402  	case http.StatusUnauthorized:
   403  		return nil, ErrUnauthorized
   404  	default:
   405  		return nil, fmt.Errorf("unexpected HTTP status: %s", res.Status)
   406  	}
   407  	var list api.ManifestList
   408  	if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
   409  		return nil, err
   410  	}
   411  	return &list, nil
   412  }
   413  
   414  // Uploader uploads files to swarm using a provided UploadFn
   415  type Uploader interface {
   416  	Upload(UploadFn) error
   417  }
   418  
   419  type UploaderFunc func(UploadFn) error
   420  
   421  func (u UploaderFunc) Upload(upload UploadFn) error {
   422  	return u(upload)
   423  }
   424  
   425  // DirectoryUploader uploads all files in a directory, optionally uploading
   426  // a file to the default path
   427  type DirectoryUploader struct {
   428  	Dir string
   429  }
   430  
   431  // Upload performs the upload of the directory and default path
   432  func (d *DirectoryUploader) Upload(upload UploadFn) error {
   433  	return filepath.Walk(d.Dir, func(path string, f os.FileInfo, err error) error {
   434  		if err != nil {
   435  			return err
   436  		}
   437  		if f.IsDir() {
   438  			return nil
   439  		}
   440  		file, err := Open(path)
   441  		if err != nil {
   442  			return err
   443  		}
   444  		relPath, err := filepath.Rel(d.Dir, path)
   445  		if err != nil {
   446  			return err
   447  		}
   448  		file.Path = filepath.ToSlash(relPath)
   449  		return upload(file)
   450  	})
   451  }
   452  
   453  // FileUploader uploads a single file
   454  type FileUploader struct {
   455  	File *File
   456  }
   457  
   458  // Upload performs the upload of the file
   459  func (f *FileUploader) Upload(upload UploadFn) error {
   460  	return upload(f.File)
   461  }
   462  
   463  // UploadFn is the type of function passed to an Uploader to perform the upload
   464  // of a single file (for example, a directory uploader would call a provided
   465  // UploadFn for each file in the directory tree)
   466  type UploadFn func(file *File) error
   467  
   468  // TarUpload uses the given Uploader to upload files to swarm as a tar stream,
   469  // returning the resulting manifest hash
   470  func (c *Client) TarUpload(hash string, uploader Uploader, defaultPath string, toEncrypt bool) (string, error) {
   471  	reqR, reqW := io.Pipe()
   472  	defer reqR.Close()
   473  	addr := hash
   474  
   475  	// If there is a hash already (a manifest), then that manifest will determine if the upload has
   476  	// to be encrypted or not. If there is no manifest then the toEncrypt parameter decides if
   477  	// there is encryption or not.
   478  	if hash == "" && toEncrypt {
   479  		// This is the built-in address for the encrypted upload endpoint
   480  		addr = "encrypt"
   481  	}
   482  	req, err := http.NewRequest("POST", c.Gateway+"/bzz:/"+addr, reqR)
   483  	if err != nil {
   484  		return "", err
   485  	}
   486  	req.Header.Set("Content-Type", "application/x-tar")
   487  	if defaultPath != "" {
   488  		q := req.URL.Query()
   489  		q.Set("defaultpath", defaultPath)
   490  		req.URL.RawQuery = q.Encode()
   491  	}
   492  
   493  	// use 'Expect: 100-continue' so we don't send the request body if
   494  	// the server refuses the request
   495  	req.Header.Set("Expect", "100-continue")
   496  
   497  	tw := tar.NewWriter(reqW)
   498  
   499  	// define an UploadFn which adds files to the tar stream
   500  	uploadFn := func(file *File) error {
   501  		hdr := &tar.Header{
   502  			Name:    file.Path,
   503  			Mode:    file.Mode,
   504  			Size:    file.Size,
   505  			ModTime: file.ModTime,
   506  			Xattrs: map[string]string{
   507  				"user.swarm.content-type": file.ContentType,
   508  			},
   509  		}
   510  		if err := tw.WriteHeader(hdr); err != nil {
   511  			return err
   512  		}
   513  		_, err = io.Copy(tw, file)
   514  		return err
   515  	}
   516  
   517  	// run the upload in a goroutine so we can send the request headers and
   518  	// wait for a '100 Continue' response before sending the tar stream
   519  	go func() {
   520  		err := uploader.Upload(uploadFn)
   521  		if err == nil {
   522  			err = tw.Close()
   523  		}
   524  		reqW.CloseWithError(err)
   525  	}()
   526  
   527  	res, err := http.DefaultClient.Do(req)
   528  	if err != nil {
   529  		return "", err
   530  	}
   531  	defer res.Body.Close()
   532  	if res.StatusCode != http.StatusOK {
   533  		return "", fmt.Errorf("unexpected HTTP status: %s", res.Status)
   534  	}
   535  	data, err := ioutil.ReadAll(res.Body)
   536  	if err != nil {
   537  		return "", err
   538  	}
   539  	return string(data), nil
   540  }
   541  
   542  // MultipartUpload uses the given Uploader to upload files to swarm as a
   543  // multipart form, returning the resulting manifest hash
   544  func (c *Client) MultipartUpload(hash string, uploader Uploader) (string, error) {
   545  	reqR, reqW := io.Pipe()
   546  	defer reqR.Close()
   547  	req, err := http.NewRequest("POST", c.Gateway+"/bzz:/"+hash, reqR)
   548  	if err != nil {
   549  		return "", err
   550  	}
   551  
   552  	// use 'Expect: 100-continue' so we don't send the request body if
   553  	// the server refuses the request
   554  	req.Header.Set("Expect", "100-continue")
   555  
   556  	mw := multipart.NewWriter(reqW)
   557  	req.Header.Set("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%q", mw.Boundary()))
   558  
   559  	// define an UploadFn which adds files to the multipart form
   560  	uploadFn := func(file *File) error {
   561  		hdr := make(textproto.MIMEHeader)
   562  		hdr.Set("Content-Disposition", fmt.Sprintf("form-data; name=%q", file.Path))
   563  		hdr.Set("Content-Type", file.ContentType)
   564  		hdr.Set("Content-Length", strconv.FormatInt(file.Size, 10))
   565  		w, err := mw.CreatePart(hdr)
   566  		if err != nil {
   567  			return err
   568  		}
   569  		_, err = io.Copy(w, file)
   570  		return err
   571  	}
   572  
   573  	// run the upload in a goroutine so we can send the request headers and
   574  	// wait for a '100 Continue' response before sending the multipart form
   575  	go func() {
   576  		err := uploader.Upload(uploadFn)
   577  		if err == nil {
   578  			err = mw.Close()
   579  		}
   580  		reqW.CloseWithError(err)
   581  	}()
   582  
   583  	res, err := http.DefaultClient.Do(req)
   584  	if err != nil {
   585  		return "", err
   586  	}
   587  	defer res.Body.Close()
   588  	if res.StatusCode != http.StatusOK {
   589  		return "", fmt.Errorf("unexpected HTTP status: %s", res.Status)
   590  	}
   591  	data, err := ioutil.ReadAll(res.Body)
   592  	if err != nil {
   593  		return "", err
   594  	}
   595  	return string(data), nil
   596  }
   597  
   598  // CreateResource creates a Mutable Resource with the given name and frequency, initializing it with the provided
   599  // data. Data is interpreted as multihash or not depending on the multihash parameter.
   600  // startTime=0 means "now"
   601  // Returns the resulting Mutable Resource manifest address that you can use to include in an ENS Resolver (setContent)
   602  // or reference future updates (Client.UpdateResource)
   603  func (c *Client) CreateResource(request *mru.Request) (string, error) {
   604  	responseStream, err := c.updateResource(request)
   605  	if err != nil {
   606  		return "", err
   607  	}
   608  	defer responseStream.Close()
   609  
   610  	body, err := ioutil.ReadAll(responseStream)
   611  	if err != nil {
   612  		return "", err
   613  	}
   614  
   615  	var manifestAddress string
   616  	if err = json.Unmarshal(body, &manifestAddress); err != nil {
   617  		return "", err
   618  	}
   619  	return manifestAddress, nil
   620  }
   621  
   622  // UpdateResource allows you to set a new version of your content
   623  func (c *Client) UpdateResource(request *mru.Request) error {
   624  	_, err := c.updateResource(request)
   625  	return err
   626  }
   627  
   628  func (c *Client) updateResource(request *mru.Request) (io.ReadCloser, error) {
   629  	body, err := request.MarshalJSON()
   630  	if err != nil {
   631  		return nil, err
   632  	}
   633  
   634  	req, err := http.NewRequest("POST", c.Gateway+"/bzz-resource:/", bytes.NewBuffer(body))
   635  	if err != nil {
   636  		return nil, err
   637  	}
   638  
   639  	res, err := http.DefaultClient.Do(req)
   640  	if err != nil {
   641  		return nil, err
   642  	}
   643  
   644  	return res.Body, nil
   645  
   646  }
   647  
   648  // GetResource returns a byte stream with the raw content of the resource
   649  // manifestAddressOrDomain is the address you obtained in CreateResource or an ENS domain whose Resolver
   650  // points to that address
   651  func (c *Client) GetResource(manifestAddressOrDomain string) (io.ReadCloser, error) {
   652  
   653  	res, err := http.Get(c.Gateway + "/bzz-resource:/" + manifestAddressOrDomain)
   654  	if err != nil {
   655  		return nil, err
   656  	}
   657  	return res.Body, nil
   658  
   659  }
   660  
   661  // GetResourceMetadata returns a structure that describes the Mutable Resource
   662  // manifestAddressOrDomain is the address you obtained in CreateResource or an ENS domain whose Resolver
   663  // points to that address
   664  func (c *Client) GetResourceMetadata(manifestAddressOrDomain string) (*mru.Request, error) {
   665  
   666  	responseStream, err := c.GetResource(manifestAddressOrDomain + "/meta")
   667  	if err != nil {
   668  		return nil, err
   669  	}
   670  	defer responseStream.Close()
   671  
   672  	body, err := ioutil.ReadAll(responseStream)
   673  	if err != nil {
   674  		return nil, err
   675  	}
   676  
   677  	var metadata mru.Request
   678  	if err := metadata.UnmarshalJSON(body); err != nil {
   679  		return nil, err
   680  	}
   681  	return &metadata, nil
   682  }