Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
)
5 changes: 3 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
83 changes: 56 additions & 27 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
61 changes: 26 additions & 35 deletions internal/register/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
21 changes: 14 additions & 7 deletions templates/cmd/service.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions templates/internal/service.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down