Skip to content

Commit 7728920

Browse files
committed
feat: customer list filters
1 parent c3995ea commit 7728920

13 files changed

Lines changed: 2538 additions & 1113 deletions

File tree

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ down: ## Stop the dependencies via docker compose
2222
.PHONY: patch-oapi-templates
2323
patch-oapi-templates: ## Patch oapi-codegen chi-middleware template with custom filter parsing
2424
$(call print-target)
25+
@go mod download github.com/oapi-codegen/oapi-codegen/v2
2526
@OAPI_MOD_DIR=$$(go list -m -f '{{.Dir}}' github.com/oapi-codegen/oapi-codegen/v2) && \
27+
if [ -z "$$OAPI_MOD_DIR" ]; then echo "error: could not locate oapi-codegen/v2 module dir"; exit 1; fi && \
2628
cp "$$OAPI_MOD_DIR/pkg/codegen/templates/chi/chi-middleware.tmpl" api/v3/templates/chi-middleware.tmpl && \
2729
chmod u+w api/v3/templates/chi-middleware.tmpl && \
2830
patch -p1 -d api/v3/templates < api/v3/templates/chi-middleware.tmpl.patch

api/spec/packages/aip/src/customers/operations.tsp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ model ListCustomersParamsFilter {
2323
* Filter customers by key.
2424
*/
2525
key?: Common.StringFieldFilter;
26+
27+
/**
28+
* Filter customers by name.
29+
*/
30+
name?: Common.StringFieldFilter;
31+
32+
/**
33+
* Filter customers by primary email.
34+
*/
35+
primary_email?: Common.StringFieldFilter;
2636
}
2737

2838
interface CustomersOperations {

api/v3/api.gen.go

Lines changed: 563 additions & 556 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v3/filters/parse.go

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
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
2526
func 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+
53107
var (
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

63117
func 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

Comments
 (0)