55 "fmt"
66 "net/url"
77 "reflect"
8+ "sort"
89 "strconv"
910 "strings"
1011)
@@ -14,12 +15,12 @@ import (
1415// whose fields are filter types. Field names are resolved from json struct tags.
1516//
1617// Supported field types:
17- // - *FilterString: parsed via ParseFilterString
18- // - *FilterStringExact: parsed via ParseFilterStringExact
19- // - *StringFilter: parsed via ParseFilterStringSimple
20- // - *FilterNumeric: parsed via ParseFilterNumeric
21- // - *FilterDateTime: parsed via ParseFilterDateTime
22- // - *FilterBoolean: parsed via ParseFilterBoolean
18+ // - *FilterString: parsed via parseFilterString
19+ // - *FilterStringExact: parsed via parseFilterStringExact
20+ // - *StringFilter: parsed via parseFilterStringSimple
21+ // - *FilterNumeric: parsed via parseFilterNumeric
22+ // - *FilterDateTime: parsed via parseFilterDateTime
23+ // - *FilterBoolean: parsed via parseFilterBoolean
2324// - *string: parsed as shorthand eq (filter[field]=value)
2425// - any type implementing json.Unmarshaler: marshals filter ops to JSON, then unmarshals
2526func Parse (qs url.Values , target any ) error {
@@ -50,19 +51,82 @@ func hasFilterKeys(qs url.Values) bool {
5051 return false
5152}
5253
54+ // checkUnknownFilterKeys returns an error if any filter[<name>]... key in qs
55+ // refers to a <name> that is not in the knownFields set. Label-style
56+ // dot-notation keys (e.g. "filter[labels.env]") are matched against their
57+ // base segment before the first dot, since label map fields use that shape.
58+ // Unknown field names are reported as a deterministic, comma-separated list
59+ // so the error can be surfaced to API clients (e.g. as a 400 response).
60+ func checkUnknownFilterKeys (qs url.Values , knownFields map [string ]struct {}) error {
61+ var unknown []string
62+ seen := make (map [string ]struct {})
63+ for key := range qs {
64+ name , ok := filterFieldName (key )
65+ if ! ok {
66+ continue
67+ }
68+ // Allow dot-notation for labels-style map filters: the known field is
69+ // the base segment ("labels" in "labels.env").
70+ base := name
71+ if dot := strings .IndexByte (name , '.' ); dot > 0 {
72+ base = name [:dot ]
73+ }
74+ if _ , known := knownFields [base ]; known {
75+ continue
76+ }
77+ if _ , already := seen [name ]; already {
78+ continue
79+ }
80+ seen [name ] = struct {}{}
81+ unknown = append (unknown , name )
82+ }
83+ if len (unknown ) == 0 {
84+ return nil
85+ }
86+ sort .Strings (unknown )
87+ return fmt .Errorf ("unknown filter field(s): %s" , strings .Join (unknown , ", " ))
88+ }
89+
90+ // filterFieldName extracts the <name> portion from a URL query key shaped like
91+ // "filter[<name>]" or "filter[<name>][op]". It returns false for keys that do
92+ // not match the filter[...] prefix or that are malformed (e.g. "filter[" with
93+ // no closing bracket or an empty name).
94+ func filterFieldName (key string ) (string , bool ) {
95+ const prefix = "filter["
96+ if ! strings .HasPrefix (key , prefix ) {
97+ return "" , false
98+ }
99+ rest := key [len (prefix ):]
100+ end := strings .IndexByte (rest , ']' )
101+ if end <= 0 {
102+ return "" , false
103+ }
104+ return rest [:end ], true
105+ }
106+
53107var (
54- filterStringType = reflect .TypeOf (( * FilterString )( nil ) )
55- filterStringExactType = reflect .TypeOf (( * FilterStringExact )( nil ) )
56- filterNumericType = reflect .TypeOf (( * FilterNumeric )( nil ) )
57- filterDateTimeType = reflect .TypeOf (( * FilterDateTime )( nil ) )
58- filterBooleanType = reflect .TypeOf (( * FilterBoolean )( nil ) )
59- stringFilterType = reflect .TypeOf (( * StringFilter )( nil ) )
60- stringPtrType = reflect .TypeOf (( * string )( nil ) )
108+ filterStringType = reflect .TypeFor [ * FilterString ]( )
109+ filterStringExactType = reflect .TypeFor [ * FilterStringExact ]( )
110+ filterNumericType = reflect .TypeFor [ * FilterNumeric ]( )
111+ filterDateTimeType = reflect .TypeFor [ * FilterDateTime ]( )
112+ filterBooleanType = reflect .TypeFor [ * FilterBoolean ]( )
113+ stringFilterType = reflect .TypeFor [ * StringFilter ]( )
114+ stringPtrType = reflect .TypeFor [ * string ]( )
61115)
62116
63117func parseFiltersValue (qs url.Values , v reflect.Value ) error {
64118 t := v .Type ()
65119
120+ knownFields := make (map [string ]struct {}, t .NumField ())
121+ for i := range t .NumField () {
122+ if name := jsonFieldName (t .Field (i )); name != "" && name != "-" {
123+ knownFields [name ] = struct {}{}
124+ }
125+ }
126+ if err := checkUnknownFilterKeys (qs , knownFields ); err != nil {
127+ return err
128+ }
129+
66130 for i := range t .NumField () {
67131 field := t .Field (i )
68132 fieldVal := v .Field (i )
@@ -74,7 +138,7 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error {
74138
75139 switch fieldVal .Type () {
76140 case filterStringType :
77- parsed , err := ParseFilterString (qs , name )
141+ parsed , err := parseFilterString (qs , name )
78142 if err != nil {
79143 return err
80144 }
@@ -83,7 +147,7 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error {
83147 }
84148
85149 case filterStringExactType :
86- parsed , err := ParseFilterStringExact (qs , name )
150+ parsed , err := parseFilterStringExact (qs , name )
87151 if err != nil {
88152 return err
89153 }
@@ -92,7 +156,7 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error {
92156 }
93157
94158 case stringFilterType :
95- parsed , err := ParseFilterStringSimple (qs , name )
159+ parsed , err := parseFilterStringSimple (qs , name )
96160 if err != nil {
97161 return err
98162 }
@@ -101,7 +165,7 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error {
101165 }
102166
103167 case filterNumericType :
104- parsed , err := ParseFilterNumeric (qs , name )
168+ parsed , err := parseFilterNumeric (qs , name )
105169 if err != nil {
106170 return err
107171 }
@@ -110,7 +174,7 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error {
110174 }
111175
112176 case filterDateTimeType :
113- parsed , err := ParseFilterDateTime (qs , name )
177+ parsed , err := parseFilterDateTime (qs , name )
114178 if err != nil {
115179 return err
116180 }
@@ -119,7 +183,7 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error {
119183 }
120184
121185 case filterBooleanType :
122- parsed , err := ParseFilterBoolean (qs , name )
186+ parsed , err := parseFilterBoolean (qs , name )
123187 if err != nil {
124188 return err
125189 }
@@ -228,7 +292,7 @@ func jsonFieldName(f reflect.StructField) string {
228292 return name
229293}
230294
231- // ParseFilterString extracts a FilterString from URL query values for
295+ // parseFilterString extracts a FilterString from URL query values for
232296// the given field name. It understands the deepObject encoding used by our API:
233297//
234298// filter[field]=value → eq (shorthand)
@@ -243,7 +307,7 @@ func jsonFieldName(f reflect.StructField) string {
243307// filter[field][lte]=value → lte
244308// filter[field][exists] → exists (true)
245309// filter[field][nexists] → exists (false)
246- func ParseFilterString (qs url.Values , field string ) (FilterString , error ) {
310+ func parseFilterString (qs url.Values , field string ) (FilterString , error ) {
247311 var f FilterString
248312
249313 prefix := fmt .Sprintf ("filter[%s]" , field )
@@ -303,9 +367,9 @@ func ParseFilterString(qs url.Values, field string) (FilterString, error) {
303367 return f , nil
304368}
305369
306- // ParseFilterStringExact extracts a FilterStringExact from URL query values.
370+ // parseFilterStringExact extracts a FilterStringExact from URL query values.
307371// Supports eq, neq, and oeq operators.
308- func ParseFilterStringExact (qs url.Values , field string ) (FilterStringExact , error ) {
372+ func parseFilterStringExact (qs url.Values , field string ) (FilterStringExact , error ) {
309373 var f FilterStringExact
310374
311375 prefix := fmt .Sprintf ("filter[%s]" , field )
@@ -342,8 +406,8 @@ func ParseFilterStringExact(qs url.Values, field string) (FilterStringExact, err
342406 return f , nil
343407}
344408
345- // ParseFilterStringSimple extracts a StringFilter (simple eq/neq/contains) from URL query values.
346- func ParseFilterStringSimple (qs url.Values , field string ) (StringFilter , error ) {
409+ // parseFilterStringSimple extracts a StringFilter (simple eq/neq/contains) from URL query values.
410+ func parseFilterStringSimple (qs url.Values , field string ) (StringFilter , error ) {
347411 var f StringFilter
348412
349413 prefix := fmt .Sprintf ("filter[%s]" , field )
@@ -380,8 +444,8 @@ func ParseFilterStringSimple(qs url.Values, field string) (StringFilter, error)
380444 return f , nil
381445}
382446
383- // ParseFilterNumeric extracts a FilterNumeric from URL query values.
384- func ParseFilterNumeric (qs url.Values , field string ) (FilterNumeric , error ) {
447+ // parseFilterNumeric extracts a FilterNumeric from URL query values.
448+ func parseFilterNumeric (qs url.Values , field string ) (FilterNumeric , error ) {
385449 var f FilterNumeric
386450
387451 prefix := fmt .Sprintf ("filter[%s]" , field )
@@ -460,9 +524,9 @@ func ParseFilterNumeric(qs url.Values, field string) (FilterNumeric, error) {
460524 return f , nil
461525}
462526
463- // ParseFilterDateTime extracts a FilterDateTime from URL query values.
527+ // parseFilterDateTime extracts a FilterDateTime from URL query values.
464528// Values are kept as strings (expected to be RFC-3339 timestamps).
465- func ParseFilterDateTime (qs url.Values , field string ) (FilterDateTime , error ) {
529+ func parseFilterDateTime (qs url.Values , field string ) (FilterDateTime , error ) {
466530 var f FilterDateTime
467531
468532 prefix := fmt .Sprintf ("filter[%s]" , field )
@@ -507,8 +571,8 @@ func ParseFilterDateTime(qs url.Values, field string) (FilterDateTime, error) {
507571 return f , nil
508572}
509573
510- // ParseFilterBoolean extracts a FilterBoolean from URL query values.
511- func ParseFilterBoolean (qs url.Values , field string ) (FilterBoolean , error ) {
574+ // parseFilterBoolean extracts a FilterBoolean from URL query values.
575+ func parseFilterBoolean (qs url.Values , field string ) (FilterBoolean , error ) {
512576 var f FilterBoolean
513577
514578 prefix := fmt .Sprintf ("filter[%s]" , field )
0 commit comments