github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/charmhub/client.go (about)

     1  // Copyright 2020 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  // CharmHub is a client for communication with charmHub.  Unlike
     5  // the charmHub client within juju, this package does not rely on
     6  // wrapping an external package client. Generic client code for this
     7  // package has been copied from "github.com/juju/charmrepo/v7/csclient".
     8  //
     9  // TODO: (hml) 2020-06-17
    10  // Implement:
    11  // - use of macaroons, at that time consider refactoring the local
    12  //   charmHub pkg to share macaroonJar.
    13  // - user/password ?
    14  // - allow for use of the channel pieces
    15  
    16  package charmhub
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"io"
    22  	"net/url"
    23  	"path"
    24  	"strings"
    25  	"time"
    26  
    27  	"github.com/juju/charm/v12"
    28  	"github.com/juju/errors"
    29  	"github.com/juju/loggo"
    30  
    31  	charmhubpath "github.com/juju/juju/charmhub/path"
    32  	"github.com/juju/juju/charmhub/transport"
    33  	charmmetrics "github.com/juju/juju/core/charm/metrics"
    34  	corelogger "github.com/juju/juju/core/logger"
    35  )
    36  
    37  const (
    38  	// DefaultServerURL is the default location of the global Charmhub API.
    39  	// An alternate location can be configured by changing the URL
    40  	// field in the Config struct.
    41  	DefaultServerURL = "https://api.charmhub.io"
    42  
    43  	// RefreshTimeout is the timout callers should use for Refresh calls.
    44  	RefreshTimeout = 10 * time.Second
    45  )
    46  
    47  const (
    48  	serverVersion = "v2"
    49  	serverEntity  = "charms"
    50  )
    51  
    52  // Logger is the interface to use for logging requests and errors.
    53  type Logger interface {
    54  	IsTraceEnabled() bool
    55  
    56  	Errorf(string, ...interface{})
    57  	Tracef(string, ...interface{})
    58  
    59  	ChildWithLabels(string, ...string) loggo.Logger
    60  }
    61  
    62  // Config holds configuration for creating a new charm hub client.
    63  // The zero value is a valid default configuration.
    64  type Config struct {
    65  	// Logger to use during the API requests. This field is required.
    66  	Logger Logger
    67  
    68  	// URL holds the base endpoint URL of the Charmhub API,
    69  	// with no trailing slash, not including the version.
    70  	// If empty string, use the default Charmhub API server.
    71  	URL string
    72  
    73  	// HTTPClient represents the HTTP client to use for all API
    74  	// requests. If nil, use the default HTTP client.
    75  	HTTPClient HTTPClient
    76  
    77  	// FileSystem represents the file system operations for downloading.
    78  	// If nil, use the real OS file system.
    79  	FileSystem FileSystem
    80  }
    81  
    82  // basePath returns the base configuration path for speaking to the server API.
    83  func basePath(configURL string) (charmhubpath.Path, error) {
    84  	baseURL := strings.TrimRight(configURL, "/")
    85  	rawURL := fmt.Sprintf("%s/%s", baseURL, path.Join(serverVersion, serverEntity))
    86  	url, err := url.Parse(rawURL)
    87  	if err != nil {
    88  		return charmhubpath.Path{}, errors.Trace(err)
    89  	}
    90  	return charmhubpath.MakePath(url), nil
    91  }
    92  
    93  // Client represents the client side of a charm store.
    94  type Client struct {
    95  	url             string
    96  	infoClient      *infoClient
    97  	findClient      *findClient
    98  	downloadClient  *downloadClient
    99  	refreshClient   *refreshClient
   100  	resourcesClient *resourcesClient
   101  	logger          Logger
   102  }
   103  
   104  // NewClient creates a new Charmhub client from the supplied configuration.
   105  func NewClient(config Config) (*Client, error) {
   106  	logger := config.Logger
   107  	if logger == nil {
   108  		return nil, errors.NotValidf("nil logger")
   109  	}
   110  	logger = logger.ChildWithLabels("client", corelogger.CHARMHUB)
   111  
   112  	url := config.URL
   113  	if url == "" {
   114  		url = DefaultServerURL
   115  	}
   116  
   117  	httpClient := config.HTTPClient
   118  	if httpClient == nil {
   119  		httpClient = DefaultHTTPClient(logger)
   120  	}
   121  
   122  	fs := config.FileSystem
   123  	if fs == nil {
   124  		fs = fileSystem{}
   125  	}
   126  
   127  	base, err := basePath(url)
   128  	if err != nil {
   129  		return nil, errors.Trace(err)
   130  	}
   131  
   132  	infoPath, err := base.Join("info")
   133  	if err != nil {
   134  		return nil, errors.Annotate(err, "constructing info path")
   135  	}
   136  
   137  	findPath, err := base.Join("find")
   138  	if err != nil {
   139  		return nil, errors.Annotate(err, "constructing find path")
   140  	}
   141  
   142  	refreshPath, err := base.Join("refresh")
   143  	if err != nil {
   144  		return nil, errors.Annotate(err, "constructing refresh path")
   145  	}
   146  
   147  	resourcesPath, err := base.Join("resources")
   148  	if err != nil {
   149  		return nil, errors.Annotate(err, "constructing resources path")
   150  	}
   151  
   152  	logger.Tracef("NewClient to %q", url)
   153  
   154  	apiRequester := newAPIRequester(httpClient, logger)
   155  	apiRequestLogger := newAPIRequesterLogger(apiRequester, logger)
   156  	restClient := newHTTPRESTClient(apiRequestLogger)
   157  
   158  	return &Client{
   159  		url:           base.String(),
   160  		infoClient:    newInfoClient(infoPath, restClient, logger),
   161  		findClient:    newFindClient(findPath, restClient, logger),
   162  		refreshClient: newRefreshClient(refreshPath, restClient, logger),
   163  		// download client doesn't require a path here, as the download could
   164  		// be from any server in theory. That information is found from the
   165  		// refresh response.
   166  		downloadClient:  newDownloadClient(httpClient, fs, logger),
   167  		resourcesClient: newResourcesClient(resourcesPath, restClient, logger),
   168  		logger:          logger,
   169  	}, nil
   170  }
   171  
   172  // URL returns the underlying store URL.
   173  func (c *Client) URL() string {
   174  	return c.url
   175  }
   176  
   177  // Info returns charm info on the provided charm name from CharmHub API.
   178  func (c *Client) Info(ctx context.Context, name string, options ...InfoOption) (transport.InfoResponse, error) {
   179  	return c.infoClient.Info(ctx, name, options...)
   180  }
   181  
   182  // Find searches for a given charm for a given name from CharmHub API.
   183  func (c *Client) Find(ctx context.Context, name string, options ...FindOption) ([]transport.FindResponse, error) {
   184  	return c.findClient.Find(ctx, name, options...)
   185  }
   186  
   187  // Refresh defines a client for making refresh API calls with different actions.
   188  func (c *Client) Refresh(ctx context.Context, config RefreshConfig) ([]transport.RefreshResponse, error) {
   189  	return c.refreshClient.Refresh(ctx, config)
   190  }
   191  
   192  // RefreshWithRequestMetrics defines a client for making refresh API calls.
   193  // Specifically to use the refresh action and provide metrics.  Intended for
   194  // use in the charm revision updater facade only.  Otherwise use Refresh.
   195  func (c *Client) RefreshWithRequestMetrics(ctx context.Context, config RefreshConfig, metrics map[charmmetrics.MetricKey]map[charmmetrics.MetricKey]string) ([]transport.RefreshResponse, error) {
   196  	return c.refreshClient.RefreshWithRequestMetrics(ctx, config, metrics)
   197  }
   198  
   199  // RefreshWithMetricsOnly defines a client making a refresh API call with no
   200  // action, whose purpose is to send metrics data for models without current
   201  // units.  E.G. the controller model.
   202  func (c *Client) RefreshWithMetricsOnly(ctx context.Context, metrics map[charmmetrics.MetricKey]map[charmmetrics.MetricKey]string) error {
   203  	return c.refreshClient.RefreshWithMetricsOnly(ctx, metrics)
   204  }
   205  
   206  // Download defines a client for downloading charms directly.
   207  func (c *Client) Download(ctx context.Context, resourceURL *url.URL, archivePath string, options ...DownloadOption) error {
   208  	return c.downloadClient.Download(ctx, resourceURL, archivePath, options...)
   209  }
   210  
   211  // DownloadAndRead defines a client for downloading charms directly.
   212  func (c *Client) DownloadAndRead(ctx context.Context, resourceURL *url.URL, archivePath string, options ...DownloadOption) (*charm.CharmArchive, error) {
   213  	return c.downloadClient.DownloadAndRead(ctx, resourceURL, archivePath, options...)
   214  }
   215  
   216  // DownloadAndReadBundle defines a client for downloading bundles directly.
   217  func (c *Client) DownloadAndReadBundle(ctx context.Context, resourceURL *url.URL, archivePath string, options ...DownloadOption) (charm.Bundle, error) {
   218  	return c.downloadClient.DownloadAndReadBundle(ctx, resourceURL, archivePath, options...)
   219  }
   220  
   221  // DownloadResource returns an io.ReadCloser to read the Resource from.
   222  func (c *Client) DownloadResource(ctx context.Context, resourceURL *url.URL) (r io.ReadCloser, err error) {
   223  	return c.downloadClient.DownloadResource(ctx, resourceURL)
   224  }
   225  
   226  // ListResourceRevisions returns resource revisions for the provided charm and resource.
   227  func (c *Client) ListResourceRevisions(ctx context.Context, charm, resource string) ([]transport.ResourceRevision, error) {
   228  	return c.resourcesClient.ListResourceRevisions(ctx, charm, resource)
   229  }