Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: generic api request function #218

Merged
merged 11 commits into from
May 1, 2024
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes here are just due to moving the files

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)
Comment on lines -114 to -121
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it's actually ok to just grab all of these as string since we're just passing them into the query param or url anyway

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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

made a new interface for testing but had an issue with passing in data to the mock client, will try to tackle in follow up PR

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
}
Loading