diff --git a/api/v3/filters/parse.go b/api/v3/filters/parse.go index 8b437da1f5..cbd6821b8a 100644 --- a/api/v3/filters/parse.go +++ b/api/v3/filters/parse.go @@ -161,12 +161,25 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error { fieldVal.Set(reflect.ValueOf(&parsed)) case stringPtrType: + if hasOperatorStyleKeys(qs, name) { + return fmt.Errorf("filter[%s]: operator-style keys are not supported for this field", name) + } if err := parseStringPtr(qs, name, fieldVal); err != nil { return err } default: - return fmt.Errorf("filter[%s]: unsupported filter field type %s", name, fieldVal.Type()) + // Handle *T where T is a named string-based type (e.g. *BillingCreditTransactionType). + if fieldVal.Kind() == reflect.Pointer && fieldVal.Type().Elem().Kind() == reflect.String { + if hasOperatorStyleKeys(qs, name) { + return fmt.Errorf("filter[%s]: operator-style keys are not supported for this field", name) + } + if err := parseStringPtrTyped(qs, name, fieldVal); err != nil { + return err + } + } else { + return fmt.Errorf("filter[%s]: unsupported filter field type %s", name, fieldVal.Type()) + } } } @@ -192,6 +205,27 @@ func parseStringPtr(qs url.Values, name string, fieldVal reflect.Value) error { return nil } +// parseStringPtrTyped handles filter[field]=value for *T fields where T is a named string type. +func parseStringPtrTyped(qs url.Values, name string, fieldVal reflect.Value) error { + prefix := "filter[" + name + "]" + for key, values := range qs { + if key != prefix { + continue + } + val, err := singleValue(key, values) + if err != nil { + return err + } + if val != "" { + ptr := reflect.New(fieldVal.Type().Elem()) + ptr.Elem().SetString(val) + fieldVal.Set(ptr) + } + break + } + return nil +} + // parseFilterString extracts a FilterString supporting all string operators. func parseFilterString(qs url.Values, field string) (FilterString, error) { var f FilterString @@ -502,6 +536,17 @@ func hasFilterKeys(qs url.Values) bool { return false } +// hasOperatorStyleKeys reports whether qs contains any filter[field][op] keys for the given field. +func hasOperatorStyleKeys(qs url.Values, name string) bool { + prefix := "filter[" + name + "][" + for key := range qs { + if strings.HasPrefix(key, prefix) { + return true + } + } + return false +} + // hasFieldKeys reports whether qs contains filter[field] or filter[field][op] keys. func hasFieldKeys(qs url.Values, field string) bool { prefix := "filter[" + field + "]" diff --git a/api/v3/filters/parse_test.go b/api/v3/filters/parse_test.go index dcb42b95e9..8cf491fe39 100644 --- a/api/v3/filters/parse_test.go +++ b/api/v3/filters/parse_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" ) +type testStringType string + // testFilter is a target exercising every filter type Parse recognizes. type testFilter struct { Field *FilterString `json:"field,omitempty"` @@ -24,6 +26,7 @@ type testFilter struct { CreatedAt *FilterDateTime `json:"created_at,omitempty"` Enabled *FilterBoolean `json:"enabled,omitempty"` Currency *string `json:"currency,omitempty"` + TxType *testStringType `json:"tx_type,omitempty"` } func TestParse_FilterString(t *testing.T) { @@ -301,6 +304,37 @@ func TestParse_StringPtr(t *testing.T) { }) } +func TestParse_NamedStringType(t *testing.T) { + t.Run("named string type filter value", func(t *testing.T) { + var f testFilter + require.NoError(t, Parse(url.Values{"filter[tx_type]": {"funded"}}, &f)) + require.NotNil(t, f.TxType) + assert.Equal(t, testStringType("funded"), *f.TxType) + }) + + t.Run("nil when no filter key present", func(t *testing.T) { + var f testFilter + require.NoError(t, Parse(url.Values{}, &f)) + assert.Nil(t, f.TxType) + }) + + t.Run("operator-style key rejected", func(t *testing.T) { + var f testFilter + err := Parse(url.Values{"filter[tx_type][eq]": {"funded"}}, &f) + require.Error(t, err) + assert.Contains(t, err.Error(), "operator-style keys are not supported") + }) +} + +func TestParse_StringPtrOperatorRejected(t *testing.T) { + t.Run("operator-style key rejected for *string field", func(t *testing.T) { + var f testFilter + err := Parse(url.Values{"filter[currency][eq]": {"USD"}}, &f) + require.Error(t, err) + assert.Contains(t, err.Error(), "operator-style keys are not supported") + }) +} + func TestParse_PointerToPointer(t *testing.T) { t.Run("allocates pointer when filter keys exist", func(t *testing.T) { var f *testFilter