From a8b95e991b0f4ca6e3d8eaadac64e759e84def44 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Mon, 26 Jun 2023 12:02:30 +0200 Subject: [PATCH] feat(tags): implement tags package (#62) The tags package allows to; - add tags - remove tags - remove unknown tags - list tags and has the accompanying CLI commands --- cmd/addtags.go | 103 +++++++++++ cmd/listtags.go | 102 +++++++++++ cmd/removetags.go | 130 ++++++++++++++ deckformat/entitylocations.go | 103 +++++++++++ tags/tags.go | 310 ++++++++++++++++++++++++++++++++++ tags/tags_suite_test.go | 13 ++ tags/tags_test.go | 290 +++++++++++++++++++++++++++++++ 7 files changed, 1051 insertions(+) create mode 100644 cmd/addtags.go create mode 100644 cmd/listtags.go create mode 100644 cmd/removetags.go create mode 100644 deckformat/entitylocations.go create mode 100644 tags/tags.go create mode 100644 tags/tags_suite_test.go create mode 100644 tags/tags_test.go diff --git a/cmd/addtags.go b/cmd/addtags.go new file mode 100644 index 0000000..bc5fcdf --- /dev/null +++ b/cmd/addtags.go @@ -0,0 +1,103 @@ +/* +Copyright © 2023 NAME HERE +*/ +package cmd + +import ( + "fmt" + "log" + "strings" + + "github.com/kong/go-apiops/deckformat" + "github.com/kong/go-apiops/filebasics" + "github.com/kong/go-apiops/logbasics" + "github.com/kong/go-apiops/tags" + "github.com/spf13/cobra" +) + +// Executes the CLI command "add-tags" +func executeAddTags(cmd *cobra.Command, tagsToAdd []string) error { + verbosity, _ := cmd.Flags().GetInt("verbose") + logbasics.Initialize(log.LstdFlags, verbosity) + + inputFilename, err := cmd.Flags().GetString("state") + if err != nil { + return fmt.Errorf("failed getting cli argument 'state'; %w", err) + } + + outputFilename, err := cmd.Flags().GetString("output-file") + if err != nil { + return fmt.Errorf("failed getting cli argument 'output-file'; %w", err) + } + + var outputFormat string + { + outputFormat, err = cmd.Flags().GetString("format") + if err != nil { + return fmt.Errorf("failed getting cli argument 'format'; %w", err) + } + outputFormat = strings.ToUpper(outputFormat) + } + + var selectors []string + { + selectors, err = cmd.Flags().GetStringArray("selector") + if err != nil { + return fmt.Errorf("failed getting cli argument 'selector'; %w", err) + } + } + + // do the work: read/add-tags/write + data, err := filebasics.DeserializeFile(inputFilename) + if err != nil { + return fmt.Errorf("failed to read input file '%s'; %w", inputFilename, err) + } + + tagger := tags.Tagger{} + tagger.SetData(data) + err = tagger.SetSelectors(selectors) + if err != nil { + return fmt.Errorf("failed to set selectors; %w", err) + } + err = tagger.AddTags(tagsToAdd) + if err != nil { + return fmt.Errorf("failed to add tags; %w", err) + } + data = tagger.GetData() + + trackInfo := deckformat.HistoryNewEntry("add-tags") + trackInfo["input"] = inputFilename + trackInfo["output"] = outputFilename + trackInfo["tags"] = tagsToAdd + trackInfo["selectors"] = selectors + deckformat.HistoryAppend(data, trackInfo) + + return filebasics.WriteSerializedFile(outputFilename, data, outputFormat) +} + +// +// +// Define the CLI data for the add-tags command +// +// + +var addTagsCmd = &cobra.Command{ + Use: "add-tags [flags] tag [...tag]", + Short: "Adds tags to objects in a decK file", + Long: `Adds tags to objects in a decK file. + +The tags are added to all objects that match the selector expressions. If no +selectors are given, all Kong entities are tagged.`, + RunE: executeAddTags, + Args: cobra.MinimumNArgs(1), +} + +func init() { + rootCmd.AddCommand(addTagsCmd) + addTagsCmd.Flags().StringP("state", "s", "-", "decK file to process. Use - to read from stdin") + addTagsCmd.Flags().StringArray("selector", []string{}, "JSON path expression to select "+ + "objects to add tags to,\ndefaults to all Kong entities (repeat for multiple selectors)") + addTagsCmd.Flags().StringP("output-file", "o", "-", "output file to write. Use - to write to stdout") + addTagsCmd.Flags().StringP("format", "", filebasics.OutputFormatYaml, "output format: "+ + filebasics.OutputFormatJSON+" or "+filebasics.OutputFormatYaml) +} diff --git a/cmd/listtags.go b/cmd/listtags.go new file mode 100644 index 0000000..6d1e079 --- /dev/null +++ b/cmd/listtags.go @@ -0,0 +1,102 @@ +/* +Copyright © 2023 NAME HERE +*/ +package cmd + +import ( + "fmt" + "log" + "strings" + + "github.com/kong/go-apiops/filebasics" + "github.com/kong/go-apiops/logbasics" + "github.com/kong/go-apiops/tags" + "github.com/spf13/cobra" +) + +// Executes the CLI command "list-tags" +func executeListTags(cmd *cobra.Command, _ []string) error { + verbosity, _ := cmd.Flags().GetInt("verbose") + logbasics.Initialize(log.LstdFlags, verbosity) + + inputFilename, err := cmd.Flags().GetString("state") + if err != nil { + return fmt.Errorf("failed getting cli argument 'state'; %w", err) + } + + outputFilename, err := cmd.Flags().GetString("output-file") + if err != nil { + return fmt.Errorf("failed getting cli argument 'output-file'; %w", err) + } + + var outputFormat string + { + outputFormat, err = cmd.Flags().GetString("format") + if err != nil { + return fmt.Errorf("failed getting cli argument 'format'; %w", err) + } + outputFormat = strings.ToUpper(outputFormat) + } + + var selectors []string + { + selectors, err = cmd.Flags().GetStringArray("selector") + if err != nil { + return fmt.Errorf("failed getting cli argument 'selector'; %w", err) + } + } + + // do the work: read/list-tags/write + data, err := filebasics.DeserializeFile(inputFilename) + if err != nil { + return fmt.Errorf("failed to read input file '%s'; %w", inputFilename, err) + } + + tagger := tags.Tagger{} + tagger.SetData(data) + err = tagger.SetSelectors(selectors) + if err != nil { + return fmt.Errorf("failed to set selectors; %w", err) + } + list, err := tagger.ListTags() + if err != nil { + return fmt.Errorf("failed to list tags; %w", err) + } + + if outputFormat == "PLAIN" { + // return as a plain text format, unix style; line separated + result := []byte(strings.Join(list, "\n")) + return filebasics.WriteFile(outputFilename, &result) + } + // return as yaml/json, create an object containing only a tags-array + result := make(map[string]interface{}) + result["tags"] = list + return filebasics.WriteSerializedFile(outputFilename, result, outputFormat) +} + +// +// +// Define the CLI data for the list-tags command +// +// + +var ListTagsCmd = &cobra.Command{ + Use: "list-tags [flags]", + Short: "Lists current tags to objects in a decK file", + Long: `Lists current tags to objects in a decK file. + +The tags will be collected from all objects that match the selector expressions. If no +selectors are given, all Kong entities will be scanned.`, + RunE: executeListTags, + Args: cobra.NoArgs, +} + +func init() { + rootCmd.AddCommand(ListTagsCmd) + ListTagsCmd.Flags().StringP("state", "s", "-", "decK file to process. Use - to read from stdin") + ListTagsCmd.Flags().StringArray("selector", []string{}, "JSON path expression to select "+ + "objects to scan for tags,\ndefaults to all Kong entities (repeat for multiple selectors)") + ListTagsCmd.Flags().StringP("output-file", "o", "-", "output file to write. Use - to write to stdout") + ListTagsCmd.Flags().StringP("format", "", "PLAIN", "output format: "+ + filebasics.OutputFormatJSON+", "+filebasics.OutputFormatYaml+", or PLAIN") +} diff --git a/cmd/removetags.go b/cmd/removetags.go new file mode 100644 index 0000000..4adce9b --- /dev/null +++ b/cmd/removetags.go @@ -0,0 +1,130 @@ +/* +Copyright © 2023 NAME HERE +*/ +package cmd + +import ( + "fmt" + "log" + "strings" + + "github.com/kong/go-apiops/deckformat" + "github.com/kong/go-apiops/filebasics" + "github.com/kong/go-apiops/logbasics" + "github.com/kong/go-apiops/tags" + "github.com/spf13/cobra" +) + +// Executes the CLI command "remove-tags" +func executeRemoveTags(cmd *cobra.Command, tagsToRemove []string) error { + verbosity, _ := cmd.Flags().GetInt("verbose") + logbasics.Initialize(log.LstdFlags, verbosity) + + inputFilename, err := cmd.Flags().GetString("state") + if err != nil { + return fmt.Errorf("failed getting cli argument 'state'; %w", err) + } + + outputFilename, err := cmd.Flags().GetString("output-file") + if err != nil { + return fmt.Errorf("failed getting cli argument 'output-file'; %w", err) + } + + var outputFormat string + { + outputFormat, err = cmd.Flags().GetString("format") + if err != nil { + return fmt.Errorf("failed getting cli argument 'format'; %w", err) + } + outputFormat = strings.ToUpper(outputFormat) + } + + var selectors []string + { + selectors, err = cmd.Flags().GetStringArray("selector") + if err != nil { + return fmt.Errorf("failed getting cli argument 'selector'; %w", err) + } + } + + var keepEmptyArrays bool + { + keepEmptyArrays, err = cmd.Flags().GetBool("keep-empty") + if err != nil { + return fmt.Errorf("failed getting cli argument 'keep-array'; %w", err) + } + } + + var keepOnlyTags bool + { + keepOnlyTags, err = cmd.Flags().GetBool("keep-only") + if err != nil { + return fmt.Errorf("failed getting cli argument 'keep-only'; %w", err) + } + } + + if !keepOnlyTags && len(tagsToRemove) == 0 { + return fmt.Errorf("no tags to remove") + } + + // do the work: read/remove-tags/write + data, err := filebasics.DeserializeFile(inputFilename) + if err != nil { + return fmt.Errorf("failed to read input file '%s'; %w", inputFilename, err) + } + + tagger := tags.Tagger{} + tagger.SetData(data) + err = tagger.SetSelectors(selectors) + if err != nil { + return fmt.Errorf("failed to set selectors; %w", err) + } + if keepOnlyTags { + err = tagger.RemoveUnknownTags(tagsToRemove, !keepEmptyArrays) + } else { + err = tagger.RemoveTags(tagsToRemove, !keepEmptyArrays) + } + if err != nil { + return fmt.Errorf("failed to remove tags; %w", err) + } + data = tagger.GetData() + + trackInfo := deckformat.HistoryNewEntry("remove-tags") + trackInfo["input"] = inputFilename + trackInfo["output"] = outputFilename + trackInfo["tags"] = tagsToRemove + trackInfo["keep-empty"] = keepEmptyArrays + trackInfo["selectors"] = selectors + deckformat.HistoryAppend(data, trackInfo) + + return filebasics.WriteSerializedFile(outputFilename, data, outputFormat) +} + +// +// +// Define the CLI data for the remove-tags command +// +// + +var RemoveTagsCmd = &cobra.Command{ + Use: "remove-tags [flags] tag [...tag]", + Short: "Removes tags from objects in a decK file", + Long: `Removes tags from objects in a decK file. + +The listed tags are removed from all objects that match the selector expressions. +If no selectors are given, all Kong entities will be selected.`, + RunE: executeRemoveTags, +} + +func init() { + rootCmd.AddCommand(RemoveTagsCmd) + RemoveTagsCmd.Flags().Bool("keep-empty", false, "keep empty tag-arrays in output") + RemoveTagsCmd.Flags().Bool("keep-only", false, "setting this flag will remove all tags except the ones listed\n"+ + "(if none are listed, all tags will be removed)") + RemoveTagsCmd.Flags().StringP("state", "s", "-", "decK file to process. Use - to read from stdin") + RemoveTagsCmd.Flags().StringArray("selector", []string{}, "JSON path expression to select "+ + "objects to remove tags from,\ndefaults to all Kong entities (repeat for multiple selectors)") + RemoveTagsCmd.Flags().StringP("output-file", "o", "-", "output file to write. Use - to write to stdout") + RemoveTagsCmd.Flags().StringP("format", "", filebasics.OutputFormatYaml, "output format: "+ + filebasics.OutputFormatJSON+" or "+filebasics.OutputFormatYaml) +} diff --git a/deckformat/entitylocations.go b/deckformat/entitylocations.go new file mode 100644 index 0000000..c95355c --- /dev/null +++ b/deckformat/entitylocations.go @@ -0,0 +1,103 @@ +package deckformat + +// EntityPointers is a map of entity names to an array of JSONpointers that can be used to find +// the all of those entities in a deck file. For example; credentials typically can be under +// their own top-level key, or nested under a consumer. +var EntityPointers = map[string][]string{ + // list created from the deck source code, looking at: deck/types/*.go + "acls": { + "$.acls[*]", + }, + "basicauth_credentials": { + "$.basicauth_credentials[*]", + "$.consumers[*].basicauth_credentials[*]", + }, + "ca_certificates": { + "$.ca_certificates[*]", + }, + "certificates": { + "$.certificates[*]", + }, + "consumer_group_consumers": { + "$.consumer_group_consumers[*]", + }, + "consumer_group_plugins": { + "$.consumer_group_plugins[*]", + "$.consumer_groups[*].consumer_group_plugins[*]", + }, + "consumer_groups": { + "$.consumer_groups[*]", + }, + "consumers": { + "$.consumers[*]", + }, + "document_objects": { + "$.document_objects[*]", + "$.services[*].document_objects[*]", + }, + "hmacauth_credentials": { + "$.hmacauth_credentials[*]", + "$.consumers[*].hmacauth_credentials[*]", + }, + "jwt_secrets": { + "$.jwt_secrets[*]", + "$.consumers[*].jwt_secrets[*]", + }, + "keyauth_credentials": { + "$.keyauth_credentials[*]", + "$.consumers[*].keyauth_credentials[*]", + }, + "mtls_auth_credentials": { + "$.mtls_auth_credentials[*]", + "$.consumers[*].mtls_auth_credentials[*]", + "$.ca_certificates[*].mtls_auth_credentials[*]", + }, + "oauth2_credentials": { + "$.oauth2_credentials[*]", + "$.consumers[*].oauth2_credentials[*]", + }, + "plugins": { + "$.plugins[*]", + "$.routes[*].plugins[*]", + "$.services[*].plugins[*]", + "$.services[*].routes[*].plugins[*]", + "$.consumers[*].plugins[*]", + "$.consumer_group_plugins[*]", // the dbless format + "$.consumer_groups[*].consumer_group_plugins[*]", // the dbless format + "$.consumer_groups[*].plugins[*]", // the deck format + }, + "rbac_role_endpoints": { + "$.rbac_role_endpoints[*]", + "$.rbac_roles[*].rbac_role_endpoints[*]", + }, + "rbac_role_entities": { + "$.rbac_role_entities[*]", + "$.rbac_roles[*].rbac_role_entities[*]", + }, + "rbac_roles": { + "$.rbac_roles[*]", + }, + "routes": { + "$.routes[*]", + "$.services[*].routes[*]", + }, + "services": { + "$.services[*]", + }, + "snis": { + "$.snis[*]", + "$.certificates[*].snis[*]", + }, + "targets": { + "$.targets[*]", + "$.upstreams[*].targets[*]", + "$.certificates[*].upstreams[*].targets[*]", + }, + "upstreams": { + "$.upstreams[*]", + "$.certificates[*].upstreams[*]", + }, + "vaults": { + "$.vaults[*]", + }, +} diff --git a/tags/tags.go b/tags/tags.go new file mode 100644 index 0000000..062dc1b --- /dev/null +++ b/tags/tags.go @@ -0,0 +1,310 @@ +package tags + +import ( + "fmt" + "sort" + + "github.com/kong/go-apiops/deckformat" + "github.com/kong/go-apiops/jsonbasics" + "github.com/kong/go-apiops/logbasics" + "github.com/vmware-labs/yaml-jsonpath/pkg/yamlpath" + "gopkg.in/yaml.v3" +) + +const tagArrayName = "tags" + +// defaultSelectors is the list of JSONpointers to entities that can hold tags +var defaultSelectors []string + +func init() { + defaultSelectors = make([]string, 0) + for _, selectors := range deckformat.EntityPointers { + defaultSelectors = append(defaultSelectors, selectors...) + } +} + +type Tagger struct { + // list of JSONpointers to entities that can hold tags, so the selector + // returns entities that can hold tags, not the tag arrays themselves + selectors []*yamlpath.Path + // list of Nodes (selected by the selectors) representing entities that can + // hold tags, not the tag arrays themselves + tagOwners []*yaml.Node + // The document to operate on + data *yaml.Node +} + +// SetData sets the Yaml document to operate on. Cannot be set to nil (panic). +func (ts *Tagger) SetData(data map[string]interface{}) { + if data == nil { + panic("data cannot be nil") + } + ts.data = jsonbasics.ConvertToYamlNode(data) + ts.tagOwners = nil // clear previous JSONpointer search results +} + +// GetData returns the (modified) document. +func (ts *Tagger) GetData() map[string]interface{} { + d := jsonbasics.ConvertToJSONInterface(ts.data) + return (*d).(map[string]interface{}) +} + +// SetSelectors sets the selectors to use. If empty (or nil), the default selectors +// are set. +func (ts *Tagger) SetSelectors(selectors []string) error { + if len(selectors) == 0 { + logbasics.Debug("no selectors provided, using defaults") + selectors = defaultSelectors + } + + compiledSelectors := make([]*yamlpath.Path, len(selectors)) + for i, selector := range selectors { + logbasics.Debug("compiling JSONpath", "path", selector) + compiledpath, err := yamlpath.NewPath(selector) + if err != nil { + return fmt.Errorf("selector '%s' is not a valid JSONpath expression; %w", selector, err) + } + compiledSelectors[i] = compiledpath + } + // we're good, they are all valid + ts.selectors = compiledSelectors + ts.tagOwners = nil // clear previous JSONpointer search results + logbasics.Debug("successfully compiled JSONpaths") + return nil +} + +// Search searches the document using the selectors, and stores the results +// internally. Only results that are JSONobjects are stored. +// The search is performed only once, and the results are cached. +// Will panic if data (the document to search) has not been set yet. +func (ts *Tagger) search() error { + if ts.tagOwners != nil { // already searched + return nil + } + + if ts.data == nil { + panic("data hasn't been set, see SetData()") + } + + if ts.selectors == nil { + err := ts.SetSelectors(nil) // set to 'nil' to set the default selectors + if err != nil { + panic("this should never happen, since we're setting the default selectors") + } + } + + // build list of targets by executing the selectors one by one + targets := make([]*yaml.Node, 0) + for idx, selector := range ts.selectors { + nodes, err := selector.Find(ts.data) + if err != nil { + return err + } + + // 'nodes' is an array of nodes matching the selector + objCount := 0 + for _, node := range nodes { + // since we're updating object fields, we'll skip anything that is + // not a JSONobject + if node.Kind == yaml.MappingNode { + targets = append(targets, node) + objCount++ + } + } + logbasics.Debug("selector results", "selector", idx, "results", len(nodes), "objects", objCount) + } + ts.tagOwners = targets + return nil +} + +// RemoveTags removes the listed tags from any entity selected. Empty tag arrays are +// removed if 'removeEmptyTagArrays' is true. The order of the remaining tags is +// preserved. +func (ts *Tagger) RemoveTags(tags []string, removeEmptyTagArrays bool) error { + if len(tags) == 0 { + if !removeEmptyTagArrays { + logbasics.Debug("no tags to remove", "tags", tags) + return nil + } + logbasics.Debug("no tags to remove, removing empty tag-arrays") + tags = []string{} + } + + reverseTags := make(map[string]bool) + for _, tag := range tags { + reverseTags[tag] = true + } + + if err := ts.search(); err != nil { + return err + } + + for _, node := range ts.tagOwners { + // 'node' is a JSONobject that can hold tags, type is yaml.MappingNode + // loop over the object to find the tags array + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + if keyNode.Value == tagArrayName && valueNode.Kind == yaml.SequenceNode { + // we found the tags array on this object + + // loop over this tags array to find the tags to remove + for j := 0; j < len(valueNode.Content); j++ { + tagNode := valueNode.Content[j] + if tagNode.Kind == yaml.ScalarNode { + tag := tagNode.Value + + // is this tag in the list of tags to remove? + if reverseTags[tag] { + valueNode.Content = append(valueNode.Content[:j], valueNode.Content[j+1:]...) + j-- // we're removing a node, so we need to go back one node + } + } + } + + // if the tags array is empty remove it + if removeEmptyTagArrays && len(valueNode.Content) == 0 { + node.Content = append(node.Content[:i], node.Content[i+2:]...) + i -= 2 // we're removing two nodes, so we need to go back two nodes + } + } + } + } + + return nil +} + +// AddTags adds the listed tags to any entity selected. The tags are added in the +// order they are listed. +func (ts *Tagger) AddTags(tags []string) error { + if len(tags) == 0 { + logbasics.Debug("no tags to add", "tags", tags) + return nil + } + + if err := ts.search(); err != nil { + return err + } + + for _, node := range ts.tagOwners { + // 'node' is a JSONobject that can hold tags, type is yaml.MappingNode + // loop over the object to find the tags array + + var ( + keyNode *yaml.Node + valueNode *yaml.Node + found bool + ) + for i := 0; i < len(node.Content); i += 2 { + keyNode = node.Content[i] + valueNode = node.Content[i+1] + if keyNode.Value == tagArrayName && valueNode.Kind == yaml.SequenceNode { + // we found the tags array on this object + found = true + break + } + } + + // if we didn't find the tags array, create it + if !found { + keyNode = &yaml.Node{ + Kind: yaml.ScalarNode, + Value: tagArrayName, + Style: yaml.DoubleQuotedStyle, + } + valueNode = &yaml.Node{ + Kind: yaml.SequenceNode, + Tag: "!!seq", + Value: "", + Style: yaml.DoubleQuotedStyle, + } + node.Content = append(node.Content, keyNode, valueNode) + } + + // loop over the tags to add + for _, tag := range tags { + // loop over this tags array to find the tags to add + found := false + for _, tagNode := range valueNode.Content { + if tagNode.Value == tag && tagNode.Kind == yaml.ScalarNode { + found = true + break + } + } + + // if the tag is not already in the array, add it + if !found { + valueNode.Content = append(valueNode.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: tag, + Style: yaml.DoubleQuotedStyle, + }) + } + } + } + return nil +} + +// ListTags returns a list of the tags in use in the data. The tags are sorted. +func (ts *Tagger) ListTags() ([]string, error) { + if err := ts.search(); err != nil { + return nil, err + } + + tags := make(map[string]bool) + for _, node := range ts.tagOwners { + // 'node' is a JSONobject that can hold tags, type is yaml.MappingNode + // loop over the object to find the tags array + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + if keyNode.Value == tagArrayName && valueNode.Kind == yaml.SequenceNode { + // we found the tags array on this object + + // loop over this tags array to find the tags + for _, tagNode := range valueNode.Content { + if tagNode.Kind == yaml.ScalarNode { + tags[tagNode.Value] = true + } + } + } + } + } + + tagList := make([]string, 0, len(tags)) + for tag := range tags { + tagList = append(tagList, tag) + } + sort.Strings(tagList) + return tagList, nil +} + +// RemoveUnknownTags removes all tags that are not in the list of known tags. +// If removeEmptyTagArrays is true, it will also remove any empty tags arrays. +func (ts *Tagger) RemoveUnknownTags(knownTags []string, removeEmptyTagArrays bool) error { + existingTags, err := ts.ListTags() + if err != nil { + return err + } + + tagsToRemove := make([]string, 0, len(existingTags)) + for _, tag := range existingTags { + found := false + for _, knownTag := range knownTags { + if tag == knownTag { + found = true + break + } + } + if !found { + tagsToRemove = append(tagsToRemove, tag) + } + } + if len(tagsToRemove) > 0 { + if err := ts.RemoveTags(tagsToRemove, removeEmptyTagArrays); err != nil { + return err + } + } + + return nil +} diff --git a/tags/tags_suite_test.go b/tags/tags_suite_test.go new file mode 100644 index 0000000..401f7ed --- /dev/null +++ b/tags/tags_suite_test.go @@ -0,0 +1,13 @@ +package tags_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFilebasics(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Tags Suite") +} diff --git a/tags/tags_test.go b/tags/tags_test.go new file mode 100644 index 0000000..4f0250a --- /dev/null +++ b/tags/tags_test.go @@ -0,0 +1,290 @@ +package tags_test + +import ( + "github.com/kong/go-apiops/filebasics" + "github.com/kong/go-apiops/tags" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// func taggerWithData(data string) tags.Tagger { +// GinkgoHelper() +// tagger := tags.Tagger{} +// d := []byte(data) +// tagger.SetData(filebasics.MustDeserialize(&d)) +// return tagger +// } + +var _ = Describe("tags", func() { + Describe("Tagger.SetData", func() { + It("panics if data is nil", func() { + Expect(func() { + tagger := tags.Tagger{} + tagger.SetData(nil) + }).Should(PanicWith("data cannot be nil")) + }) + + It("does not panic if data is not nil", func() { + Expect(func() { + tagger := tags.Tagger{} + tagger.SetData(map[string]interface{}{}) + }).ShouldNot(Panic()) + }) + }) + + Describe("Tagger.SetSelectors", func() { + It("allows nil, or 0 length", func() { + tagger := tags.Tagger{} + + err := tagger.SetSelectors(nil) + Expect(err).To(BeNil()) + + err = tagger.SetSelectors([]string{}) + Expect(err).To(BeNil()) + }) + + It("accepts a valid JSONpointer", func() { + tagger := tags.Tagger{} + err := tagger.SetSelectors([]string{"$..routes[*]", "$..services[*]"}) + Expect(err).To(BeNil()) + }) + + It("fails on a bad JSONpointer", func() { + tagger := tags.Tagger{} + err := tagger.SetSelectors([]string{"bad one"}) + Expect(err).To(MatchError("selector 'bad one' is not a valid JSONpath expression; " + + "invalid character ' ' at position 3, following \"bad\"")) + }) + }) + + Describe("Tagger.RemoveTags", func() { + It("removes tags, not changing order", func() { + dataInput := []byte(` + { "anykey": [ + { "name": "svc1", "tags": ["tag1", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag3"] }, + { "name": "svc3", "tags": ["tag3", "tag1"] } + ]}`) + + tagger := tags.Tagger{} + tagger.SetData(filebasics.MustDeserialize(&dataInput)) + tagger.SetSelectors([]string{ + "$..anykey[*]", + }) + tagger.RemoveTags([]string{ + "tag2", + }, true) + + result := *(filebasics.MustSerialize(tagger.GetData(), filebasics.OutputFormatJSON)) + Expect(result).To(MatchJSON(` + { "anykey": [ + { "name": "svc1", "tags": ["tag1"] }, + { "name": "svc2", "tags": ["tag3"] }, + { "name": "svc3", "tags": ["tag3", "tag1"] } + ]}`)) + }) + + It("uses default selectors if none specified", func() { + dataInput := []byte(` + { + "services": [ + { "name": "svc1", "tags": ["tag1", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag3"] }, + { "name": "svc3", "tags": ["tag3", "tag1"] } + ], + "anykey": [ + { "name": "svc1", "tags": ["tag1", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag3"] }, + { "name": "svc3", "tags": ["tag3", "tag1"] } + ] + }`) + + tagger := tags.Tagger{} + tagger.SetData(filebasics.MustDeserialize(&dataInput)) + tagger.RemoveTags([]string{ + "tag2", + }, true) + + result := *(filebasics.MustSerialize(tagger.GetData(), filebasics.OutputFormatJSON)) + Expect(result).To(MatchJSON(` + { + "services": [ + { "name": "svc1", "tags": ["tag1"] }, + { "name": "svc2", "tags": ["tag3"] }, + { "name": "svc3", "tags": ["tag3", "tag1"] } + ], + "anykey": [ + { "name": "svc1", "tags": ["tag1", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag3"] }, + { "name": "svc3", "tags": ["tag3", "tag1"] } + ] + }`)) + }) + + It("removes empty tag array if set to", func() { + dataInput := []byte(` + { "anykey": [ + { "name": "svc1", "tags": ["tag1", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag3"] }, + { "name": "svc3", "tags": ["tag3", "tag1"] } + ]}`) + + tagger := tags.Tagger{} + tagger.SetData(filebasics.MustDeserialize(&dataInput)) + tagger.SetSelectors([]string{ + "$..anykey[*]", + }) + tagger.RemoveTags([]string{ + "tag2", + "tag3", + }, true) + + result := *(filebasics.MustSerialize(tagger.GetData(), filebasics.OutputFormatJSON)) + Expect(result).To(MatchJSON(` + { "anykey": [ + { "name": "svc1", "tags": ["tag1"] }, + { "name": "svc2" }, + { "name": "svc3", "tags": ["tag1"] } + ]}`)) + }) + + It("keeps empty tag array if set to", func() { + dataInput := []byte(` + { "anykey": [ + { "name": "svc1", "tags": ["tag1", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag3"] }, + { "name": "svc3", "tags": ["tag3", "tag1"] } + ]}`) + + tagger := tags.Tagger{} + tagger.SetData(filebasics.MustDeserialize(&dataInput)) + tagger.SetSelectors([]string{ + "$..anykey[*]", + }) + tagger.RemoveTags([]string{ + "tag2", + "tag3", + }, false) + + result := *(filebasics.MustSerialize(tagger.GetData(), filebasics.OutputFormatJSON)) + Expect(result).To(MatchJSON(` + { "anykey": [ + { "name": "svc1", "tags": ["tag1"] }, + { "name": "svc2", "tags": [] }, + { "name": "svc3", "tags": ["tag1"] } + ]}`)) + }) + }) + + Describe("Tagger.AddTags", func() { + It("adds tags to existing tag arrays, in order provided", func() { + dataInput := []byte(` + { "anykey": [ + { "name": "svc1" }, + { "name": "svc2", "tags": ["tag1"] }, + { "name": "svc3", "tags": ["tag2", "tag3"] } + ]}`) + + tagger := tags.Tagger{} + tagger.SetData(filebasics.MustDeserialize(&dataInput)) + tagger.SetSelectors([]string{ + "$..anykey[*]", + }) + tagger.AddTags([]string{ + "tagX", + "tagY", + }) + + result := *(filebasics.MustSerialize(tagger.GetData(), filebasics.OutputFormatJSON)) + Expect(result).To(MatchJSON(` + { "anykey": [ + { "name": "svc1", "tags": ["tagX", "tagY"] }, + { "name": "svc2", "tags": ["tag1", "tagX", "tagY"] }, + { "name": "svc3", "tags": ["tag2", "tag3", "tagX", "tagY"] } + ]}`)) + }) + }) + + Describe("Tagger.ListTags", func() { + It("lists tags in sorted order, deduplicated", func() { + dataInput := []byte(` + { "anykey": [ + { "name": "svc1", "tags": ["tag3", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag1"] }, + { "name": "svc3", "tags": ["tag1", "tag3"] } + ]}`) + + tagger := tags.Tagger{} + tagger.SetData(filebasics.MustDeserialize(&dataInput)) + tagger.SetSelectors([]string{ + "$..anykey[*]", + }) + + result, err := tagger.ListTags() + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal([]string{ + "tag1", + "tag2", + "tag3", + })) + }) + }) + + Describe("Tagger.RemoveUnknownTags", func() { + It("removes unknown tags without impacting order", func() { + dataInput := []byte(` + { "anykey": [ + { "name": "svc1", "tags": ["tag3", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag1"] }, + { "name": "svc3", "tags": ["tag1", "tag3"] } + ]}`) + + tagger := tags.Tagger{} + tagger.SetData(filebasics.MustDeserialize(&dataInput)) + tagger.SetSelectors([]string{ + "$..anykey[*]", + }) + + err := tagger.RemoveUnknownTags([]string{ + "tag1", + }, false) + Expect(err).ToNot(HaveOccurred()) + + result := *(filebasics.MustSerialize(tagger.GetData(), filebasics.OutputFormatJSON)) + Expect(result).To(MatchJSON(` + { "anykey": [ + { "name": "svc1", "tags": [] }, + { "name": "svc2", "tags": ["tag1"] }, + { "name": "svc3", "tags": ["tag1"] } + ]}`)) + }) + + It("removes empty tags arrays if set to", func() { + dataInput := []byte(` + { "anykey": [ + { "name": "svc1", "tags": ["tag3", "tag2"] }, + { "name": "svc2", "tags": ["tag2", "tag1"] }, + { "name": "svc3", "tags": ["tag1", "tag3"] } + ]}`) + + tagger := tags.Tagger{} + tagger.SetData(filebasics.MustDeserialize(&dataInput)) + tagger.SetSelectors([]string{ + "$..anykey[*]", + }) + + err := tagger.RemoveUnknownTags([]string{ + "tag1", + }, true) + Expect(err).ToNot(HaveOccurred()) + + result := *(filebasics.MustSerialize(tagger.GetData(), filebasics.OutputFormatJSON)) + Expect(result).To(MatchJSON(` + { "anykey": [ + { "name": "svc1" }, + { "name": "svc2", "tags": ["tag1"] }, + { "name": "svc3", "tags": ["tag1"] } + ]}`)) + }) + }) +})