Skip to content

Commit

Permalink
Provide a ListOpts helper
Browse files Browse the repository at this point in the history
With `query.ListOpts`, it should be easier to list resources based on
repeated properties. For example: get information about multiple ports
by ID with a single call.

ListOpts is currently implemented for three Network resources:
* ports
* networks
* subnets
  • Loading branch information
pierreprinetti committed Aug 31, 2023
1 parent de873b9 commit 1fe62ca
Show file tree
Hide file tree
Showing 3 changed files with 245 additions and 0 deletions.
41 changes: 41 additions & 0 deletions query/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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)
}
108 changes: 108 additions & 0 deletions query/list.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions query/list_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
})
}
}

0 comments on commit 1fe62ca

Please sign in to comment.