diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f33df03e..5eb77c346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,15 @@ ### Breaking: ### Bug Fixes: -- fix(stats): `stats historical` now returns write errors instead of silently swallowing them. [#1678](https://github.com/fastly/cli/pull/1678) +- fix(stats): `stats historical` now returns write errors instead of silently swallowing them [#1678](https://github.com/fastly/cli/pull/1678) ### Enhancements: -- feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. [#1678](https://github.com/fastly/cli/pull/1678) -- feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. [#1678](https://github.com/fastly/cli/pull/1678) -- feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. [#1678](https://github.com/fastly/cli/pull/1678) -- feat(stats): add `stats domain-inspector` subcommand for domain-level metrics. [#1678](https://github.com/fastly/cli/pull/1678) -- feat(stats): add `stats origin-inspector` subcommand for origin-level metrics. [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `--field` flag to `stats historical` to filter to a single stats field [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `stats domain-inspector` subcommand for domain-level metrics [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `stats origin-inspector` subcommand for origin-level metrics [#1678](https://github.com/fastly/cli/pull/1678) +- feat(apisecurity/tags): add API Security Operations API support ([#1688](https://github.com/fastly/cli/pull/1688)) ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index e3e53cea9..76df1db88 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -72,6 +72,7 @@ kv-store kv-store-entry log-tail ngwaf +apisecurity object-storage pops products diff --git a/pkg/commands/apisecurity/doc.go b/pkg/commands/apisecurity/doc.go new file mode 100644 index 000000000..05e619ef9 --- /dev/null +++ b/pkg/commands/apisecurity/doc.go @@ -0,0 +1,2 @@ +// Package apisecurity contains commands to manage API operations discovered for services. +package apisecurity diff --git a/pkg/commands/apisecurity/root.go b/pkg/commands/apisecurity/root.go new file mode 100644 index 000000000..c086da107 --- /dev/null +++ b/pkg/commands/apisecurity/root.go @@ -0,0 +1,31 @@ +package apisecurity + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "apisecurity" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly API security operations") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/apisecurity/tags/create.go b/pkg/commands/apisecurity/tags/create.go new file mode 100644 index 000000000..1c616fdd4 --- /dev/null +++ b/pkg/commands/apisecurity/tags/create.go @@ -0,0 +1,105 @@ +package tags + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an operation tag. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + name string + + // Optional. + description argparser.OptionalString + serviceName argparser.OptionalServiceNameID +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create an operation tag") + + // Required. + c.CmdClause.Flag("name", "Name of the operation tag").Required().StringVar(&c.name) + + // Optional. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("description", "Description of the operation tag").Action(c.description.Set).StringVar(&c.description.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + if serviceID == "" { + return errors.New("service-id is required") + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &operations.CreateTagInput{ + ServiceID: &serviceID, + Name: &c.name, + } + + if c.description.WasSet { + input.Description = &c.description.Value + } + + tag, err := operations.CreateTag(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, tag); ok { + return err + } + + text.Success(out, "Created operation tag '%s' (id: %s)", tag.Name, tag.ID) + return nil +} diff --git a/pkg/commands/apisecurity/tags/delete.go b/pkg/commands/apisecurity/tags/delete.go new file mode 100644 index 000000000..7483f5e86 --- /dev/null +++ b/pkg/commands/apisecurity/tags/delete.go @@ -0,0 +1,107 @@ +package tags + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an operation tag. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + tagID string + + // Optional. + serviceName argparser.OptionalServiceNameID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete an operation tag") + + // Required. + c.CmdClause.Flag("tag-id", "Tag ID").Required().StringVar(&c.tagID) + + // Optional. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + if serviceID == "" { + return errors.New("service-id is required") + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err = operations.DeleteTag(context.TODO(), fc, &operations.DeleteTagInput{ + ServiceID: &serviceID, + TagID: &c.tagID, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ServiceID string `json:"service_id"` + TagID string `json:"tag_id"` + Deleted bool `json:"deleted"` + }{ + serviceID, + c.tagID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted operation tag (id: %s)", c.tagID) + return nil +} diff --git a/pkg/commands/apisecurity/tags/doc.go b/pkg/commands/apisecurity/tags/doc.go new file mode 100644 index 000000000..1a70e6459 --- /dev/null +++ b/pkg/commands/apisecurity/tags/doc.go @@ -0,0 +1,2 @@ +// Package tags contains commands to manipulate Fastly API Security operation tags. +package tags diff --git a/pkg/commands/apisecurity/tags/get.go b/pkg/commands/apisecurity/tags/get.go new file mode 100644 index 000000000..5e5d2cb46 --- /dev/null +++ b/pkg/commands/apisecurity/tags/get.go @@ -0,0 +1,97 @@ +package tags + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// GetCommand calls the Fastly API to get an operation tag. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + tagID string + + // Optional. + serviceName argparser.OptionalServiceNameID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("get", "Get an operation tag") + + // Required. + c.CmdClause.Flag("tag-id", "Tag ID").Required().StringVar(&c.tagID) + + // Optional. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + if serviceID == "" { + return errors.New("service-id is required") + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + tag, err := operations.DescribeTag(context.TODO(), fc, &operations.DescribeTagInput{ + ServiceID: &serviceID, + TagID: &c.tagID, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, tag); ok { + return err + } + + text.PrintOperationTag(out, tag) + return nil +} diff --git a/pkg/commands/apisecurity/tags/list.go b/pkg/commands/apisecurity/tags/list.go new file mode 100644 index 000000000..97e198c9f --- /dev/null +++ b/pkg/commands/apisecurity/tags/list.go @@ -0,0 +1,102 @@ +package tags + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" +) + +// ListCommand calls the Fastly API to list all operation tags. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Optional. + serviceName argparser.OptionalServiceNameID + limit argparser.OptionalInt + page argparser.OptionalInt +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List all operation tags") + + // Optional. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("limit", "Maximum number of tags to return per page").Action(c.limit.Set).IntVar(&c.limit.Value) + c.CmdClause.Flag("page", "Page number to return").Action(c.page.Set).IntVar(&c.page.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + if serviceID == "" { + return errors.New("service-id is required") + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &operations.ListTagsInput{ + ServiceID: &serviceID, + } + + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + if c.page.WasSet { + input.Page = &c.page.Value + } + + tags, err := operations.ListTags(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, tags); ok { + return err + } + + text.PrintOperationTagsTbl(out, tags.Data) + return nil +} diff --git a/pkg/commands/apisecurity/tags/root.go b/pkg/commands/apisecurity/tags/root.go new file mode 100644 index 000000000..046f3f2a3 --- /dev/null +++ b/pkg/commands/apisecurity/tags/root.go @@ -0,0 +1,31 @@ +package tags + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "tags" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly API Security operation tags") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/apisecurity/tags/tags_test.go b/pkg/commands/apisecurity/tags/tags_test.go new file mode 100644 index 000000000..53f36cf84 --- /dev/null +++ b/pkg/commands/apisecurity/tags/tags_test.go @@ -0,0 +1,446 @@ +package tags_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/apisecurity" + sub "github.com/fastly/cli/pkg/commands/apisecurity/tags" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" +) + +const ( + serviceID = "test-service-id" + tagID = "tag-123" + tagName = "APIv1" + tagDescription = "All-APIv1-endpoints" + updatedTagName = "APIv1.1" + updatedTagDesc = "Updated-APIv1-endpoints" +) + +var tag = operations.OperationTag{ + ID: tagID, + Name: tagName, + Description: tagDescription, + Count: 5, + CreatedAt: "2021-06-15T23:00:00Z", + UpdatedAt: "2021-06-15T23:00:00Z", +} + +func TestTagsCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: fmt.Sprintf("--name %s", tagName), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --name flag", + Args: fmt.Sprintf("--service-id %s", serviceID), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--service-id %s --name %s", serviceID, tagName), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s --name %s --description %s", serviceID, tagName, tagDescription), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), + }, + }, + }, + WantOutput: fstfmt.Success("Created operation tag '%s' (id: %s)", tagName, tagID), + }, + { + Name: "validate API success without description", + Args: fmt.Sprintf("--service-id %s --name %s", serviceID, tagName), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), + }, + }, + }, + WantOutput: fstfmt.Success("Created operation tag '%s' (id: %s)", tagName, tagID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--service-id %s --name %s --description %s --json", serviceID, tagName, tagDescription), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(tag), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestTagsDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: fmt.Sprintf("--tag-id %s", tagID), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --tag-id flag", + Args: fmt.Sprintf("--service-id %s", serviceID), + WantError: "error parsing arguments: required flag --tag-id not provided", + }, + { + Name: "validate bad request", + Args: fmt.Sprintf("--service-id %s --tag-id %s", serviceID, tagID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid tag ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s --tag-id %s", serviceID, tagID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted operation tag (id: %s)", tagID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--service-id %s --tag-id %s --json", serviceID, tagID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.JSON(`{"service_id": %q, "tag_id": %q, "deleted": true}`, serviceID, tagID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestTagsGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: fmt.Sprintf("--tag-id %s", tagID), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --tag-id flag", + Args: fmt.Sprintf("--service-id %s", serviceID), + WantError: "error parsing arguments: required flag --tag-id not provided", + }, + { + Name: "validate bad request", + Args: fmt.Sprintf("--service-id %s --tag-id invalid", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid tag ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s --tag-id %s", serviceID, tagID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), + }, + }, + }, + WantOutput: tagString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--service-id %s --tag-id %s --json", serviceID, tagID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(tag), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestTagsList(t *testing.T) { + tagsObject := operations.OperationTags{ + Data: []operations.OperationTag{ + { + ID: "tag-001", + Name: "API v1", + Description: "All v1 endpoints", + Count: 10, + CreatedAt: "2021-06-15T23:00:00Z", + UpdatedAt: "2021-06-15T23:00:00Z", + }, + { + ID: "tag-002", + Name: "API v2", + Description: "All v2 endpoints", + Count: 25, + CreatedAt: "2021-07-01T12:00:00Z", + UpdatedAt: "2021-07-01T12:00:00Z", + }, + }, + Meta: operations.Meta{ + Limit: 50, + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--service-id %s", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero tags)", + Args: fmt.Sprintf("--service-id %s", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(operations.OperationTags{ + Data: []operations.OperationTag{}, + Meta: operations.Meta{ + Limit: 50, + Total: 0, + }, + }))), + }, + }, + }, + WantOutput: zeroListTagsString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tagsObject))), + }, + }, + }, + WantOutput: listTagsString, + }, + { + Name: "validate API success with pagination", + Args: fmt.Sprintf("--service-id %s --limit 10 --page 2", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tagsObject))), + }, + }, + }, + WantOutput: listTagsString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--service-id %s --json", serviceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tagsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(tagsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestTagsUpdate(t *testing.T) { + updatedTag := operations.OperationTag{ + ID: tagID, + Name: updatedTagName, + Description: updatedTagDesc, + Count: 5, + CreatedAt: "2021-06-15T23:00:00Z", + UpdatedAt: "2021-06-16T10:00:00Z", + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: fmt.Sprintf("--tag-id %s --name %s --description %s", tagID, updatedTagName, updatedTagDesc), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --tag-id flag", + Args: fmt.Sprintf("--service-id %s --name %s --description %s", serviceID, updatedTagName, updatedTagDesc), + WantError: "error parsing arguments: required flag --tag-id not provided", + }, + { + Name: "validate missing --name flag", + Args: fmt.Sprintf("--service-id %s --tag-id %s --description %s", serviceID, tagID, updatedTagDesc), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --description flag", + Args: fmt.Sprintf("--service-id %s --tag-id %s --name %s", serviceID, tagID, updatedTagName), + WantError: "error parsing arguments: required flag --description not provided", + }, + { + Name: "validate bad request", + Args: fmt.Sprintf("--service-id %s --tag-id %s --name %s --description %s", serviceID, tagID, updatedTagName, updatedTagDesc), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid tag", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s --tag-id %s --name %s --description %s", serviceID, tagID, updatedTagName, updatedTagDesc), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedTag))), + }, + }, + }, + WantOutput: fstfmt.Success("Updated operation tag '%s' (id: %s)", updatedTagName, tagID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--service-id %s --tag-id %s --name %s --description %s --json", serviceID, tagID, updatedTagName, updatedTagDesc), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedTag))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedTag), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +var tagString = strings.TrimSpace(` +ID: tag-123 +Name: APIv1 +Description: All-APIv1-endpoints +Operation Count: 5 +Created At: 2021-06-15T23:00:00Z +Updated At: 2021-06-15T23:00:00Z +`) + "\n" + +var listTagsString = strings.TrimSpace(` +ID Name Description Operations Created At Updated At +tag-001 API v1 All v1 endpoints 10 2021-06-15T23:00:00Z 2021-06-15T23:00:00Z +tag-002 API v2 All v2 endpoints 25 2021-07-01T12:00:00Z 2021-07-01T12:00:00Z +`) + "\n" + +var zeroListTagsString = strings.TrimSpace(` +ID Name Description Operations Created At Updated At +`) + "\n" diff --git a/pkg/commands/apisecurity/tags/update.go b/pkg/commands/apisecurity/tags/update.go new file mode 100644 index 000000000..49d8110c3 --- /dev/null +++ b/pkg/commands/apisecurity/tags/update.go @@ -0,0 +1,113 @@ +package tags + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v13/fastly" + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an operation tag. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + tagID string + name string + description string + // Optional. + serviceName argparser.OptionalServiceNameID +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("update", "Update an operation tag") + + // Required. + c.CmdClause.Flag("tag-id", "Tag ID").Required().StringVar(&c.tagID) + c.CmdClause.Flag("name", "Updated name of the operation tag").Required().StringVar(&c.name) + c.CmdClause.Flag("description", "Updated description of the operation tag").Required().StringVar(&c.description) + + // Optional. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + if serviceID == "" { + return errors.New("service-id is required") + } + + if c.name == "" { + return errors.New("--name is required") + } + + if c.description == "" { + return errors.New("--description is required") + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &operations.UpdateTagInput{ + Description: &c.description, + Name: &c.name, + ServiceID: &serviceID, + TagID: &c.tagID, + } + + tag, err := operations.UpdateTag(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, tag); ok { + return err + } + + text.Success(out, "Updated operation tag '%s' (id: %s)", tag.Name, tag.ID) + return nil +} diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 0f8aacd14..4d1f049fd 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -51,6 +51,8 @@ import ( aliasvclcondition "github.com/fastly/cli/pkg/commands/alias/vcl/condition" aliasvclcustom "github.com/fastly/cli/pkg/commands/alias/vcl/custom" aliasvclsnippet "github.com/fastly/cli/pkg/commands/alias/vcl/snippet" + "github.com/fastly/cli/pkg/commands/apisecurity" + "github.com/fastly/cli/pkg/commands/apisecurity/tags" authcmd "github.com/fastly/cli/pkg/commands/auth" "github.com/fastly/cli/pkg/commands/authtoken" "github.com/fastly/cli/pkg/commands/compute" @@ -434,6 +436,13 @@ func Define( // nolint:revive // function-length ngwafWorkspaceAlertWebhookList := workspaceAlertWebhook.NewListCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) ngwafWorkspaceAlertWebhookRotateSigningKey := workspaceAlertWebhook.NewRotateSigningKeyCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) ngwafWorkspaceAlertWebhookUpdate := workspaceAlertWebhook.NewUpdateCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) + apiSecurityRoot := apisecurity.NewRootCommand(app, data) + apiSecurityTagsRoot := tags.NewRootCommand(apiSecurityRoot.CmdClause, data) + apiSecurityTagsCreate := tags.NewCreateCommand(apiSecurityTagsRoot.CmdClause, data) + apiSecurityTagsDelete := tags.NewDeleteCommand(apiSecurityTagsRoot.CmdClause, data) + apiSecurityTagsGet := tags.NewGetCommand(apiSecurityTagsRoot.CmdClause, data) + apiSecurityTagsList := tags.NewListCommand(apiSecurityTagsRoot.CmdClause, data) + apiSecurityTagsUpdate := tags.NewUpdateCommand(apiSecurityTagsRoot.CmdClause, data) objectStorageRoot := objectstorage.NewRootCommand(app, data) objectStorageAccesskeysRoot := accesskeys.NewRootCommand(objectStorageRoot.CmdClause, data) objectStorageAccesskeysCreate := accesskeys.NewCreateCommand(objectStorageAccesskeysRoot.CmdClause, data) @@ -1448,6 +1457,13 @@ func Define( // nolint:revive // function-length ngwafWorkspaceGet, ngwafWorkspaceList, ngwafWorkspaceUpdate, + apiSecurityRoot, + apiSecurityTagsRoot, + apiSecurityTagsCreate, + apiSecurityTagsDelete, + apiSecurityTagsGet, + apiSecurityTagsList, + apiSecurityTagsUpdate, objectStorageRoot, objectStorageAccesskeysRoot, objectStorageAccesskeysCreate, diff --git a/pkg/text/operationtag.go b/pkg/text/operationtag.go new file mode 100644 index 000000000..e420ab55b --- /dev/null +++ b/pkg/text/operationtag.go @@ -0,0 +1,46 @@ +package text + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v13/fastly/apisecurity/operations" +) + +// PrintOperationTag displays an operation tag. +func PrintOperationTag(out io.Writer, tag *operations.OperationTag) { + fmt.Fprintf(out, "ID: %s\n", tag.ID) + fmt.Fprintf(out, "Name: %s\n", tag.Name) + if tag.Description != "" { + fmt.Fprintf(out, "Description: %s\n", tag.Description) + } + if tag.Count > 0 { + fmt.Fprintf(out, "Operation Count: %d\n", tag.Count) + } + if tag.CreatedAt != "" { + fmt.Fprintf(out, "Created At: %s\n", tag.CreatedAt) + } + if tag.UpdatedAt != "" { + fmt.Fprintf(out, "Updated At: %s\n", tag.UpdatedAt) + } +} + +// PrintOperationTagsTbl displays operation tags in a table format. +func PrintOperationTagsTbl(out io.Writer, tags []operations.OperationTag) { + tbl := NewTable(out) + tbl.AddHeader("ID", "Name", "Description", "Operations", "Created At", "Updated At") + + if tags == nil { + tbl.Print() + return + } + + for _, tag := range tags { + description := tag.Description + if description == "" { + description = "-" + } + tbl.AddLine(tag.ID, tag.Name, description, fmt.Sprintf("%d", tag.Count), tag.CreatedAt, tag.UpdatedAt) + } + tbl.Print() +}