diff --git a/cmd/resource_cmds.go b/cmd/resources/resource_cmds.go similarity index 72% rename from cmd/resource_cmds.go rename to cmd/resources/resource_cmds.go index 2d811b97..29e6a2f6 100644 --- a/cmd/resource_cmds.go +++ b/cmd/resources/resource_cmds.go @@ -1,32 +1,30 @@ // this file WILL be generated (sc-241153) -package cmd +package resources import ( - "net/http" - "github.com/spf13/cobra" - "ldcli/cmd/resources" "ldcli/internal/analytics" + "ldcli/internal/resources" ) -func addAllResourceCmds(rootCmd *cobra.Command, client *http.Client, analyticsTracker analytics.Tracker) { +func AddAllResourceCmds(rootCmd *cobra.Command, client resources.Client, analyticsTracker analytics.Tracker) { // Resource commands - gen_TeamsResourceCmd := resources.NewResourceCmd( + gen_TeamsResourceCmd := NewResourceCmd( rootCmd, analyticsTracker, "teams", - "A team is a group of members in your LaunchDarkly account.", - "A team can have maintainers who are able to add and remove team members. It also can have custom roles assigned to it that allows shared access to those roles for all team members. To learn more, read [Teams](https://docs.launchdarkly.com/home/teams).\n\nThe Teams API allows you to create, read, update, and delete a team.\n\nSeveral of the endpoints in the Teams API require one or more member IDs. The member ID is returned as part of the [List account members](/tag/Account-members#operation/getMembers) response. It is the `_id` field of each element in the `items` array.", + "Make requests (list, create, etc.) on teams", + "A team is a group of members in your LaunchDarkly account. A team can have maintainers who are able to add and remove team members. It also can have custom roles assigned to it that allows shared access to those roles for all team members. To learn more, read [Teams](https://docs.launchdarkly.com/home/teams).\n\nThe Teams API allows you to create, read, update, and delete a team.\n\nSeveral of the endpoints in the Teams API require one or more member IDs. The member ID is returned as part of the [List account members](/tag/Account-members#operation/getMembers) response. It is the `_id` field of each element in the `items` array.", ) // Operation commands - resources.NewOperationCmd(gen_TeamsResourceCmd, client, resources.OperationData{ + NewOperationCmd(gen_TeamsResourceCmd, client, OperationData{ Short: "Create team", Long: "Create a team. To learn more, read [Creating a team](https://docs.launchdarkly.com/home/teams/creating).\n\n### Expanding the teams response\nLaunchDarkly supports four fields for expanding the \"Create team\" response. By default, these fields are **not** included in the response.\n\nTo expand the response, append the `expand` query parameter and add a comma-separated list with any of the following fields:\n\n* `members` includes the total count of members that belong to the team.\n* `roles` includes a paginated list of the custom roles that you have assigned to the team.\n* `projects` includes a paginated list of the projects that the team has any write access to.\n* `maintainers` includes a paginated list of the maintainers that you have assigned to the team.\n\nFor example, `expand=members,roles` includes the `members` and `roles` fields in the response.\n", Use: "create", // TODO: translate post -> create - Params: []resources.Param{ + Params: []Param{ { Name: "expand", In: "query", @@ -38,12 +36,11 @@ func addAllResourceCmds(rootCmd *cobra.Command, client *http.Client, analyticsTr RequiresBody: true, Path: "/api/v2/teams", }) - - resources.NewOperationCmd(gen_TeamsResourceCmd, client, resources.OperationData{ + NewOperationCmd(gen_TeamsResourceCmd, client, OperationData{ Short: "Get team", Long: "Fetch a team by key.\n\n### Expanding the teams response\nLaunchDarkly supports four fields for expanding the \"Get team\" response. By default, these fields are **not** included in the response.\n\nTo expand the response, append the `expand` query parameter and add a comma-separated list with any of the following fields:\n\n* `members` includes the total count of members that belong to the team.\n* `roles` includes a paginated list of the custom roles that you have assigned to the team.\n* `projects` includes a paginated list of the projects that the team has any write access to.\n* `maintainers` includes a paginated list of the maintainers that you have assigned to the team.\n\nFor example, `expand=members,roles` includes the `members` and `roles` fields in the response.\n", Use: "get", - Params: []resources.Param{ + Params: []Param{ { Name: "teamKey", // TODO: kebab case/trim key? to be consistent with our existing flags (e.g. projectKey = project) In: "path", diff --git a/cmd/resource_cmds_test.go b/cmd/resources/resource_cmds_test.go similarity index 92% rename from cmd/resource_cmds_test.go rename to cmd/resources/resource_cmds_test.go index 48e8e509..464b567e 100644 --- a/cmd/resource_cmds_test.go +++ b/cmd/resources/resource_cmds_test.go @@ -1,4 +1,4 @@ -package cmd_test +package resources_test import ( "testing" @@ -24,6 +24,7 @@ func TestCreateTeam(t *testing.T) { assert.Contains(t, string(output), "Create a team.") }) t.Run("with valid flags calls makeRequest function", func(t *testing.T) { + t.Skip("TODO: add back when mock client is added") args := []string{ "teams", "create", diff --git a/cmd/resources/resources.go b/cmd/resources/resources.go index e3de364c..330ca49d 100644 --- a/cmd/resources/resources.go +++ b/cmd/resources/resources.go @@ -3,7 +3,9 @@ package resources import ( "encoding/json" "fmt" - "net/http" + "net/url" + "regexp" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -12,6 +14,9 @@ import ( "ldcli/cmd/cliflags" "ldcli/cmd/validators" "ldcli/internal/analytics" + "ldcli/internal/errors" + "ldcli/internal/output" + "ldcli/internal/resources" ) func NewResourceCmd(parentCmd *cobra.Command, analyticsTracker analytics.Tracker, resourceName, shortDescription, longDescription string) *cobra.Command { @@ -35,13 +40,14 @@ func NewResourceCmd(parentCmd *cobra.Command, analyticsTracker analytics.Tracker } type OperationData struct { - Short string - Long string - Use string - Params []Param - HTTPMethod string - RequiresBody bool - Path string + Short string + Long string + Use string + Params []Param + HTTPMethod string + RequiresBody bool + Path string + SupportsSemanticPatch bool // TBD on how to actually determine from openapi spec } type Param struct { @@ -54,7 +60,7 @@ type Param struct { type OperationCmd struct { OperationData - client *http.Client + client resources.Client cmd *cobra.Command } @@ -71,8 +77,17 @@ func (op *OperationCmd) initFlags() error { } } + if op.SupportsSemanticPatch { + op.cmd.Flags().Bool("semantic-patch", false, "Perform a semantic patch request") + err := viper.BindPFlag("semantic-patch", op.cmd.Flags().Lookup("semantic-patch")) + if err != nil { + return err + } + } + for _, p := range op.Params { shorthand := fmt.Sprintf(p.Name[0:1]) // todo: how do we handle potential dupes + // TODO: consider handling these all as strings switch p.Type { case "string": op.cmd.Flags().StringP(p.Name, shorthand, "", p.Description) @@ -97,41 +112,76 @@ func (op *OperationCmd) initFlags() error { return nil } -func (op *OperationCmd) makeRequest(cmd *cobra.Command, args []string) error { - paramVals := map[string]interface{}{} +func buildURLWithParams(baseURI, path string, urlParams []string) string { + s := make([]interface{}, len(urlParams)) + for i, v := range urlParams { + s[i] = v + } + + re := regexp.MustCompile(`{\w+}`) + format := re.ReplaceAllString(path, "%s") + + return baseURI + fmt.Sprintf(format, s...) +} +func (op *OperationCmd) makeRequest(cmd *cobra.Command, args []string) error { + var data interface{} if op.RequiresBody { - var data interface{} // TODO: why does viper.GetString(cliflags.DataFlag) not work? err := json.Unmarshal([]byte(cmd.Flags().Lookup(cliflags.DataFlag).Value.String()), &data) if err != nil { return err } - paramVals[cliflags.DataFlag] = data + } + jsonData, err := json.Marshal(data) + if err != nil { + return err } + query := url.Values{} + var urlParms []string for _, p := range op.Params { - var val interface{} - switch p.Type { - case "string": - val = viper.GetString(p.Name) - case "boolean": - val = viper.GetBool(p.Name) - case "int": - val = viper.GetInt(p.Name) + val := viper.GetString(p.Name) + if val != "" { + switch p.In { + case "path": + urlParms = append(urlParms, val) + case "query": + query.Add(p.Name, val) + } } + } - if val != nil { - paramVals[p.Name] = val - } + path := buildURLWithParams(viper.GetString(cliflags.BaseURIFlag), op.Path, urlParms) + + contentType := "application/json" + if viper.GetBool("semantic-patch") { + contentType += "; domain-model=launchdarkly.semanticpatch" + } + + res, err := op.client.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + strings.ToUpper(op.HTTPMethod), + path, + contentType, + query, + jsonData, + ) + if err != nil { + return errors.NewError(output.CmdOutputError(viper.GetString(cliflags.OutputFlag), err)) + } + + output, err := output.CmdOutput("get", viper.GetString(cliflags.OutputFlag), res) + if err != nil { + return errors.NewError(err.Error()) } - fmt.Fprintf(cmd.OutOrStdout(), "would be making a %s request to %s here, with args: %s\n", op.HTTPMethod, op.Path, paramVals) + fmt.Fprintf(cmd.OutOrStdout(), output+"\n") return nil } -func NewOperationCmd(parentCmd *cobra.Command, client *http.Client, op OperationData) *cobra.Command { +func NewOperationCmd(parentCmd *cobra.Command, client resources.Client, op OperationData) *cobra.Command { opCmd := OperationCmd{ OperationData: op, client: client, @@ -143,7 +193,6 @@ func NewOperationCmd(parentCmd *cobra.Command, client *http.Client, op Operation RunE: opCmd.makeRequest, Short: op.Short, Use: op.Use, - //TODO: add tracking here } opCmd.cmd = cmd diff --git a/cmd/root.go b/cmd/root.go index 5b95edde..7f35728f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,10 +3,8 @@ package cmd import ( "fmt" "log" - "net/http" "os" "strings" - "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -17,12 +15,14 @@ import ( flagscmd "ldcli/cmd/flags" mbrscmd "ldcli/cmd/members" projcmd "ldcli/cmd/projects" + resourcecmd "ldcli/cmd/resources" "ldcli/internal/analytics" "ldcli/internal/config" "ldcli/internal/environments" "ldcli/internal/flags" "ldcli/internal/members" "ldcli/internal/projects" + "ldcli/internal/resources" ) type APIClients struct { @@ -30,7 +30,7 @@ type APIClients struct { FlagsClient flags.Client MembersClient members.Client ProjectsClient projects.Client - GenericClient *http.Client + ResourcesClient resources.Client } func NewRootCommand( @@ -144,7 +144,7 @@ func NewRootCommand( cmd.AddCommand(projectsCmd) cmd.AddCommand(NewQuickStartCmd(analyticsTracker, clients.EnvironmentsClient, clients.FlagsClient)) - addAllResourceCmds(cmd, clients.GenericClient, analyticsTracker) + resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTracker) return cmd, nil } @@ -155,7 +155,7 @@ func Execute(analyticsTracker analytics.Tracker, version string) { FlagsClient: flags.NewClient(version), MembersClient: members.NewClient(version), ProjectsClient: projects.NewClient(version), - GenericClient: &http.Client{Timeout: time.Second * 3}, + ResourcesClient: resources.NewClient(version), } rootCmd, err := NewRootCommand( analyticsTracker, diff --git a/internal/resources/client.go b/internal/resources/client.go new file mode 100644 index 00000000..750e49df --- /dev/null +++ b/internal/resources/client.go @@ -0,0 +1,53 @@ +package resources + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "ldcli/internal/errors" +) + +type Client interface { + MakeRequest(accessToken, method, path, contentType string, query url.Values, data []byte) ([]byte, error) +} + +type ResourcesClient struct { + cliVersion string +} + +var _ Client = ResourcesClient{} + +func NewClient(cliVersion string) ResourcesClient { + return ResourcesClient{cliVersion: cliVersion} +} + +func (c ResourcesClient) MakeRequest(accessToken, method, path, contentType string, query url.Values, data []byte) ([]byte, error) { + client := http.Client{Timeout: 3 * time.Second} + + req, _ := http.NewRequest(method, path, bytes.NewReader(data)) + req.Header.Add("Authorization", accessToken) + req.Header.Add("Content-type", contentType) + req.Header.Set("User-Agent", fmt.Sprintf("launchdarkly-cli/v%s", c.cliVersion)) + req.URL.RawQuery = query.Encode() + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode >= 400 { + return body, errors.NewAPIError(body, nil, nil) + } + + return body, nil +}