diff --git a/go.mod b/go.mod index 2fda575..bc8834e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.5 require ( github.com/equinix/equinix-sdk-go v0.61.0 github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -20,11 +21,10 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/oauth2 v0.26.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.28.0 // indirect - gopkg.in/validator.v2 v2.0.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index da2482d..63ff761 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,11 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -56,7 +59,5 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= -gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/api.go b/internal/api/api.go index 3152909..2280e77 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -16,20 +16,17 @@ import ( "net/http" "net/http/httputil" "os" + "strings" + "github.com/equinix/cli/internal/version" equinixoauth2 "github.com/equinix/equinix-sdk-go/extensions/equinixoauth2" "github.com/spf13/viper" ) -var standardHeaders = map[string]string{ - "User-Agent": "equinix-cli/version generic-request-client", -} - // Client is a generic Equinix API client that can be used for any Equinix API. type Client struct { - BaseURL string - DefaultHeaders map[string]string - HTTPClient *http.Client + BaseURL string + HTTPClient *http.Client } // debugTransport wraps an HTTP transport to log requests and responses when debug mode is enabled @@ -66,13 +63,34 @@ func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { return resp, err } +// headerInjectingTransport wraps an HTTP transport to log requests and responses when debug mode is enabled +type headerInjectingTransport struct { + defaultHeaders map[string]string + transport http.RoundTripper +} + +func (t *headerInjectingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + for k, v := range t.defaultHeaders { + req.Header.Set(k, v) + } + if req.Body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Inject CLI identifier into User-Agent + originalUserAgent := req.Header.Get("User-Agent") + req.Header.Set("User-Agent", strings.TrimSpace(fmt.Sprintf("equinix-cli/%s %s", version.Version, originalUserAgent))) + + // Execute the request + return t.transport.RoundTrip(req) +} + // NewStandardClient creates a new Client for Equinix APIs that exist under // api.equinix.com and use OAuth2 client credentials for authentication func NewStandardClient(options ...ClientOption) (*Client, error) { client := &Client{ - BaseURL: "https://api.equinix.com", - DefaultHeaders: standardHeaders, - HTTPClient: &http.Client{}, + BaseURL: "https://api.equinix.com", + HTTPClient: &http.Client{}, } clientID := viper.GetString("equinix_client_id") @@ -109,23 +127,37 @@ func WithDebug() ClientOption { } } +func withDefaultHeaders(headers map[string]string) ClientOption { + return func(transport http.RoundTripper) http.RoundTripper { + return &headerInjectingTransport{ + defaultHeaders: headers, + transport: transport, + } + } +} + // NewPortalClient creates a new Client for Equinix APIs that exist under // portal.equinix.com and rely on Cookies to transmit OAuth2 tokens func NewPortalClient(options ...ClientOption) (*Client, error) { client := &Client{ - BaseURL: "https://portal.equinix.com/api", - DefaultHeaders: standardHeaders, - HTTPClient: &http.Client{}, + BaseURL: "https://portal.equinix.com/api", + HTTPClient: &http.Client{}, } cookie := viper.GetString("equinix_portal_cookie") if cookie == "" { return nil, errors.New("portal cookie not found in env or config") } - client.DefaultHeaders["Cookie"] = cookie - client.DefaultHeaders["User-Agent"] = fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0 %s", client.DefaultHeaders["User-Agent"]) - client.DefaultHeaders["Accept"] = "*/*" - client.DefaultHeaders["Accept-Encoding"] = "*/*" + + defaultHeaders := map[string]string{ + "Cookie": cookie, + // We previously set a browser-like User-Agent header here, but in manual testing this appears to + // be unnecessary. Keeping it as a comment here for reference in case we need to re-enable it later. + //"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0", + "Accept": "*/*", + "Accept-Encoding": "*/*", + } + options = append(options, withDefaultHeaders(defaultHeaders)) // Apply options to potentially wrap the transport transport := http.RoundTripper(http.DefaultTransport) @@ -141,16 +173,19 @@ func NewPortalClient(options ...ClientOption) (*Client, error) { // NewMetalClient creates a new Client for the Equinix Metal API func NewMetalClient(options ...ClientOption) (*Client, error) { client := &Client{ - BaseURL: "https://api.equinix.com", - DefaultHeaders: standardHeaders, - HTTPClient: &http.Client{}, + BaseURL: "https://api.equinix.com", + HTTPClient: &http.Client{}, } token := viper.GetString("metal_auth_token") if token == "" { return nil, errors.New("metal_auth_token not found in env or config") } - client.DefaultHeaders["X-Auth-Token"] = token + + defaultHeaders := map[string]string{ + "X-Auth-Token": token, + } + options = append(options, withDefaultHeaders(defaultHeaders)) // Apply options to potentially wrap the transport transport := http.RoundTripper(http.DefaultTransport) @@ -178,12 +213,6 @@ func (c *Client) Request(apiPath, method string, data string) ([]byte, error) { if err != nil { return nil, err } - for k, v := range c.DefaultHeaders { - req.Header.Set(k, v) - } - if data != "" { - req.Header.Set("Content-Type", "application/json") - } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, err diff --git a/internal/register/register.go b/internal/register/register.go index f9d73f3..f9e5db8 100644 --- a/internal/register/register.go +++ b/internal/register/register.go @@ -178,12 +178,15 @@ func createMethodCommand(methodName, originalMethodName string, service interfac // Extract description from loaded SDK descriptions or generate default description := extractMethodDescription(service, originalMethodName, builderMethod) + // Extract the service name to find the matching service in the execution client + serviceName := getServiceNameFromType(service) + cmd := &cobra.Command{ Use: methodName, Short: description.Short, Long: description.Long, RunE: func(cmd *cobra.Command, args []string) error { - return executeMethod(cmd, service, method, builderMethod, args, description.ParameterDescriptions) + return executeMethod(cmd, serviceName, method, builderMethod, args, description.ParameterDescriptions) }, } @@ -320,35 +323,24 @@ func getServiceFromClient(client interface{}, serviceName string) interface{} { } // ClientFactoryFunc is a function that creates a new authenticated API client -type ClientFactoryFunc func() (interface{}, error) - -var clientFactory ClientFactoryFunc - -// SetClientFactory sets the factory function for creating authenticated clients -func SetClientFactory(factory ClientFactoryFunc) { - clientFactory = factory -} +type ClientFactoryFunc func() (APIClientInterface, error) // executeMethod performs the actual API call using reflection -func executeMethod(cmd *cobra.Command, service interface{}, _ reflect.Method, builderMethod reflect.Method, args []string, paramDescriptions []parser.ParameterDescription) error { - ctx := context.Background() - - // Create a fresh authenticated client if factory is set - if clientFactory != nil { - freshClient, err := clientFactory() - if err != nil { - return fmt.Errorf("failed to create authenticated client: %w", err) - } +func executeMethod(cmd *cobra.Command, serviceName string, _ reflect.Method, builderMethod reflect.Method, args []string, paramDescriptions []parser.ParameterDescription) error { + ctx := cmd.Context() + clientFactory, ok := ClientFactoryFromContext(cmd.Context()) + if !ok { + return fmt.Errorf("missing or invalid method to create authenticated client: %v", ctx.Value("executionClientFactory")) + } - // Get the service from the fresh client - // Extract the service name to find the matching field - serviceName := getServiceNameFromType(service) - freshService := getServiceFromClient(freshClient, serviceName) - if freshService != nil { - service = freshService - } + client, err := clientFactory() + if err != nil { + return fmt.Errorf("failed to create authenticated client: %w", err) } + // Get the service from the fresh client + // Extract the service name to find the matching field + service := getServiceFromClient(client, serviceName) serviceValue := reflect.ValueOf(service) // Build the arguments for the builder method @@ -1285,16 +1277,15 @@ func GetServiceList(client APIClientInterface) ([]ServiceInfo, error) { // contextKey is used for context values type contextKey string -// ContextWithClient adds a client to the context -func ContextWithClient(ctx context.Context, client interface{}) context.Context { - return context.WithValue(ctx, contextKey("client"), client) +var clientFactoryKey = contextKey("clientFactory") + +// ContextWithClientFactory adds a client to the context +func ContextWithClientFactory(ctx context.Context, clientFactory ClientFactoryFunc) context.Context { + return context.WithValue(ctx, clientFactoryKey, clientFactory) } -// ClientFromContext retrieves a client from the context -func ClientFromContext(ctx context.Context) (interface{}, bool) { - client := ctx.Value(contextKey("client")) - if client == nil { - return nil, false - } - return client, true +// ClientFactoryFromContext retrieves a client from the context +func ClientFactoryFromContext(ctx context.Context) (ClientFactoryFunc, bool) { + clientFactory, ok := ctx.Value(clientFactoryKey).(ClientFactoryFunc) + return clientFactory, ok } diff --git a/templates/cmd/service.go.tmpl b/templates/cmd/service.go.tmpl index a4313be..7f5924c 100644 --- a/templates/cmd/service.go.tmpl +++ b/templates/cmd/service.go.tmpl @@ -10,6 +10,10 @@ import ( "github.com/spf13/cobra" ) +// TODO: Put service subcommands in separate packages to +// avoid variable naming collisions +var {{SERVICE}}Debug bool + //go:embed descriptions/{{SERVICE}}.json var {{SERVICE}}Descriptions []byte @@ -21,20 +25,23 @@ var {{SERVICE}}Cmd = &cobra.Command{ The {{SERVICE}} commands are dynamically generated based on the {{SERVICE_DISPLAY}} API client, providing access to all available API services.`, - PersistentPreRun: func(_ *cobra.Command, _ []string) { - // Ensure client is initialized when actually running commands - // This validates credentials before execution - _, err := {{SERVICE}}.NewClient() - if err != nil { - fmt.Fprintf(os.Stderr, "Error initializing {{SERVICE_DISPLAY}} client: %v\n", err) - os.Exit(1) + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + // Inject the client factory into the command context so that + // it can be retrieved at execution time + executionClientFactory := func() (register.APIClientInterface, error) { + {{SERVICE}}.SetDebug({{SERVICE}}Debug) + return {{SERVICE}}.NewClient() } + cmd.SetContext(register.ContextWithClientFactory(cmd.Context(), executionClientFactory)) }, } func init() { rootCmd.AddCommand({{SERVICE}}Cmd) + // Add common debug flag that will be inherited by all subcommands for {{SERVICE}} + {{SERVICE}}Cmd.PersistentFlags().BoolVar(&{{SERVICE}}Debug, "debug", false, "Enable debug logging for HTTP requests") + // Load SDK descriptions for better command documentation if err := register.LoadDescriptions({{SERVICE}}Descriptions); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not load {{SERVICE}} descriptions: %v\n", err) diff --git a/templates/internal/service.go.tmpl b/templates/internal/service.go.tmpl index 726f04e..b7381e5 100644 --- a/templates/internal/service.go.tmpl +++ b/templates/internal/service.go.tmpl @@ -21,8 +21,17 @@ func NewClient() (*{{SERVICE}}.APIClient, error) { opts = append(opts, api.WithDebug()) } - // Use the standard client setup for authentication - stdClient, err := api.NewStandardClient(opts...) + // TEMP: use a Metal client for the metalv1 service + // until it is removed from public SDKs + var stdClient *api.Client + var err error + if "{{SERVICE}}" == "metalv1" { + stdClient, err = api.NewMetalClient(opts...) + } else { + // Use the standard client setup for authentication + stdClient, err = api.NewStandardClient(opts...) + } + if err != nil { return nil, err }