Skip to content

Commit

Permalink
feat: generic api request function (#218)
Browse files Browse the repository at this point in the history
* format url with url params

* make a real request

* handle singular outputs only for now

* refactor client

* fix tests

* remove unused stuff

* remove more stuff

* pr feedback

* fix output

* fix teams cmd help text
  • Loading branch information
k3llymariee authored May 1, 2024
1 parent 4ed7125 commit 0141d07
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 46 deletions.
23 changes: 10 additions & 13 deletions cmd/resource_cmds.go → cmd/resources/resource_cmds.go
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package cmd_test
package resources_test

import (
"testing"
Expand All @@ -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",
Expand Down
103 changes: 76 additions & 27 deletions cmd/resources/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package resources
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -54,7 +60,7 @@ type Param struct {

type OperationCmd struct {
OperationData
client *http.Client
client resources.Client
cmd *cobra.Command
}

Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ package cmd
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"

"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -17,20 +15,22 @@ 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 {
EnvironmentsClient environments.Client
FlagsClient flags.Client
MembersClient members.Client
ProjectsClient projects.Client
GenericClient *http.Client
ResourcesClient resources.Client
}

func NewRootCommand(
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions internal/resources/client.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 0141d07

Please sign in to comment.