From 0f4d27de85693523d81134cefd1790448e588b4a Mon Sep 17 00:00:00 2001 From: Ben Zvan Date: Mon, 14 Dec 2020 03:39:10 -0600 Subject: [PATCH 1/2] Adds support for managing edge dictionaries (#159) * Adds dictionary describe * adds dictionary create * adds dictionary delete * adds dictionaryitem describe * adds dictionaryitem list * adds dictionaryitem create * adds dictionaryitem update * adds dictionaryitem delete * use key and value in place of itemkey and itemvalue * use full name of edge dictionary in help * more terse output on create dictionary item * adds verbose output for describe dictionary * Update pkg/text/dictionaryitem.go fix typo Co-authored-by: Mark McDonnell * Update pkg/text/dictionary.go fix typo Co-authored-by: Mark McDonnell * fixes a number of old typos in doc comments * switch from fmt to text in this PR * adds dictionary list * adds update dictionary * refactor for fastly/v2 * adds dictionary batchmodify * Combines multiple uses of MakeTestFile into testutils configure_test.go and edgedictionaryitem_test.go both had a need for a tempfile, so I simplified the method from configure_test and added it to the testutils where other common test utils go. * Reduce specificity of wantError for missing file Windows tests were failing due to a difference in the text content of the missingFile error. I reduced the error expectation to the portion that I know is not OS dependent. * deletes prefix property from dictionaryitem_test * fix dictionary id/name in dictionaryitem describe * fix copy/paste 'describe a service' in new doc comments * adds write-only option to dictionary create command * adds write-only option to dictionary update command * Fixes logic in dictionary describe The `dictionary describe --verbose` command makes additional calls to the fastly api's GetDictionaryInfo and ListDictionaryItems endpoints. The call to GetDictionaryInfo was using the serviceID value in place of the dictionaryID, which would have resulted in a failed call at best and values from the wrong dictionary at worst. I added enforcement in the mock call to that endpoint to verify that the dictionaryID in the input is the same as the dictionaryID in the output from the original call to GetDictionary. * Update pkg/edgedictionaryitem/update.go Add TODO for cleanup due to errors in the current go-fastly release. Co-authored-by: Mark McDonnell * Apply suggestions from code review Small text changes that would have bothered me for ages once I noticed them. Co-authored-by: Mark McDonnell Co-authored-by: Mark McDonnell --- pkg/api/interface.go | 15 + pkg/app/run.go | 32 ++ pkg/app/run_test.go | 90 ++++ pkg/configure/configure_test.go | 38 +- pkg/edgedictionary/create.go | 65 +++ pkg/edgedictionary/delete.go | 48 ++ pkg/edgedictionary/describe.go | 78 +++ pkg/edgedictionary/edgedictionary_test.go | 492 ++++++++++++++++++ pkg/edgedictionary/list.go | 52 ++ pkg/edgedictionary/root.go | 28 + pkg/edgedictionary/update.go | 78 +++ pkg/edgedictionaryitem/batch.go | 73 +++ pkg/edgedictionaryitem/create.go | 50 ++ pkg/edgedictionaryitem/delete.go | 48 ++ pkg/edgedictionaryitem/describe.go | 49 ++ .../edgedictionaryitem_test.go | 446 ++++++++++++++++ pkg/edgedictionaryitem/list.go | 53 ++ pkg/edgedictionaryitem/root.go | 28 + pkg/edgedictionaryitem/update.go | 58 +++ pkg/mock/api.go | 75 +++ pkg/testutil/file.go | 20 + pkg/text/backend.go | 2 +- pkg/text/dictionary.go | 26 + pkg/text/dictionaryitem.go | 37 ++ pkg/text/dictionaryitem_test.go | 30 ++ pkg/text/healthcheck.go | 2 +- pkg/text/service.go | 6 +- 27 files changed, 1977 insertions(+), 42 deletions(-) create mode 100644 pkg/edgedictionary/create.go create mode 100644 pkg/edgedictionary/delete.go create mode 100644 pkg/edgedictionary/describe.go create mode 100644 pkg/edgedictionary/edgedictionary_test.go create mode 100644 pkg/edgedictionary/list.go create mode 100644 pkg/edgedictionary/root.go create mode 100644 pkg/edgedictionary/update.go create mode 100644 pkg/edgedictionaryitem/batch.go create mode 100644 pkg/edgedictionaryitem/create.go create mode 100644 pkg/edgedictionaryitem/delete.go create mode 100644 pkg/edgedictionaryitem/describe.go create mode 100644 pkg/edgedictionaryitem/edgedictionaryitem_test.go create mode 100644 pkg/edgedictionaryitem/list.go create mode 100644 pkg/edgedictionaryitem/root.go create mode 100644 pkg/edgedictionaryitem/update.go create mode 100644 pkg/testutil/file.go create mode 100644 pkg/text/dictionary.go create mode 100644 pkg/text/dictionaryitem.go create mode 100644 pkg/text/dictionaryitem_test.go diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 0dbf9a69f..d4cb816ab 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -60,6 +60,21 @@ type Interface interface { GetPackage(*fastly.GetPackageInput) (*fastly.Package, error) UpdatePackage(*fastly.UpdatePackageInput) (*fastly.Package, error) + CreateDictionary(*fastly.CreateDictionaryInput) (*fastly.Dictionary, error) + GetDictionary(*fastly.GetDictionaryInput) (*fastly.Dictionary, error) + DeleteDictionary(*fastly.DeleteDictionaryInput) error + ListDictionaries(*fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) + UpdateDictionary(*fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) + + ListDictionaryItems(*fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) + GetDictionaryItem(*fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) + CreateDictionaryItem(*fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) + UpdateDictionaryItem(*fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) + DeleteDictionaryItem(*fastly.DeleteDictionaryItemInput) error + BatchModifyDictionaryItems(*fastly.BatchModifyDictionaryItemsInput) error + + GetDictionaryInfo(*fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) + CreateBigQuery(*fastly.CreateBigQueryInput) (*fastly.BigQuery, error) ListBigQueries(*fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) GetBigQuery(*fastly.GetBigQueryInput) (*fastly.BigQuery, error) diff --git a/pkg/app/run.go b/pkg/app/run.go index 4860b9099..5e3e98362 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -17,6 +17,8 @@ import ( "github.com/fastly/cli/pkg/config" "github.com/fastly/cli/pkg/configure" "github.com/fastly/cli/pkg/domain" + "github.com/fastly/cli/pkg/edgedictionary" + "github.com/fastly/cli/pkg/edgedictionaryitem" "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/healthcheck" "github.com/fastly/cli/pkg/logging" @@ -156,6 +158,21 @@ func Run(args []string, env config.Environment, file config.File, configFilePath healthcheckUpdate := healthcheck.NewUpdateCommand(healthcheckRoot.CmdClause, &globals) healthcheckDelete := healthcheck.NewDeleteCommand(healthcheckRoot.CmdClause, &globals) + dictionaryRoot := edgedictionary.NewRootCommand(app, &globals) + dictionaryCreate := edgedictionary.NewCreateCommand(dictionaryRoot.CmdClause, &globals) + dictionaryDescribe := edgedictionary.NewDescribeCommand(dictionaryRoot.CmdClause, &globals) + dictionaryDelete := edgedictionary.NewDeleteCommand(dictionaryRoot.CmdClause, &globals) + dictionaryList := edgedictionary.NewListCommand(dictionaryRoot.CmdClause, &globals) + dictionaryUpdate := edgedictionary.NewUpdateCommand(dictionaryRoot.CmdClause, &globals) + + dictionaryItemRoot := edgedictionaryitem.NewRootCommand(app, &globals) + dictionaryItemList := edgedictionaryitem.NewListCommand(dictionaryItemRoot.CmdClause, &globals) + dictionaryItemDescribe := edgedictionaryitem.NewDescribeCommand(dictionaryItemRoot.CmdClause, &globals) + dictionaryItemCreate := edgedictionaryitem.NewCreateCommand(dictionaryItemRoot.CmdClause, &globals) + dictionaryItemUpdate := edgedictionaryitem.NewUpdateCommand(dictionaryItemRoot.CmdClause, &globals) + dictionaryItemDelete := edgedictionaryitem.NewDeleteCommand(dictionaryItemRoot.CmdClause, &globals) + dictionaryItemBatchModify := edgedictionaryitem.NewBatchCommand(dictionaryItemRoot.CmdClause, &globals) + loggingRoot := logging.NewRootCommand(app, &globals) bigQueryRoot := bigquery.NewRootCommand(loggingRoot.CmdClause, &globals) @@ -381,6 +398,21 @@ func Run(args []string, env config.Environment, file config.File, configFilePath healthcheckUpdate, healthcheckDelete, + dictionaryRoot, + dictionaryCreate, + dictionaryDescribe, + dictionaryDelete, + dictionaryList, + dictionaryUpdate, + + dictionaryItemRoot, + dictionaryItemList, + dictionaryItemDescribe, + dictionaryItemCreate, + dictionaryItemUpdate, + dictionaryItemDelete, + dictionaryItemBatchModify, + loggingRoot, bigQueryRoot, diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index e6503974b..429864b60 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -111,6 +111,8 @@ COMMANDS domain Manipulate Fastly service version domains backend Manipulate Fastly service version backends healthcheck Manipulate Fastly service version healthchecks + dictionary Manipulate Fastly edge dictionaries + dictionaryitem Manipulate Fastly edge dictionary items logging Manipulate Fastly service version logging endpoints stats View statistics (historical and realtime) for a Fastly service @@ -559,6 +561,94 @@ COMMANDS --version=VERSION Number of service version -n, --name=NAME Healthcheck name + dictionary create --version=VERSION --name=NAME [] + Create a Fastly edge dictionary on a Fastly service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + -n, --name=NAME Name of Dictionary + --write-only=WRITE-ONLY Whether to mark this dictionary as write-only. + Can be true or false (defaults to false) + + dictionary describe --version=VERSION --name=NAME [] + Show detailed information about a Fastly edge dictionary + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + -n, --name=NAME Name of Dictionary + + dictionary delete --version=VERSION --name=NAME [] + Delete a Fastly edge dictionary from a Fastly service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + -n, --name=NAME Name of Dictionary + + dictionary list --version=VERSION [] + List all dictionaries on a Fastly service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + + dictionary update --version=VERSION --name=NAME [] + Update name of dictionary on a Fastly service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + -n, --name=NAME Old name of Dictionary + --new-name=NEW-NAME New name of Dictionary + --write-only=WRITE-ONLY Whether to mark this dictionary as write-only. + Can be true or false (defaults to false) + + dictionaryitem list --dictionary-id=DICTIONARY-ID [] + List items in a Fastly edge dictionary + + -s, --service-id=SERVICE-ID Service ID + --dictionary-id=DICTIONARY-ID + Dictionary ID + + dictionaryitem describe --dictionary-id=DICTIONARY-ID --key=KEY [] + Show detailed information about a Fastly edge dictionary item + + -s, --service-id=SERVICE-ID Service ID + --dictionary-id=DICTIONARY-ID + Dictionary ID + --key=KEY Dictionary item key + + dictionaryitem create --dictionary-id=DICTIONARY-ID --key=KEY --value=VALUE [] + Create a new item on a Fastly edge dictionary + + -s, --service-id=SERVICE-ID Service ID + --dictionary-id=DICTIONARY-ID + Dictionary ID + --key=KEY Dictionary item key + --value=VALUE Dictionary item value + + dictionaryitem update --dictionary-id=DICTIONARY-ID --key=KEY --value=VALUE [] + Update or insert an item on a Fastly edge dictionary + + -s, --service-id=SERVICE-ID Service ID + --dictionary-id=DICTIONARY-ID + Dictionary ID + --key=KEY Dictionary item key + --value=VALUE Dictionary item value + + dictionaryitem delete --dictionary-id=DICTIONARY-ID --key=KEY [] + Delete an item from a Fastly edge dictionary + + -s, --service-id=SERVICE-ID Service ID + --dictionary-id=DICTIONARY-ID + Dictionary ID + --key=KEY Dictionary item key + + dictionaryitem batchmodify --dictionary-id=DICTIONARY-ID --file=FILE [] + Update multiple items in a Fastly edge dictionary + + -s, --service-id=SERVICE-ID Service ID + --dictionary-id=DICTIONARY-ID + Dictionary ID + --file=FILE Batch update json file + logging bigquery create --name=NAME --version=VERSION --project-id=PROJECT-ID --dataset=DATASET --table=TABLE --user=USER --secret-key=SECRET-KEY [] Create a BigQuery logging endpoint on a Fastly service version diff --git a/pkg/configure/configure_test.go b/pkg/configure/configure_test.go index b34ad4e80..2e998434a 100644 --- a/pkg/configure/configure_test.go +++ b/pkg/configure/configure_test.go @@ -2,14 +2,11 @@ package configure_test import ( "bytes" - "crypto/rand" "errors" - "fmt" "io" "io/ioutil" "net/http" "os" - "path/filepath" "strings" "testing" @@ -198,7 +195,7 @@ func TestConfigure(t *testing.T) { }, } { t.Run(testcase.name, func(t *testing.T) { - configFilePath := tempFile(t, testcase.configFileData) + configFilePath := testutil.MakeTempFile(t, testcase.configFileData) defer os.RemoveAll(configFilePath) var ( @@ -224,36 +221,3 @@ func TestConfigure(t *testing.T) { }) } } - -func tempFile(t *testing.T, contents string) string { - t.Helper() - - p := make([]byte, 32) - n, err := rand.Read(p) - if err != nil { - t.Fatal(err) - } - - filename := filepath.Join( - os.TempDir(), - fmt.Sprintf("fastly-%x", p[:n]), - ) - - if contents != "" { - f, err := os.Create(filename) - if err != nil { - t.Fatal(err) - } - if _, err := fmt.Fprintln(f, contents); err != nil { - t.Fatal(err) - } - if err := f.Sync(); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - } - - return filename -} diff --git a/pkg/edgedictionary/create.go b/pkg/edgedictionary/create.go new file mode 100644 index 000000000..8888e4b43 --- /dev/null +++ b/pkg/edgedictionary/create.go @@ -0,0 +1,65 @@ +package edgedictionary + +import ( + "io" + "strconv" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// CreateCommand calls the Fastly API to create a service. +type CreateCommand struct { + common.Base + manifest manifest.Data + Input fastly.CreateDictionaryInput + + writeOnly common.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { + var c CreateCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("create", "Create a Fastly edge dictionary on a Fastly service version") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) + c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Required().StringVar(&c.Input.Name) + c.CmdClause.Flag("write-only", "Whether to mark this dictionary as write-only. Can be true or false (defaults to false)").Action(c.writeOnly.Set).StringVar(&c.writeOnly.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + if c.writeOnly.WasSet { + writeOnly, err := strconv.ParseBool(c.writeOnly.Value) + if err != nil { + return err + } + c.Input.WriteOnly = fastly.Compatibool(writeOnly) + } + + d, err := c.Globals.Client.CreateDictionary(&c.Input) + if err != nil { + return err + } + + var writeOnlyOutput string + if d.WriteOnly { + writeOnlyOutput = "as write-only " + } + + text.Success(out, "Created dictionary %s %s(service %s version %d)", d.Name, writeOnlyOutput, d.ServiceID, d.ServiceVersion) + return nil +} diff --git a/pkg/edgedictionary/delete.go b/pkg/edgedictionary/delete.go new file mode 100644 index 000000000..5b2ba7e2c --- /dev/null +++ b/pkg/edgedictionary/delete.go @@ -0,0 +1,48 @@ +package edgedictionary + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// DeleteCommand calls the Fastly API to delete a service. +type DeleteCommand struct { + common.Base + manifest manifest.Data + Input fastly.DeleteDictionaryInput +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { + var c DeleteCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("delete", "Delete a Fastly edge dictionary from a Fastly service version") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) + c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Required().StringVar(&c.Input.Name) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + err := c.Globals.Client.DeleteDictionary(&c.Input) + if err != nil { + return err + } + + text.Success(out, "Deleted dictionary %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/edgedictionary/describe.go b/pkg/edgedictionary/describe.go new file mode 100644 index 000000000..1a1a71dc1 --- /dev/null +++ b/pkg/edgedictionary/describe.go @@ -0,0 +1,78 @@ +package edgedictionary + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// DescribeCommand calls the Fastly API to describe a dictionary. +type DescribeCommand struct { + common.Base + manifest manifest.Data + Input fastly.GetDictionaryInput +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { + var c DescribeCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly edge dictionary").Alias("get") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) + c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Required().StringVar(&c.Input.Name) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + dictionary, err := c.Globals.Client.GetDictionary(&c.Input) + if err != nil { + return err + } + + text.Output(out, "Service ID: %s", dictionary.ServiceID) + text.Output(out, "Version: %d", dictionary.ServiceVersion) + text.PrintDictionary(out, "", dictionary) + + if c.Globals.Verbose() { + infoInput := fastly.GetDictionaryInfoInput{ + ServiceID: c.Input.ServiceID, + ServiceVersion: c.Input.ServiceVersion, + ID: dictionary.ID, + } + info, err := c.Globals.Client.GetDictionaryInfo(&infoInput) + if err != nil { + return err + } + text.Output(out, "Digest: %s", info.Digest) + text.Output(out, "Item Count: %d", info.ItemCount) + + itemInput := fastly.ListDictionaryItemsInput{ + ServiceID: c.Input.ServiceID, + DictionaryID: dictionary.ID, + } + items, err := c.Globals.Client.ListDictionaryItems(&itemInput) + if err != nil { + return err + } + for i, item := range items { + text.Output(out, "Item %d/%d:", i+1, len(items)) + text.PrintDictionaryItemKV(out, " ", item) + } + } + + return nil +} diff --git a/pkg/edgedictionary/edgedictionary_test.go b/pkg/edgedictionary/edgedictionary_test.go new file mode 100644 index 000000000..431759eef --- /dev/null +++ b/pkg/edgedictionary/edgedictionary_test.go @@ -0,0 +1,492 @@ +package edgedictionary_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/update" + "github.com/fastly/go-fastly/v2/fastly" +) + +func TestDictionaryDescribe(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionary", "describe", "--version", "1", "--service-id", "123"}, + api: mock.API{GetDictionaryFn: describeDictionaryOK}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"dictionary", "describe", "--version", "1", "--service-id", "123", "--name", "dict-1"}, + api: mock.API{GetDictionaryFn: describeDictionaryOK}, + wantOutput: describeDictionaryOutput, + }, + { + args: []string{"dictionary", "describe", "--version", "1", "--service-id", "123", "--name", "dict-1"}, + api: mock.API{GetDictionaryFn: describeDictionaryOKDeleted}, + wantOutput: describeDictionaryOutputDeleted, + }, + { + args: []string{"dictionary", "describe", "--version", "1", "--service-id", "123", "--name", "dict-1", "--verbose"}, + api: mock.API{ + GetDictionaryFn: describeDictionaryOK, + GetDictionaryInfoFn: getDictionaryInfoOK, + ListDictionaryItemsFn: listDictionaryItemsOK, + }, + wantOutput: describeDictionaryOutputVerbose, + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestDictionaryCreate(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionary", "create", "--version", "1", "--service-id", "123"}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"dictionary", "create", "--version", "1", "--service-id", "123", "--name", "denylist"}, + api: mock.API{CreateDictionaryFn: createDictionaryOK}, + wantOutput: createDictionaryOutput, + }, + { + args: []string{"dictionary", "create", "--version", "1", "--service-id", "123", "--name", "denylist", "--write-only", "true"}, + api: mock.API{CreateDictionaryFn: createDictionaryOK}, + wantOutput: createDictionaryOutputWriteOnly, + }, + { + args: []string{"dictionary", "create", "--version", "1", "--service-id", "123", "--name", "denylist", "--write-only", "fish"}, + wantError: "strconv.ParseBool: parsing \"fish\": invalid syntax", + }, + { + args: []string{"dictionary", "create", "--version", "1", "--service-id", "123", "--name", "denylist"}, + api: mock.API{CreateDictionaryFn: createDictionaryDuplicate}, + wantError: "Duplicate record", + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestDeleteDictionary(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionary", "delete", "--service-id", "123", "--version", "1"}, + api: mock.API{DeleteDictionaryFn: deleteDictionaryOK}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"dictionary", "delete", "--service-id", "123", "--version", "1", "--name", "allowlist"}, + api: mock.API{DeleteDictionaryFn: deleteDictionaryOK}, + wantOutput: deleteDictionaryOutput, + }, + { + args: []string{"dictionary", "delete", "--service-id", "123", "--version", "1", "--name", "allowlist"}, + api: mock.API{DeleteDictionaryFn: deleteDictionaryError}, + wantError: errTest.Error(), + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestListDictionary(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionary", "list", "--version", "1"}, + api: mock.API{ListDictionariesFn: listDictionariesOk}, + wantError: "error reading service: no service ID found", + }, + { + args: []string{"dictionary", "list", "--service-id", "123"}, + api: mock.API{DeleteDictionaryFn: deleteDictionaryOK}, + wantError: "error parsing arguments: required flag --version not provided", + }, + { + args: []string{"dictionary", "list", "--version", "1", "--service-id", "123"}, + api: mock.API{ListDictionariesFn: listDictionariesOk}, + wantOutput: listDictionariesOutput, + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestUpdateDictionary(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionary", "update", "--version", "1", "--name", "oldname", "--new-name", "newname"}, + wantError: "error reading service: no service ID found", + }, + { + args: []string{"dictionary", "update", "--service-id", "123", "--name", "oldname", "--new-name", "newname"}, + wantError: "error parsing arguments: required flag --version not provided", + }, + { + args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--new-name", "newname"}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname"}, + wantError: "error parsing arguments: required flag --new-name or --write-only not provided", + }, + { + args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname", "--new-name", "dict-1"}, + api: mock.API{UpdateDictionaryFn: updateDictionaryNameOK}, + wantOutput: updateDictionaryNameOutput, + }, + { + args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname", "--new-name", "dict-1", "--write-only", "true"}, + api: mock.API{UpdateDictionaryFn: updateDictionaryNameOK}, + wantOutput: updateDictionaryNameOutput, + }, + { + args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname", "--write-only", "true"}, + api: mock.API{UpdateDictionaryFn: updateDictionaryWriteOnlyOK}, + wantOutput: updateDictionaryOutput, + }, + { + args: []string{"dictionary", "update", "-v", "--service-id", "123", "--version", "1", "--name", "oldname", "--new-name", "dict-1"}, + api: mock.API{UpdateDictionaryFn: updateDictionaryNameOK}, + wantOutput: updateDictionaryOutputVerbose, + }, + { + args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname", "--new-name", "dict-1"}, + api: mock.API{UpdateDictionaryFn: updateDictionaryError}, + wantError: errTest.Error(), + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func describeDictionaryOK(i *fastly.GetDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + Name: i.Name, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: false, + ID: "456", + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func describeDictionaryOKDeleted(i *fastly.GetDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + Name: i.Name, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: false, + ID: "456", + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:08Z"), + }, nil +} + +func createDictionaryOK(i *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + Name: i.Name, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: i.WriteOnly == true, + ID: "456", + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +// getDictionaryInfoOK mocks the response from fastly.GetDictionaryInfo, which is not otherwise used +// in the fastly-cli and will need to be updated here if that call changes. +// This function requires i.ID to equal "456" to enforce the input to this call matches the +// response to GetDictionaryInfo in describeDictionaryOK +func getDictionaryInfoOK(i *fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) { + if i.ID == "456" { + return &fastly.DictionaryInfo{ + ItemCount: 2, + LastUpdated: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + Digest: "digest_hash", + }, nil + } else { + return nil, errFail + } +} + +// listDictionaryItemsOK mocks the response from fastly.ListDictionaryItems which is primarily used +// in the fastly-cli.edgedictionaryitem package and will need to be updated here if that call changes +func listDictionaryItemsOK(i *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) { + return []*fastly.DictionaryItem{ + { + ServiceID: i.ServiceID, + DictionaryID: i.DictionaryID, + ItemKey: "foo", + ItemValue: "bar", + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, + { + ServiceID: i.ServiceID, + DictionaryID: i.DictionaryID, + ItemKey: "baz", + ItemValue: "bear", + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:06:08Z"), + }, + }, nil +} + +func createDictionaryDuplicate(*fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { + return nil, errors.New("Duplicate record") +} + +func deleteDictionaryOK(*fastly.DeleteDictionaryInput) error { + return nil +} + +func deleteDictionaryError(*fastly.DeleteDictionaryInput) error { + return errTest +} + +func listDictionariesOk(i *fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) { + return []*fastly.Dictionary{ + { + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + Name: "dict-1", + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: false, + ID: "456", + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, + { + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + Name: "dict-2", + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: false, + ID: "456", + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, + }, nil +} + +func updateDictionaryNameOK(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + Name: *i.NewName, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: cbPtrIsTrue(i.WriteOnly), + ID: "456", + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func updateDictionaryWriteOnlyOK(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + Name: i.Name, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: cbPtrIsTrue(i.WriteOnly), + ID: "456", + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func cbPtrIsTrue(cb *fastly.Compatibool) bool { + if cb != nil { + return *cb == true + } + return false +} + +func updateDictionaryError(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { + return nil, errTest +} + +var errTest = errors.New("an expected error ocurred") +var errFail = errors.New("this error should not be returned and indicates a failure in the code") + +var createDictionaryOutput = "\nSUCCESS: Created dictionary denylist (service 123 version 1)\n" +var createDictionaryOutputWriteOnly = "\nSUCCESS: Created dictionary denylist as write-only (service 123 version 1)\n" +var deleteDictionaryOutput = "\nSUCCESS: Deleted dictionary allowlist (service 123 version 1)\n" +var updateDictionaryOutput = "\nSUCCESS: Updated dictionary oldname (service 123 version 1)\n" +var updateDictionaryNameOutput = "\nSUCCESS: Updated dictionary dict-1 (service 123 version 1)\n" + +var updateDictionaryOutputVerbose = strings.Join( + []string{ + "Fastly API token not provided", + "Fastly API endpoint: https://api.fastly.com", + "", + strings.TrimSpace(updateDictionaryNameOutput), + describeDictionaryOutput, + }, + "\n") + +var describeDictionaryOutput = strings.TrimSpace(` +Service ID: 123 +Version: 1 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +`) + "\n" + +var describeDictionaryOutputDeleted = strings.TrimSpace(` +Service ID: 123 +Version: 1 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +Deleted (UTC): 2001-02-03 04:05 +`) + "\n" + +var describeDictionaryOutputVerbose = strings.TrimSpace(` +Fastly API token not provided +Fastly API endpoint: https://api.fastly.com +Service ID: 123 +Version: 1 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +Digest: digest_hash +Item Count: 2 +Item 1/2: + Item Key: foo + Item Value: bar +Item 2/2: + Item Key: baz + Item Value: bear +`) + "\n" + +var listDictionariesOutput = strings.TrimSpace(` +Service ID: 123 +Version: 1 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +ID: 456 +Name: dict-2 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +`) + "\n" diff --git a/pkg/edgedictionary/list.go b/pkg/edgedictionary/list.go new file mode 100644 index 000000000..ccf59f3ab --- /dev/null +++ b/pkg/edgedictionary/list.go @@ -0,0 +1,52 @@ +package edgedictionary + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// ListCommand calls the Fastly API to list dictionaries +type ListCommand struct { + common.Base + manifest manifest.Data + Input fastly.ListDictionariesInput +} + +// NewListCommand returns a usable command registered under the parent +func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { + var c ListCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("list", "List all dictionaries on a Fastly service version") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + dictionaries, err := c.Globals.Client.ListDictionaries(&c.Input) + if err != nil { + return err + } + + text.Output(out, "Service ID: %s", serviceID) + text.Output(out, "Version: %d", c.Input.ServiceVersion) + for _, dictionary := range dictionaries { + text.PrintDictionary(out, "", dictionary) + } + + return nil +} diff --git a/pkg/edgedictionary/root.go b/pkg/edgedictionary/root.go new file mode 100644 index 000000000..a611e94f3 --- /dev/null +++ b/pkg/edgedictionary/root.go @@ -0,0 +1,28 @@ +package edgedictionary + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/config" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + common.Base + // no flags +} + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command("dictionary", "Manipulate Fastly edge dictionaries") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + panic("unreachable") +} diff --git a/pkg/edgedictionary/update.go b/pkg/edgedictionary/update.go new file mode 100644 index 000000000..f45e09c6a --- /dev/null +++ b/pkg/edgedictionary/update.go @@ -0,0 +1,78 @@ +package edgedictionary + +import ( + "fmt" + "io" + "strconv" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// UpdateCommand calls the Fastly API to update a dictionary. +type UpdateCommand struct { + common.Base + manifest manifest.Data + input fastly.UpdateDictionaryInput + + newname common.OptionalString + writeOnly common.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { + var c UpdateCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("update", "Update name of dictionary on a Fastly service version").Alias("get") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.input.ServiceVersion) + c.CmdClause.Flag("name", "Old name of Dictionary").Short('n').Required().StringVar(&c.input.Name) + c.CmdClause.Flag("new-name", "New name of Dictionary").Action(c.newname.Set).StringVar(&c.newname.Value) + c.CmdClause.Flag("write-only", "Whether to mark this dictionary as write-only. Can be true or false (defaults to false)").Action(c.writeOnly.Set).StringVar(&c.writeOnly.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.input.ServiceID = serviceID + + if !c.newname.WasSet && !c.writeOnly.WasSet { + return errors.RemediationError{Inner: fmt.Errorf("error parsing arguments: required flag --new-name or --write-only not provided"), Remediation: "To fix this error, provide at least one of the aforementioned flags"} + } + + if c.newname.WasSet { + c.input.NewName = &c.newname.Value + } + + if c.writeOnly.WasSet { + writeOnly, err := strconv.ParseBool(c.writeOnly.Value) + if err != nil { + return err + } + c.input.WriteOnly = fastly.CBool(writeOnly) + } + + d, err := c.Globals.Client.UpdateDictionary(&c.input) + if err != nil { + return err + } + + text.Success(out, "Updated dictionary %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) + + if c.Globals.Verbose() { + text.Output(out, "Service ID: %s", d.ServiceID) + text.Output(out, "Version: %d", d.ServiceVersion) + text.PrintDictionary(out, "", d) + } + + return nil +} diff --git a/pkg/edgedictionaryitem/batch.go b/pkg/edgedictionaryitem/batch.go new file mode 100644 index 000000000..0f225af2d --- /dev/null +++ b/pkg/edgedictionaryitem/batch.go @@ -0,0 +1,73 @@ +package edgedictionaryitem + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// BatchCommand calls the Fastly API to batch update a dictionary. +type BatchCommand struct { + common.Base + manifest manifest.Data + Input fastly.BatchModifyDictionaryItemsInput + + file common.OptionalString +} + +// NewBatchCommand returns a usable command registered under the parent. +func NewBatchCommand(parent common.Registerer, globals *config.Data) *BatchCommand { + var c BatchCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("batchmodify", "Update multiple items in a Fastly edge dictionary") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + c.CmdClause.Flag("file", "Batch update json file").Required().Action(c.file.Set).StringVar(&c.file.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *BatchCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + jsonFile, err := os.Open(c.file.Value) + if err != nil { + return err + } + + jsonBytes, err := ioutil.ReadAll(jsonFile) + if err != nil { + return err + } + + err = json.Unmarshal(jsonBytes, &c.Input) + if err != nil { + return err + } + + if len(c.Input.Items) == 0 { + return fmt.Errorf("item key not found in file %s", c.file.Value) + } + + err = c.Globals.Client.BatchModifyDictionaryItems(&c.Input) + if err != nil { + return err + } + + text.Success(out, "Made %d modifications of Dictionary %s on service %s", len(c.Input.Items), c.Input.DictionaryID, c.Input.ServiceID) + return nil +} diff --git a/pkg/edgedictionaryitem/create.go b/pkg/edgedictionaryitem/create.go new file mode 100644 index 000000000..fcf61cf59 --- /dev/null +++ b/pkg/edgedictionaryitem/create.go @@ -0,0 +1,50 @@ +package edgedictionaryitem + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// CreateCommand calls the Fastly API to create a dictionary item. +type CreateCommand struct { + common.Base + manifest manifest.Data + Input fastly.CreateDictionaryItemInput +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { + var c CreateCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("create", "Create a new item on a Fastly edge dictionary") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) + c.CmdClause.Flag("value", "Dictionary item value").Required().StringVar(&c.Input.ItemValue) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + _, err := c.Globals.Client.CreateDictionaryItem(&c.Input) + if err != nil { + return err + } + + text.Success(out, "Created dictionary item %s (service %s, dictionary %s)", c.Input.ItemKey, c.Input.ServiceID, c.Input.DictionaryID) + + return nil +} diff --git a/pkg/edgedictionaryitem/delete.go b/pkg/edgedictionaryitem/delete.go new file mode 100644 index 000000000..a86e9b566 --- /dev/null +++ b/pkg/edgedictionaryitem/delete.go @@ -0,0 +1,48 @@ +package edgedictionaryitem + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// DeleteCommand calls the Fastly API to delete a service. +type DeleteCommand struct { + common.Base + manifest manifest.Data + Input fastly.DeleteDictionaryItemInput +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { + var c DeleteCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("delete", "Delete an item from a Fastly edge dictionary") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + err := c.Globals.Client.DeleteDictionaryItem(&c.Input) + if err != nil { + return err + } + + text.Success(out, "Deleted dictionary item %s (service %s, dicitonary %s)", c.Input.ItemKey, c.Input.ServiceID, c.Input.DictionaryID) + return nil +} diff --git a/pkg/edgedictionaryitem/describe.go b/pkg/edgedictionaryitem/describe.go new file mode 100644 index 000000000..1cfd57114 --- /dev/null +++ b/pkg/edgedictionaryitem/describe.go @@ -0,0 +1,49 @@ +package edgedictionaryitem + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// DescribeCommand calls the Fastly API to describe a dictionary item. +type DescribeCommand struct { + common.Base + manifest manifest.Data + Input fastly.GetDictionaryItemInput +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { + var c DescribeCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly edge dictionary item").Alias("get") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + dictionary, err := c.Globals.Client.GetDictionaryItem(&c.Input) + if err != nil { + return err + } + + text.Output(out, "Service ID: %s", c.Input.ServiceID) + text.PrintDictionaryItem(out, "", dictionary) + return nil +} diff --git a/pkg/edgedictionaryitem/edgedictionaryitem_test.go b/pkg/edgedictionaryitem/edgedictionaryitem_test.go new file mode 100644 index 000000000..83b00d708 --- /dev/null +++ b/pkg/edgedictionaryitem/edgedictionaryitem_test.go @@ -0,0 +1,446 @@ +package edgedictionaryitem_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/update" + "github.com/fastly/go-fastly/v2/fastly" +) + +func TestDictionaryItemDescribe(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionaryitem", "describe", "--service-id", "123", "--key", "foo"}, + api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, + wantError: "error parsing arguments: required flag --dictionary-id not provided", + }, + { + args: []string{"dictionaryitem", "describe", "--service-id", "123", "--dictionary-id", "456"}, + api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, + wantError: "error parsing arguments: required flag --key not provided", + }, + { + args: []string{"dictionaryitem", "describe", "--service-id", "123", "--dictionary-id", "456", "--key", "foo"}, + api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, + wantOutput: describeDictionaryItemOutput, + }, + { + args: []string{"dictionaryitem", "describe", "--service-id", "123", "--dictionary-id", "456", "--key", "foo-deleted"}, + api: mock.API{GetDictionaryItemFn: describeDictionaryItemOKDeleted}, + wantOutput: describeDictionaryItemOutputDeleted, + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestDictionaryItemsList(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionaryitem", "list", "--service-id", "123"}, + api: mock.API{ListDictionaryItemsFn: listDictionaryItemsOK}, + wantError: "error parsing arguments: required flag --dictionary-id not provided", + }, + { + args: []string{"dictionaryitem", "list", "--service-id", "123", "--dictionary-id", "456"}, + api: mock.API{ListDictionaryItemsFn: listDictionaryItemsOK}, + wantOutput: listDictionaryItemsOutput, + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestDictionaryItemCreate(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionaryitem", "create", "--service-id", "123"}, + api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: []string{"dictionaryitem", "create", "--service-id", "123", "--dictionary-id", "456"}, + api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: []string{"dictionaryitem", "create", "--service-id", "123", "--dictionary-id", "456", "--key", "foo", "--value", "bar"}, + api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, + wantOutput: "\nSUCCESS: Created dictionary item foo (service 123, dictionary 456)\n", + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestDictionaryItemUpdate(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionaryitem", "update", "--service-id", "123"}, + api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: []string{"dictionaryitem", "update", "--service-id", "123", "--dictionary-id", "456"}, + api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: []string{"dictionaryitem", "update", "--service-id", "123", "--dictionary-id", "456", "--key", "foo", "--value", "bar"}, + api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, + wantOutput: describeDictionaryItemOutput, + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestDictionaryItemDelete(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"dictionaryitem", "delete", "--service-id", "123"}, + api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: []string{"dictionaryitem", "delete", "--service-id", "123", "--dictionary-id", "456"}, + api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: []string{"dictionaryitem", "delete", "--service-id", "123", "--dictionary-id", "456", "--key", "foo"}, + api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, + wantOutput: "\nSUCCESS: Deleted dictionary item foo (service 123, dicitonary 456)\n", + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestDictionaryItemBatchModify(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + fileData string + wantError string + wantOutput string + }{ + { + args: []string{"dictionaryitem", "batchmodify", "--service-id", "123"}, + wantError: "error parsing arguments: required flag ", + }, + { + args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456"}, + wantError: "error parsing arguments: required flag --file not provided", + }, + { + fileData: `{invalid": "json"}`, + args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "filePath"}, + wantError: "invalid character 'i' looking for beginning of object key string", + }, + { + fileData: `{"valid": "json"}`, + args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "filePath"}, + wantError: "item key not found in file ", + }, + { + args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "missingFile"}, + wantError: "open missingFile", + }, + { + fileData: dictionaryItemBatchModifyInputOK, + args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "filePath"}, + api: mock.API{BatchModifyDictionaryItemsFn: batchModifyDictionaryItemsError}, + wantError: errTest.Error(), + }, + { + fileData: dictionaryItemBatchModifyInputOK, + args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "filePath"}, + api: mock.API{BatchModifyDictionaryItemsFn: batchModifyDictionaryItemsOK}, + wantOutput: "\nSUCCESS: Made 4 modifications of Dictionary 456 on service 123\n", + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var filePath string + if testcase.fileData != "" { + filePath = testutil.MakeTempFile(t, testcase.fileData) + defer os.RemoveAll(filePath) + } + + // Insert temp file path into args when "filePath" is present as placeholder + for i, v := range testcase.args { + if v == "filePath" { + testcase.args[i] = filePath + } + } + + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func describeDictionaryItemOK(i *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: i.ServiceID, + DictionaryID: i.DictionaryID, + ItemKey: i.ItemKey, + ItemValue: "bar", + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +var describeDictionaryItemOutput = strings.TrimSpace(` +Service ID: 123 +Dictionary ID: 456 +Item Key: foo +Item Value: bar +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +`) + "\n" + +func describeDictionaryItemOKDeleted(i *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: i.ServiceID, + DictionaryID: i.DictionaryID, + ItemKey: i.ItemKey, + ItemValue: "bar", + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:06:08Z"), + }, nil +} + +var describeDictionaryItemOutputDeleted = strings.TrimSpace(` +Service ID: 123 +Dictionary ID: 456 +Item Key: foo-deleted +Item Value: bar +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +Deleted (UTC): 2001-02-03 04:06 +`) + "\n" + +func listDictionaryItemsOK(i *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) { + return []*fastly.DictionaryItem{ + { + ServiceID: i.ServiceID, + DictionaryID: i.DictionaryID, + ItemKey: "foo", + ItemValue: "bar", + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, + { + ServiceID: i.ServiceID, + DictionaryID: i.DictionaryID, + ItemKey: "baz", + ItemValue: "bear", + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:06:08Z"), + }, + }, nil +} + +var listDictionaryItemsOutput = strings.TrimSpace(` +Service ID: 123 +Item: 1/2 + Dictionary ID: 456 + Item Key: foo + Item Value: bar + Created (UTC): 2001-02-03 04:05 + Last edited (UTC): 2001-02-03 04:05 + +Item: 2/2 + Dictionary ID: 456 + Item Key: baz + Item Value: bear + Created (UTC): 2001-02-03 04:05 + Last edited (UTC): 2001-02-03 04:05 + Deleted (UTC): 2001-02-03 04:06 +`) + "\n\n" + +func createDictionaryItemOK(i *fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: i.ServiceID, + DictionaryID: i.DictionaryID, + ItemKey: i.ItemKey, + ItemValue: i.ItemValue, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func updateDictionaryItemOK(i *fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: i.ServiceID, + DictionaryID: i.DictionaryID, + ItemKey: i.ItemKey, + ItemValue: *i.ItemValue, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func deleteDictionaryItemOK(i *fastly.DeleteDictionaryItemInput) error { + return nil +} + +var dictionaryItemBatchModifyInputOK = ` +{ + "items": [ + { + "op": "create", + "item_key": "some_key", + "item_value": "new_value" + }, + { + "op": "update", + "item_key": "some_key", + "item_value": "new_value" + }, + { + "op": "upsert", + "item_key": "some_key", + "item_value": "new_value" + }, + { + "op": "delete", + "item_key": "some_key" + } + ] +}` + +func batchModifyDictionaryItemsOK(i *fastly.BatchModifyDictionaryItemsInput) error { + return nil +} + +func batchModifyDictionaryItemsError(i *fastly.BatchModifyDictionaryItemsInput) error { + return errTest +} + +var errTest = errors.New("an expected error ocurred") diff --git a/pkg/edgedictionaryitem/list.go b/pkg/edgedictionaryitem/list.go new file mode 100644 index 000000000..2ffc05417 --- /dev/null +++ b/pkg/edgedictionaryitem/list.go @@ -0,0 +1,53 @@ +package edgedictionaryitem + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// ListCommand calls the Fastly API to list dictionary items. +type ListCommand struct { + common.Base + manifest manifest.Data + Input fastly.ListDictionaryItemsInput +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { + var c ListCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("list", "List items in a Fastly edge dictionary") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + dictionaries, err := c.Globals.Client.ListDictionaryItems(&c.Input) + if err != nil { + return err + } + + text.Output(out, "Service ID: %s\n", c.Input.ServiceID) + for i, dictionary := range dictionaries { + text.Output(out, "Item: %d/%d", i+1, len(dictionaries)) + text.PrintDictionaryItem(out, "\t", dictionary) + text.Break(out) + } + + return nil +} diff --git a/pkg/edgedictionaryitem/root.go b/pkg/edgedictionaryitem/root.go new file mode 100644 index 000000000..b42492742 --- /dev/null +++ b/pkg/edgedictionaryitem/root.go @@ -0,0 +1,28 @@ +package edgedictionaryitem + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/config" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + common.Base + // no flags +} + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command("dictionaryitem", "Manipulate Fastly edge dictionary items") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + panic("unreachable") +} diff --git a/pkg/edgedictionaryitem/update.go b/pkg/edgedictionaryitem/update.go new file mode 100644 index 000000000..8780eb9c3 --- /dev/null +++ b/pkg/edgedictionaryitem/update.go @@ -0,0 +1,58 @@ +package edgedictionaryitem + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +// UpdateCommand calls the Fastly API to update a dictionary item. +type UpdateCommand struct { + common.Base + manifest manifest.Data + Input fastly.UpdateDictionaryItemInput + + itemvalue common.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +// +// TODO(integralist) update to not use common.OptionalString once we have a +// new Go-Fastly release that modifies UpdateDictionaryItemInput so that the +// ItemValue is no longer optional. +func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { + var c UpdateCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("update", "Update or insert an item on a Fastly edge dictionary") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) + c.CmdClause.Flag("value", "Dictionary item value").Required().Action(c.itemvalue.Set).StringVar(&c.itemvalue.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.ServiceID = serviceID + + c.Input.ItemValue = &c.itemvalue.Value + + dictionary, err := c.Globals.Client.UpdateDictionaryItem(&c.Input) + if err != nil { + return err + } + + text.Output(out, "Service ID: %s", c.Input.ServiceID) + text.PrintDictionaryItem(out, "", dictionary) + return nil +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 27ea6e5f7..6223ca200 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -47,6 +47,21 @@ type API struct { GetPackageFn func(*fastly.GetPackageInput) (*fastly.Package, error) UpdatePackageFn func(*fastly.UpdatePackageInput) (*fastly.Package, error) + CreateDictionaryFn func(*fastly.CreateDictionaryInput) (*fastly.Dictionary, error) + GetDictionaryFn func(*fastly.GetDictionaryInput) (*fastly.Dictionary, error) + DeleteDictionaryFn func(*fastly.DeleteDictionaryInput) error + ListDictionariesFn func(*fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) + UpdateDictionaryFn func(*fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) + + ListDictionaryItemsFn func(*fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) + GetDictionaryItemFn func(*fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) + CreateDictionaryItemFn func(*fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) + UpdateDictionaryItemFn func(*fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) + DeleteDictionaryItemFn func(*fastly.DeleteDictionaryItemInput) error + BatchModifyDictionaryItemsFn func(*fastly.BatchModifyDictionaryItemsInput) error + + GetDictionaryInfoFn func(*fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) + CreateBigQueryFn func(*fastly.CreateBigQueryInput) (*fastly.BigQuery, error) ListBigQueriesFn func(*fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) GetBigQueryFn func(*fastly.GetBigQueryInput) (*fastly.BigQuery, error) @@ -357,6 +372,66 @@ func (m API) UpdatePackage(i *fastly.UpdatePackageInput) (*fastly.Package, error return m.UpdatePackageFn(i) } +// CreateDictionary implements Interface. +func (m API) CreateDictionary(i *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { + return m.CreateDictionaryFn(i) +} + +// GetDictionary implements Interface. +func (m API) GetDictionary(i *fastly.GetDictionaryInput) (*fastly.Dictionary, error) { + return m.GetDictionaryFn(i) +} + +// DeleteDictionary implements Interface. +func (m API) DeleteDictionary(i *fastly.DeleteDictionaryInput) error { + return m.DeleteDictionaryFn(i) +} + +// ListDictionaries implements Interface. +func (m API) ListDictionaries(i *fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) { + return m.ListDictionariesFn(i) +} + +// UpdateDictionary implements Interface. +func (m API) UpdateDictionary(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { + return m.UpdateDictionaryFn(i) +} + +// ListDictionaryItems implements Interface. +func (m API) ListDictionaryItems(i *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) { + return m.ListDictionaryItemsFn(i) +} + +// GetDictionaryItem implements Interface. +func (m API) GetDictionaryItem(i *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) { + return m.GetDictionaryItemFn(i) +} + +// CreateDictionaryItem implements Interface. +func (m API) CreateDictionaryItem(i *fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) { + return m.CreateDictionaryItemFn(i) +} + +// UpdateDictionaryItem implements Interface. +func (m API) UpdateDictionaryItem(i *fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) { + return m.UpdateDictionaryItemFn(i) +} + +// DeleteDictionaryItem implements Interface. +func (m API) DeleteDictionaryItem(i *fastly.DeleteDictionaryItemInput) error { + return m.DeleteDictionaryItemFn(i) +} + +// BatchModifyDictionaryItems implements Interface. +func (m API) BatchModifyDictionaryItems(i *fastly.BatchModifyDictionaryItemsInput) error { + return m.BatchModifyDictionaryItemsFn(i) +} + +// GetDictionaryInfo implements Interface. +func (m API) GetDictionaryInfo(i *fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) { + return m.GetDictionaryInfoFn(i) +} + // CreateBigQuery implements Interface. func (m API) CreateBigQuery(i *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) { return m.CreateBigQueryFn(i) diff --git a/pkg/testutil/file.go b/pkg/testutil/file.go new file mode 100644 index 000000000..593b020e8 --- /dev/null +++ b/pkg/testutil/file.go @@ -0,0 +1,20 @@ +package testutil + +import ( + "io/ioutil" + "testing" +) + +// MakeTempFile creates a tempfile with the given contents and returns its path +func MakeTempFile(t *testing.T, contents string) string { + t.Helper() + + tmpfile, err := ioutil.TempFile("", "fastly-*") + if err != nil { + t.Fatal(err) + } + if _, err := tmpfile.Write([]byte(contents)); err != nil { + t.Fatal(err) + } + return tmpfile.Name() +} diff --git a/pkg/text/backend.go b/pkg/text/backend.go index c443b0a24..b15c6f989 100644 --- a/pkg/text/backend.go +++ b/pkg/text/backend.go @@ -9,7 +9,7 @@ import ( ) // PrintBackend pretty prints a fastly.Backend structure in verbose format -// to a given io.Writer. Consumers can provider an prefix string which will +// to a given io.Writer. Consumers can provide a prefix string which will // be used as a prefix to each line, useful for indentation. func PrintBackend(out io.Writer, prefix string, b *fastly.Backend) { out = textio.NewPrefixWriter(out, prefix) diff --git a/pkg/text/dictionary.go b/pkg/text/dictionary.go new file mode 100644 index 000000000..1aca9852c --- /dev/null +++ b/pkg/text/dictionary.go @@ -0,0 +1,26 @@ +package text + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/go-fastly/v2/fastly" + "github.com/segmentio/textio" +) + +// PrintDictionary pretty prints a fastly.Dictionary structure in verbose +// format to a given io.Writer. Consumers can provide a prefix string which +// will be used as a prefix to each line, useful for indentation. +func PrintDictionary(out io.Writer, prefix string, d *fastly.Dictionary) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "ID: %s\n", d.ID) + fmt.Fprintf(out, "Name: %s\n", d.Name) + fmt.Fprintf(out, "Write Only: %t\n", d.WriteOnly) + fmt.Fprintf(out, "Created (UTC): %s\n", d.CreatedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Last edited (UTC): %s\n", d.UpdatedAt.UTC().Format(common.TimeFormat)) + if d.DeletedAt != nil { + fmt.Fprintf(out, "Deleted (UTC): %s\n", d.DeletedAt.UTC().Format(common.TimeFormat)) + } +} diff --git a/pkg/text/dictionaryitem.go b/pkg/text/dictionaryitem.go new file mode 100644 index 000000000..b79620a39 --- /dev/null +++ b/pkg/text/dictionaryitem.go @@ -0,0 +1,37 @@ +package text + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/go-fastly/v2/fastly" + "github.com/segmentio/textio" +) + +// PrintDictionaryItem pretty prints a fastly.DictionaryInfo structure in verbose +// format to a given io.Writer. Consumers can provide a prefix string which +// will be used as a prefix to each line, useful for indentation. +func PrintDictionaryItem(out io.Writer, prefix string, d *fastly.DictionaryItem) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "Dictionary ID: %s\n", d.DictionaryID) + fmt.Fprintf(out, "Item Key: %s\n", d.ItemKey) + fmt.Fprintf(out, "Item Value: %s\n", d.ItemValue) + if d.CreatedAt != nil { + fmt.Fprintf(out, "Created (UTC): %s\n", d.CreatedAt.UTC().Format(common.TimeFormat)) + } + if d.UpdatedAt != nil { + fmt.Fprintf(out, "Last edited (UTC): %s\n", d.UpdatedAt.UTC().Format(common.TimeFormat)) + } + if d.DeletedAt != nil { + fmt.Fprintf(out, "Deleted (UTC): %s\n", d.DeletedAt.UTC().Format(common.TimeFormat)) + } +} + +// PrintDictionaryItemKV pretty prints only the key/value pairs from a dictionary item. +func PrintDictionaryItemKV(out io.Writer, prefix string, d *fastly.DictionaryItem) { + out = textio.NewPrefixWriter(out, prefix) + fmt.Fprintf(out, "Item Key: %s\n", d.ItemKey) + fmt.Fprintf(out, "Item Value: %s\n", d.ItemValue) +} diff --git a/pkg/text/dictionaryitem_test.go b/pkg/text/dictionaryitem_test.go new file mode 100644 index 000000000..ed99ca0d9 --- /dev/null +++ b/pkg/text/dictionaryitem_test.go @@ -0,0 +1,30 @@ +package text_test + +import ( + "bytes" + "testing" + + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v2/fastly" +) + +func TestPrintDictionaryItem(t *testing.T) { + for _, testcase := range []struct { + name string + dictionaryItem *fastly.DictionaryItem + wantOutput string + }{ + { + name: "base", + dictionaryItem: &fastly.DictionaryItem{}, + wantOutput: "Dictionary ID: \nItem Key: \nItem Value: \n", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var buf bytes.Buffer + text.PrintDictionaryItem(&buf, "", testcase.dictionaryItem) + testutil.AssertString(t, testcase.wantOutput, buf.String()) + }) + } +} diff --git a/pkg/text/healthcheck.go b/pkg/text/healthcheck.go index 35fda86e9..a59f69e43 100644 --- a/pkg/text/healthcheck.go +++ b/pkg/text/healthcheck.go @@ -9,7 +9,7 @@ import ( ) // PrintHealthCheck pretty prints a fastly.HealthCheck structure in verbose -// format to a given io.Writer. Consumers can provider an prefix string which +// format to a given io.Writer. Consumers can provide a prefix string which // will be used as a prefix to each line, useful for indentation. func PrintHealthCheck(out io.Writer, prefix string, h *fastly.HealthCheck) { out = textio.NewPrefixWriter(out, prefix) diff --git a/pkg/text/service.go b/pkg/text/service.go index 91763d044..b2ffc33c8 100644 --- a/pkg/text/service.go +++ b/pkg/text/service.go @@ -22,7 +22,7 @@ func ServiceType(t string) string { } // PrintService pretty prints a fastly.Service structure in verbose format -// to a given io.Writer. Consumers can provider an prefix string which will +// to a given io.Writer. Consumers can provide a prefix string which will // be used as a prefix to each line, useful for indentation. func PrintService(out io.Writer, prefix string, s *fastly.Service) { out = textio.NewPrefixWriter(out, prefix) @@ -52,7 +52,7 @@ func PrintService(out io.Writer, prefix string, s *fastly.Service) { } // PrintServiceDetail pretty prints a fastly.ServiceDetail structure in verbose -// format to a given io.Writer. Consumers can provider an prefix string which +// format to a given io.Writer. Consumers can provide a prefix string which // will be used as a prefix to each line, useful for indentation. func PrintServiceDetail(out io.Writer, indent string, s *fastly.ServiceDetail) { out = textio.NewPrefixWriter(out, indent) @@ -95,7 +95,7 @@ func PrintServiceDetail(out io.Writer, indent string, s *fastly.ServiceDetail) { } // PrintVersion pretty prints a fastly.Version structure in verbose format to a -// given io.Writer. Consumers can provider an prefix string which will be used +// given io.Writer. Consumers can provide a prefix string which will be used // as a prefix to each line, useful for indentation. func PrintVersion(out io.Writer, indent string, v *fastly.Version) { out = textio.NewPrefixWriter(out, indent) From 536378344de773690c56408d2169b1e8b488433d Mon Sep 17 00:00:00 2001 From: Mark McDonnell Date: Mon, 14 Dec 2020 10:20:19 +0000 Subject: [PATCH 2/2] v0.21.0 (#176) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e94697eee..4f9937404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [v0.21.0](https://github.com/fastly/cli/releases/tag/v0.21.0) (2020-12-14) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.20.0...v0.21.0) + +**Enhancements:** + +- Adds support for managing edge dictionaries [\#159](https://github.com/fastly/cli/pull/159) + ## [v0.20.0](https://github.com/fastly/cli/releases/tag/v0.20.0) (2020-11-24) [Full Changelog](https://github.com/fastly/cli/compare/v0.19.0...v0.20.0)