diff --git a/query/errors.go b/query/errors.go new file mode 100644 index 00000000..d469d7bd --- /dev/null +++ b/query/errors.go @@ -0,0 +1,42 @@ +package query + +// As we can't use Go v1.20 in this module yet, this is an adaptation of +// `errors.Join`. +// https://cs.opensource.google/go/go/+/refs/tags/go1.21.0:src/errors/join.go + +func joinErrors(errs ...error) error { + n := 0 + for _, err := range errs { + if err != nil { + n++ + } + } + if n == 0 { + return nil + } + e := &joinError{ + errs: make([]error, 0, n), + } + for _, err := range errs { + if err != nil { + e.errs = append(e.errs, err) + } + } + return e +} + + +type joinError struct { + errs []error +} + +func (e *joinError) Error() string { + var b []byte + for i, err := range e.errs { + if i > 0 { + b = append(b, '\n') + } + b = append(b, err.Error()...) + } + return string(b) +} diff --git a/query/list.go b/query/list.go new file mode 100644 index 00000000..94fb6a82 --- /dev/null +++ b/query/list.go @@ -0,0 +1,108 @@ +package query + +import ( + "fmt" + "net/url" + "reflect" + + "github.com/gophercloud/gophercloud" +) + +func New(listOpts interface{}) *ListOpts { + availableFields := make(map[string]string) + { + t := reflect.TypeOf(listOpts) + for i := 0; i < t.NumField(); i++ { + if tag := t.Field(i).Tag.Get("q"); tag != "" { + availableFields[tag] = t.Field(i).Name + } + } + } + + queryString, err := gophercloud.BuildQueryString(listOpts) + + return &ListOpts{ + allowedFields: availableFields, + query: queryString.Query(), + errs: joinErrors(err), + } +} + +// ListOpts can be used to list multiple resources. +type ListOpts struct { + allowedFields map[string]string + query url.Values + errs error +} + +// And adds an arbitrary number of permutations of a single property to filter +// in. When a single ListOpts is called multiple times with the same property +// name, the resulting query contains the resulting intersection (AND). Note +// that how these properties are combined in OpenStack depend on the property. +// For example: passing multiple "id" behaves like an OR. Instead, passing +// multiple "tags" will only return resources that have ALL those tags. This +// helper function only combines the parameters in the most straightforward +// way; please refer to the OpenStack documented behaviour to know how these +// parameters are treated. +// +// ListOpts is currently implemented for three Network resources: +// +// * ports +// * networks +// * subnets +func (o *ListOpts) And(property string, values ...interface{}) *ListOpts { + if existingValues, ok := o.query[property]; ok { + // There already are values of the same property: we AND them + // with the new ones. We only keep the values that exist in + // both `o.query` AND in `values`. + + // First, to avoid nested loops, we build a hashmap with the + // new values. + newValuesSet := make(map[string]struct{}) + for _, newValue := range values { + newValuesSet[fmt.Sprint(newValue)] = struct{}{} + } + + // intersectedValues is a slice which will contain the values + // that we want to keep. They will be at most as many as what + // we already have; that's what we set the slice capacity to. + intersectedValues := make([]string, 0, len(existingValues)) + + // We add each existing value to intersectedValues if and only + // if it's also present in the new set. + for _, existingValue := range existingValues { + if _, ok := newValuesSet[existingValue]; ok { + intersectedValues = append(intersectedValues, existingValue) + } + } + o.query[property] = intersectedValues + return o + } + + if _, ok := o.allowedFields[property]; !ok { + o.errs = joinErrors(o.errs, fmt.Errorf("invalid property for the filter: %q", property)) + return o + } + + for _, v := range values { + o.query.Add(property, fmt.Sprint(v)) + } + + return o +} + +func (o ListOpts) String() string { + return "?" + o.query.Encode() +} + +func (o ListOpts) ToPortListQuery() (string, error) { + return o.String(), o.errs +} + +func (o ListOpts) ToNetworkListQuery() (string, error) { + return o.String(), o.errs +} + +func (o ListOpts) ToSubnetListQuery() (string, error) { + return o.String(), o.errs +} diff --git a/query/list_test.go b/query/list_test.go new file mode 100644 index 00000000..fcc170e9 --- /dev/null +++ b/query/list_test.go @@ -0,0 +1,96 @@ +package query_test + +import ( + "fmt" + "testing" + + "github.com/gophercloud/gophercloud/openstack/networking/v2/networks" + "github.com/gophercloud/gophercloud/openstack/networking/v2/ports" + "github.com/gophercloud/gophercloud/openstack/networking/v2/subnets" + "github.com/gophercloud/utils/query" +) + +var _ networks.ListOptsBuilder = (*query.ListOpts)(nil) +var _ ports.ListOptsBuilder = (*query.ListOpts)(nil) +var _ subnets.ListOptsBuilder = (*query.ListOpts)(nil) + +func ExampleListOpts_And_by_id() { + q := query.New(ports.ListOpts{ + Name: "Jules", + }).And("id", "123", "321", "12345") + fmt.Println(q) + //Output: ?id=123&id=321&id=12345&name=Jules +} + +func ExampleListOpts_And_by_name() { + q := query.New(ports.ListOpts{}). + And("name", "port-1", "port-&321", "the-other-port") + fmt.Println(q) + //Output: ?name=port-1&name=port-%26321&name=the-other-port +} + +func ExampleListOpts_And_by_Name_and_tag() { + q := query.New(ports.ListOpts{}). + And("name", "port-1", "port-3"). + And("tags", "my-tag") + fmt.Println(q) + //Output: ?name=port-1&name=port-3&tags=my-tag +} + +func ExampleListOpts_And_by_id_twice() { + q := query.New(ports.ListOpts{}). + And("id", "1", "2", "3"). + And("id", "2", "3", "4") + fmt.Println(q) + //Output: ?id=2&id=3 +} + +func ExampleListOpts_And_by_id_twice_plus_ListOpts() { + q := query.New(ports.ListOpts{ID: "3"}). + And("id", "1", "2", "3"). + And("id", "3", "4", "5") + fmt.Println(q) + //Output: ?id=3 +} + +func TestToPortListQuery(t *testing.T) { + for _, tc := range [...]struct { + name string + base interface{} + andProperty string + andItems []interface{} + expected string + expectedError bool + }{ + { + "valid", + ports.ListOpts{}, + "name", + []interface{}{"port-1"}, + "?name=port-1", + false, + }, + { + "invalid_field", + ports.ListOpts{}, + "door", + []interface{}{"pod bay"}, + "?", + true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + q, err := query.New(tc.base).And(tc.andProperty, tc.andItems...).ToPortListQuery() + if q != tc.expected { + t.Errorf("expected query %q, got %q", tc.expected, q) + } + if (err != nil) != tc.expectedError { + if err != nil { + t.Errorf("unexpected error: %v", err) + } else { + t.Errorf("expected error, got nil") + } + } + }) + } +}