Skip to content

Commit a098615

Browse files
committed
feat(api): support more filter types in parser
1 parent 7cddb66 commit a098615

5 files changed

Lines changed: 124 additions & 1 deletion

File tree

api/spec/packages/aip/src/test.tsp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ model FieldFilters {
3030
datetime?: Common.DateTimeFieldFilter;
3131
#suppress "@openmeter/api-spec-aip/doc-decorator" "test model"
3232
labels?: Common.LabelsFieldFilter;
33+
#suppress "@openmeter/api-spec-aip/doc-decorator" "test model"
34+
timestamp?: Shared.DateTime;
3335
}
3436

3537
@route("/field-filters")

api/v3/filters/parse.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package filters
22

33
import (
4+
"encoding"
45
"errors"
56
"fmt"
67
"net/url"
@@ -85,6 +86,7 @@ var (
8586
filterLabelsType = reflect.TypeFor[FilterLabels]()
8687
filterLabelsPtrType = reflect.TypeFor[*FilterLabels]()
8788
stringPtrType = reflect.TypeFor[*string]()
89+
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
8890
)
8991

9092
// parseFiltersValue iterates struct fields and dispatches to per-type parsers.
@@ -186,8 +188,16 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error {
186188
}
187189

188190
default:
191+
// Handle *T where *T implements encoding.TextUnmarshaler (e.g. *time.Time).
192+
if fieldVal.Kind() == reflect.Pointer && fieldVal.Type().Implements(textUnmarshalerType) {
193+
if hasOperatorStyleKeys(qs, name) {
194+
return fmt.Errorf("filter[%s]: operator-style keys are not supported for this field", name)
195+
}
196+
if err := parseTextUnmarshalerPtr(qs, name, fieldVal); err != nil {
197+
return err
198+
}
189199
// Handle *T where T is a named string-based type (e.g. *BillingCreditTransactionType).
190-
if fieldVal.Kind() == reflect.Pointer && fieldVal.Type().Elem().Kind() == reflect.String {
200+
} else if fieldVal.Kind() == reflect.Pointer && fieldVal.Type().Elem().Kind() == reflect.String {
191201
if hasOperatorStyleKeys(qs, name) {
192202
return fmt.Errorf("filter[%s]: operator-style keys are not supported for this field", name)
193203
}
@@ -243,6 +253,29 @@ func parseStringPtrTyped(qs url.Values, name string, fieldVal reflect.Value) err
243253
return nil
244254
}
245255

256+
// parseTextUnmarshalerPtr handles filter[field]=value for *T fields where *T implements encoding.TextUnmarshaler.
257+
func parseTextUnmarshalerPtr(qs url.Values, name string, fieldVal reflect.Value) error {
258+
prefix := "filter[" + name + "]"
259+
for key, values := range qs {
260+
if key != prefix {
261+
continue
262+
}
263+
val, err := singleValue(key, values)
264+
if err != nil {
265+
return err
266+
}
267+
if val != "" {
268+
ptr := reflect.New(fieldVal.Type().Elem())
269+
if err := ptr.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(val)); err != nil {
270+
return fmt.Errorf("filter[%s]: invalid value %q: %w", name, val, err)
271+
}
272+
fieldVal.Set(ptr)
273+
}
274+
break
275+
}
276+
return nil
277+
}
278+
246279
// parseFilterString extracts a FilterString supporting all string operators.
247280
func parseFilterString(qs url.Values, field string) (FilterString, error) {
248281
var f FilterString

api/v3/filters/parse_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type testFilter struct {
2727
Enabled *FilterBoolean `json:"enabled,omitempty"`
2828
Currency *string `json:"currency,omitempty"`
2929
TxType *testStringType `json:"tx_type,omitempty"`
30+
Timestamp *time.Time `json:"timestamp,omitempty"`
3031
}
3132

3233
func TestParse_FilterString(t *testing.T) {
@@ -349,6 +350,37 @@ func TestParse_StringPtrOperatorRejected(t *testing.T) {
349350
})
350351
}
351352

353+
func TestParse_TimestampPtr(t *testing.T) {
354+
ts := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
355+
356+
t.Run("valid RFC-3339 value", func(t *testing.T) {
357+
var f testFilter
358+
require.NoError(t, Parse(url.Values{"filter[timestamp]": {"2024-01-02T03:04:05Z"}}, &f))
359+
require.NotNil(t, f.Timestamp)
360+
assert.Equal(t, ts, *f.Timestamp)
361+
})
362+
363+
t.Run("nil when no filter key present", func(t *testing.T) {
364+
var f testFilter
365+
require.NoError(t, Parse(url.Values{}, &f))
366+
assert.Nil(t, f.Timestamp)
367+
})
368+
369+
t.Run("invalid value is rejected", func(t *testing.T) {
370+
var f testFilter
371+
err := Parse(url.Values{"filter[timestamp]": {"not-a-time"}}, &f)
372+
require.Error(t, err)
373+
assert.Contains(t, err.Error(), "filter[timestamp]")
374+
})
375+
376+
t.Run("operator-style key rejected", func(t *testing.T) {
377+
var f testFilter
378+
err := Parse(url.Values{"filter[timestamp][eq]": {"2024-01-02T03:04:05Z"}}, &f)
379+
require.Error(t, err)
380+
assert.Contains(t, err.Error(), "operator-style keys are not supported")
381+
})
382+
}
383+
352384
func TestParse_PointerToPointer(t *testing.T) {
353385
t.Run("allocates pointer when filter keys exist", func(t *testing.T) {
354386
var f *testFilter

api/v3/test/filters_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type fieldFiltersTarget struct {
3030
ULID *filters.FilterULID `json:"ulid,omitempty"`
3131
DateTime *filters.FilterDateTime `json:"datetime,omitempty"`
3232
Labels *filters.FilterLabels `json:"labels,omitempty"`
33+
Timestamp *time.Time `json:"timestamp,omitempty"`
3334
}
3435

3536
// validatorErrorResponse mirrors the AIP-style error body produced by
@@ -229,6 +230,52 @@ func TestFieldFilterValidation(t *testing.T) {
229230
wantReasonSubstr: "must be an object",
230231
},
231232

233+
// Shared.DateTime scalar (plain timestamp field, not a filter wrapper).
234+
// The OAS validator enforces RFC-3339 date-time format (kin-openapi regex).
235+
// Valid: UTC (Z), positive/negative numeric offsets, sub-second precision, leap second.
236+
{name: "timestamp valid UTC Z", query: "filter[timestamp]=2024-01-01T00:00:00Z", wantStatus: http.StatusNoContent},
237+
// + must be percent-encoded in query strings; raw + decodes to space.
238+
{name: "timestamp valid positive offset", query: "filter[timestamp]=2024-06-15T12:30:00%2B05:30", wantStatus: http.StatusNoContent},
239+
{name: "timestamp valid negative offset", query: "filter[timestamp]=2024-06-15T12:30:00-07:00", wantStatus: http.StatusNoContent},
240+
{name: "timestamp valid sub-second precision", query: "filter[timestamp]=2024-01-02T03:04:05.999Z", wantStatus: http.StatusNoContent},
241+
{name: "timestamp valid leap second", query: "filter[timestamp]=2016-12-31T23:59:60Z", wantStatus: http.StatusNoContent},
242+
// Invalid: not a date-time string at all.
243+
{
244+
name: "timestamp invalid not a datetime",
245+
query: "filter[timestamp]=not-a-datetime",
246+
wantStatus: http.StatusBadRequest,
247+
wantField: "timestamp",
248+
wantRule: "format",
249+
wantReasonSubstr: "date-time",
250+
},
251+
// Invalid: date-only (missing time component).
252+
{
253+
name: "timestamp invalid date only",
254+
query: "filter[timestamp]=2024-01-01",
255+
wantStatus: http.StatusBadRequest,
256+
wantField: "timestamp",
257+
wantRule: "format",
258+
wantReasonSubstr: "date-time",
259+
},
260+
// Invalid: missing timezone designator.
261+
{
262+
name: "timestamp invalid no timezone",
263+
query: "filter[timestamp]=2024-01-01T00:00:00",
264+
wantStatus: http.StatusBadRequest,
265+
wantField: "timestamp",
266+
wantRule: "format",
267+
wantReasonSubstr: "date-time",
268+
},
269+
// Invalid: space separator instead of T.
270+
{
271+
name: "timestamp invalid space separator",
272+
query: "filter[timestamp]=2024-01-01+00:00:00Z",
273+
wantStatus: http.StatusBadRequest,
274+
wantField: "timestamp",
275+
wantRule: "format",
276+
wantReasonSubstr: "date-time",
277+
},
278+
232279
// Multiple filters in one request — independent fields can be combined.
233280
{
234281
name: "combined boolean+numeric+string",
@@ -528,6 +575,13 @@ func TestFieldFilterParse(t *testing.T) {
528575
wantParse: fieldFiltersTarget{Labels: &filters.FilterLabels{"key": {Contains: lo.ToPtr("team")}}},
529576
},
530577

578+
// Shared.DateTime scalar — plain *time.Time field, not a filter wrapper.
579+
{
580+
name: "timestamp short",
581+
query: "filter[timestamp]=2024-01-02T03:04:05Z",
582+
wantParse: fieldFiltersTarget{Timestamp: &dt},
583+
},
584+
531585
// Multiple independent filters in one request.
532586
{
533587
name: "combined boolean+numeric+string",

api/v3/test/openapi.test.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ components:
9494
$ref: "#/components/schemas/DateTimeFieldFilter"
9595
labels:
9696
$ref: "#/components/schemas/LabelsFieldFilter"
97+
timestamp:
98+
$ref: "#/components/schemas/DateTime"
9799
additionalProperties: false
98100
description: Field filters with all supported types.
99101
LabelsFieldFilter:

0 commit comments

Comments
 (0)