From bee53b4269eab6152edd87d9d84d6c27a50ff540 Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Wed, 11 Mar 2026 11:43:25 -0400 Subject: [PATCH 1/2] feat(apisecurity/tags): add crud operations for api security tags --- CHANGELOG.md | 13 +- pkg/app/run_test.go | 1 + pkg/commands/apisecurity/doc.go | 2 + pkg/commands/apisecurity/root.go | 31 ++ pkg/commands/apisecurity/tags/add-bulk.go | 107 ++++ pkg/commands/apisecurity/tags/create.go | 105 ++++ pkg/commands/apisecurity/tags/delete.go | 107 ++++ pkg/commands/apisecurity/tags/doc.go | 2 + pkg/commands/apisecurity/tags/get.go | 97 ++++ pkg/commands/apisecurity/tags/list.go | 102 ++++ pkg/commands/apisecurity/tags/root.go | 31 ++ pkg/commands/apisecurity/tags/tags_test.go | 545 +++++++++++++++++++++ pkg/commands/apisecurity/tags/update.go | 113 +++++ pkg/commands/commands.go | 18 + pkg/text/operationtag.go | 46 ++ 15 files changed, 1314 insertions(+), 6 deletions(-) create mode 100644 pkg/commands/apisecurity/doc.go create mode 100644 pkg/commands/apisecurity/root.go create mode 100644 pkg/commands/apisecurity/tags/add-bulk.go create mode 100644 pkg/commands/apisecurity/tags/create.go create mode 100644 pkg/commands/apisecurity/tags/delete.go create mode 100644 pkg/commands/apisecurity/tags/doc.go create mode 100644 pkg/commands/apisecurity/tags/get.go create mode 100644 pkg/commands/apisecurity/tags/list.go create mode 100644 pkg/commands/apisecurity/tags/root.go create mode 100644 pkg/commands/apisecurity/tags/tags_test.go create mode 100644 pkg/commands/apisecurity/tags/update.go create mode 100644 pkg/text/operationtag.go 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/add-bulk.go b/pkg/commands/apisecurity/tags/add-bulk.go new file mode 100644 index 000000000..5144dc620 --- /dev/null +++ b/pkg/commands/apisecurity/tags/add-bulk.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" +) + +// AddBulkCommand calls the Fastly API to add tags to multiple operations. +type AddBulkCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + operationIDs []string + tagIDs []string + + // Optional. + serviceName argparser.OptionalServiceNameID +} + +// NewAddBulkCommand returns a usable command registered under the parent. +func NewAddBulkCommand(parent argparser.Registerer, g *global.Data) *AddBulkCommand { + c := AddBulkCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("add-bulk", "Add tags to multiple operations") + + // Required. + c.CmdClause.Flag("operation-id", "Operation ID. Set flag multiple times to include multiple operations").Required().StringsVar(&c.operationIDs) + c.CmdClause.Flag("tag-id", "Tag ID to add. Set flag multiple times to include multiple tags").Required().StringsVar(&c.tagIDs) + + // 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 *AddBulkCommand) 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 len(c.operationIDs) == 0 { + return errors.New("at least one operation-id must be provided") + } + if len(c.tagIDs) == 0 { + return errors.New("at least one tag-id must be provided") + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + result, err := operations.BulkAddTags(context.TODO(), fc, &operations.BulkAddTagsInput{ + ServiceID: &serviceID, + OperationIDs: c.operationIDs, + TagIDs: c.tagIDs, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, result); ok { + return err + } + + text.Success(out, "Bulk add tags completed. Processed %d operations", len(result.Data)) + return nil +} 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..df7ce1387 --- /dev/null +++ b/pkg/commands/apisecurity/tags/tags_test.go @@ -0,0 +1,545 @@ +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" + operationID1 = "op-123" + operationID2 = "op-456" + tagID2 = "tag-456" +) + +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) +} + +func TestTagsAddBulk(t *testing.T) { + bulkResponse := operations.BulkOperationResultsResponse{ + Data: []operations.BulkOperationResult{ + { + ID: operationID1, + StatusCode: 200, + }, + { + ID: operationID2, + StatusCode: 200, + }, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: fmt.Sprintf("--operation-id %s --tag-id %s", operationID1, tagID), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --operation-id flag", + Args: fmt.Sprintf("--service-id %s --tag-id %s", serviceID, tagID), + WantError: "error parsing arguments: required flag --operation-id not provided", + }, + { + Name: "validate missing --tag-id flag", + Args: fmt.Sprintf("--service-id %s --operation-id %s", serviceID, operationID1), + WantError: "error parsing arguments: required flag --tag-id not provided", + }, + { + Name: "validate bad request", + Args: fmt.Sprintf("--service-id %s --operation-id %s --tag-id %s", serviceID, operationID1, 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 request", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success (single operation, single tag)", + Args: fmt.Sprintf("--service-id %s --operation-id %s --tag-id %s", serviceID, operationID1, tagID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusMultiStatus, + Status: http.StatusText(http.StatusMultiStatus), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), + }, + }, + }, + WantOutput: fstfmt.Success("Bulk add tags completed. Processed %d operations", 2), + }, + { + Name: "validate API success (multiple operations, multiple tags)", + Args: fmt.Sprintf("--service-id %s --operation-id %s --operation-id %s --tag-id %s --tag-id %s", serviceID, operationID1, operationID2, tagID, tagID2), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusMultiStatus, + Status: http.StatusText(http.StatusMultiStatus), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), + }, + }, + }, + WantOutput: fstfmt.Success("Bulk add tags completed. Processed %d operations", 2), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--service-id %s --operation-id %s --tag-id %s --json", serviceID, operationID1, tagID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusMultiStatus, + Status: http.StatusText(http.StatusMultiStatus), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(bulkResponse), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "add-bulk"}, 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..6ae22bc71 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,14 @@ 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) + apiSecurityTagsAddBulk := tags.NewAddBulkCommand(apiSecurityTagsRoot.CmdClause, data) objectStorageRoot := objectstorage.NewRootCommand(app, data) objectStorageAccesskeysRoot := accesskeys.NewRootCommand(objectStorageRoot.CmdClause, data) objectStorageAccesskeysCreate := accesskeys.NewCreateCommand(objectStorageAccesskeysRoot.CmdClause, data) @@ -1448,6 +1458,14 @@ func Define( // nolint:revive // function-length ngwafWorkspaceGet, ngwafWorkspaceList, ngwafWorkspaceUpdate, + apiSecurityRoot, + apiSecurityTagsRoot, + apiSecurityTagsCreate, + apiSecurityTagsDelete, + apiSecurityTagsGet, + apiSecurityTagsList, + apiSecurityTagsUpdate, + apiSecurityTagsAddBulk, 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() +} From 8a26ba25ed133cbcf38dc633533c7ffbe2c4f8f1 Mon Sep 17 00:00:00 2001 From: Anthony Gomez Date: Fri, 13 Mar 2026 17:25:29 -0400 Subject: [PATCH 2/2] remove bulk --- pkg/commands/apisecurity/tags/add-bulk.go | 107 --------------------- pkg/commands/apisecurity/tags/tags_test.go | 99 ------------------- pkg/commands/commands.go | 2 - 3 files changed, 208 deletions(-) delete mode 100644 pkg/commands/apisecurity/tags/add-bulk.go diff --git a/pkg/commands/apisecurity/tags/add-bulk.go b/pkg/commands/apisecurity/tags/add-bulk.go deleted file mode 100644 index 5144dc620..000000000 --- a/pkg/commands/apisecurity/tags/add-bulk.go +++ /dev/null @@ -1,107 +0,0 @@ -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" -) - -// AddBulkCommand calls the Fastly API to add tags to multiple operations. -type AddBulkCommand struct { - argparser.Base - argparser.JSONOutput - - // Required. - operationIDs []string - tagIDs []string - - // Optional. - serviceName argparser.OptionalServiceNameID -} - -// NewAddBulkCommand returns a usable command registered under the parent. -func NewAddBulkCommand(parent argparser.Registerer, g *global.Data) *AddBulkCommand { - c := AddBulkCommand{ - Base: argparser.Base{ - Globals: g, - }, - } - - c.CmdClause = parent.Command("add-bulk", "Add tags to multiple operations") - - // Required. - c.CmdClause.Flag("operation-id", "Operation ID. Set flag multiple times to include multiple operations").Required().StringsVar(&c.operationIDs) - c.CmdClause.Flag("tag-id", "Tag ID to add. Set flag multiple times to include multiple tags").Required().StringsVar(&c.tagIDs) - - // 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 *AddBulkCommand) 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 len(c.operationIDs) == 0 { - return errors.New("at least one operation-id must be provided") - } - if len(c.tagIDs) == 0 { - return errors.New("at least one tag-id must be provided") - } - - fc, ok := c.Globals.APIClient.(*fastly.Client) - if !ok { - return errors.New("failed to convert interface to a fastly client") - } - - result, err := operations.BulkAddTags(context.TODO(), fc, &operations.BulkAddTagsInput{ - ServiceID: &serviceID, - OperationIDs: c.operationIDs, - TagIDs: c.tagIDs, - }) - if err != nil { - c.Globals.ErrLog.Add(err) - return err - } - - if ok, err := c.WriteJSON(out, result); ok { - return err - } - - text.Success(out, "Bulk add tags completed. Processed %d operations", len(result.Data)) - return nil -} diff --git a/pkg/commands/apisecurity/tags/tags_test.go b/pkg/commands/apisecurity/tags/tags_test.go index df7ce1387..53f36cf84 100644 --- a/pkg/commands/apisecurity/tags/tags_test.go +++ b/pkg/commands/apisecurity/tags/tags_test.go @@ -22,9 +22,6 @@ const ( tagDescription = "All-APIv1-endpoints" updatedTagName = "APIv1.1" updatedTagDesc = "Updated-APIv1-endpoints" - operationID1 = "op-123" - operationID2 = "op-456" - tagID2 = "tag-456" ) var tag = operations.OperationTag{ @@ -429,102 +426,6 @@ func TestTagsUpdate(t *testing.T) { testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } -func TestTagsAddBulk(t *testing.T) { - bulkResponse := operations.BulkOperationResultsResponse{ - Data: []operations.BulkOperationResult{ - { - ID: operationID1, - StatusCode: 200, - }, - { - ID: operationID2, - StatusCode: 200, - }, - }, - } - - scenarios := []testutil.CLIScenario{ - { - Name: "validate missing --service-id flag", - Args: fmt.Sprintf("--operation-id %s --tag-id %s", operationID1, tagID), - WantError: "error reading service: no service ID found", - }, - { - Name: "validate missing --operation-id flag", - Args: fmt.Sprintf("--service-id %s --tag-id %s", serviceID, tagID), - WantError: "error parsing arguments: required flag --operation-id not provided", - }, - { - Name: "validate missing --tag-id flag", - Args: fmt.Sprintf("--service-id %s --operation-id %s", serviceID, operationID1), - WantError: "error parsing arguments: required flag --tag-id not provided", - }, - { - Name: "validate bad request", - Args: fmt.Sprintf("--service-id %s --operation-id %s --tag-id %s", serviceID, operationID1, 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 request", - "status": 400 - } - `))), - }, - }, - }, - WantError: "400 - Bad Request", - }, - { - Name: "validate API success (single operation, single tag)", - Args: fmt.Sprintf("--service-id %s --operation-id %s --tag-id %s", serviceID, operationID1, tagID), - Client: &http.Client{ - Transport: &testutil.MockRoundTripper{ - Response: &http.Response{ - StatusCode: http.StatusMultiStatus, - Status: http.StatusText(http.StatusMultiStatus), - Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), - }, - }, - }, - WantOutput: fstfmt.Success("Bulk add tags completed. Processed %d operations", 2), - }, - { - Name: "validate API success (multiple operations, multiple tags)", - Args: fmt.Sprintf("--service-id %s --operation-id %s --operation-id %s --tag-id %s --tag-id %s", serviceID, operationID1, operationID2, tagID, tagID2), - Client: &http.Client{ - Transport: &testutil.MockRoundTripper{ - Response: &http.Response{ - StatusCode: http.StatusMultiStatus, - Status: http.StatusText(http.StatusMultiStatus), - Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), - }, - }, - }, - WantOutput: fstfmt.Success("Bulk add tags completed. Processed %d operations", 2), - }, - { - Name: "validate optional --json flag", - Args: fmt.Sprintf("--service-id %s --operation-id %s --tag-id %s --json", serviceID, operationID1, tagID), - Client: &http.Client{ - Transport: &testutil.MockRoundTripper{ - Response: &http.Response{ - StatusCode: http.StatusMultiStatus, - Status: http.StatusText(http.StatusMultiStatus), - Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), - }, - }, - }, - WantOutput: fstfmt.EncodeJSON(bulkResponse), - }, - } - - testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "add-bulk"}, scenarios) -} - var tagString = strings.TrimSpace(` ID: tag-123 Name: APIv1 diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 6ae22bc71..4d1f049fd 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -443,7 +443,6 @@ func Define( // nolint:revive // function-length apiSecurityTagsGet := tags.NewGetCommand(apiSecurityTagsRoot.CmdClause, data) apiSecurityTagsList := tags.NewListCommand(apiSecurityTagsRoot.CmdClause, data) apiSecurityTagsUpdate := tags.NewUpdateCommand(apiSecurityTagsRoot.CmdClause, data) - apiSecurityTagsAddBulk := tags.NewAddBulkCommand(apiSecurityTagsRoot.CmdClause, data) objectStorageRoot := objectstorage.NewRootCommand(app, data) objectStorageAccesskeysRoot := accesskeys.NewRootCommand(objectStorageRoot.CmdClause, data) objectStorageAccesskeysCreate := accesskeys.NewCreateCommand(objectStorageAccesskeysRoot.CmdClause, data) @@ -1465,7 +1464,6 @@ func Define( // nolint:revive // function-length apiSecurityTagsGet, apiSecurityTagsList, apiSecurityTagsUpdate, - apiSecurityTagsAddBulk, objectStorageRoot, objectStorageAccesskeysRoot, objectStorageAccesskeysCreate,