From 05be25fe03a3eaf7d388142a0db6fad49dca3ece Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 19 May 2026 20:10:52 +0200 Subject: [PATCH] feat(api): support more filter types in parser --- api/spec/packages/aip/src/test.tsp | 2 ++ api/v3/filters/parse.go | 37 ++++++++++++++++++-- api/v3/filters/parse_test.go | 32 ++++++++++++++++++ api/v3/test/filters_test.go | 54 ++++++++++++++++++++++++++++++ api/v3/test/openapi.test.yaml | 2 ++ 5 files changed, 125 insertions(+), 2 deletions(-) diff --git a/api/spec/packages/aip/src/test.tsp b/api/spec/packages/aip/src/test.tsp index ffafdcc344..b8778dd4a5 100644 --- a/api/spec/packages/aip/src/test.tsp +++ b/api/spec/packages/aip/src/test.tsp @@ -30,6 +30,8 @@ model FieldFilters { datetime?: Common.DateTimeFieldFilter; #suppress "@openmeter/api-spec-aip/doc-decorator" "test model" labels?: Common.LabelsFieldFilter; + #suppress "@openmeter/api-spec-aip/doc-decorator" "test model" + timestamp?: Shared.DateTime; } @route("/field-filters") diff --git a/api/v3/filters/parse.go b/api/v3/filters/parse.go index 5149b6e6c1..7cba9fa316 100644 --- a/api/v3/filters/parse.go +++ b/api/v3/filters/parse.go @@ -1,6 +1,7 @@ package filters import ( + "encoding" "errors" "fmt" "net/url" @@ -85,6 +86,7 @@ var ( filterLabelsType = reflect.TypeFor[FilterLabels]() filterLabelsPtrType = reflect.TypeFor[*FilterLabels]() stringPtrType = reflect.TypeFor[*string]() + textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() ) // parseFiltersValue iterates struct fields and dispatches to per-type parsers. @@ -186,8 +188,16 @@ func parseFiltersValue(qs url.Values, v reflect.Value) error { } default: - // Handle *T where T is a named string-based type (e.g. *BillingCreditTransactionType). - if fieldVal.Kind() == reflect.Pointer && fieldVal.Type().Elem().Kind() == reflect.String { + // Handle *T where *T implements encoding.TextUnmarshaler (e.g. *time.Time). + if fieldVal.Kind() == reflect.Pointer && fieldVal.Type().Implements(textUnmarshalerType) { + if hasOperatorStyleKeys(qs, name) { + return fmt.Errorf("filter[%s]: operator-style keys are not supported for this field", name) + } + if err := parseTextUnmarshalerPtr(qs, name, fieldVal); err != nil { + return err + } + // Handle *T where T is a named string-based type (e.g. *BillingCreditTransactionType). + } else 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) } @@ -243,6 +253,29 @@ func parseStringPtrTyped(qs url.Values, name string, fieldVal reflect.Value) err return nil } +// parseTextUnmarshalerPtr handles filter[field]=value for *T fields where *T implements encoding.TextUnmarshaler. +func parseTextUnmarshalerPtr(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()) + if err := ptr.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(val)); err != nil { + return fmt.Errorf("filter[%s]: invalid value %q: %w", name, val, err) + } + 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 diff --git a/api/v3/filters/parse_test.go b/api/v3/filters/parse_test.go index 6560fb3b2a..f6c3b7b4b5 100644 --- a/api/v3/filters/parse_test.go +++ b/api/v3/filters/parse_test.go @@ -27,6 +27,7 @@ type testFilter struct { Enabled *FilterBoolean `json:"enabled,omitempty"` Currency *string `json:"currency,omitempty"` TxType *testStringType `json:"tx_type,omitempty"` + Timestamp *time.Time `json:"timestamp,omitempty"` } func TestParse_FilterString(t *testing.T) { @@ -349,6 +350,37 @@ func TestParse_StringPtrOperatorRejected(t *testing.T) { }) } +func TestParse_TimestampPtr(t *testing.T) { + ts := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + + t.Run("valid RFC-3339 value", func(t *testing.T) { + var f testFilter + require.NoError(t, Parse(url.Values{"filter[timestamp]": {"2024-01-02T03:04:05Z"}}, &f)) + require.NotNil(t, f.Timestamp) + assert.Equal(t, ts, *f.Timestamp) + }) + + 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.Timestamp) + }) + + t.Run("invalid value is rejected", func(t *testing.T) { + var f testFilter + err := Parse(url.Values{"filter[timestamp]": {"not-a-time"}}, &f) + require.Error(t, err) + assert.Contains(t, err.Error(), "filter[timestamp]") + }) + + t.Run("operator-style key rejected", func(t *testing.T) { + var f testFilter + err := Parse(url.Values{"filter[timestamp][eq]": {"2024-01-02T03:04:05Z"}}, &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 diff --git a/api/v3/test/filters_test.go b/api/v3/test/filters_test.go index d73a00f700..39c5c9ccc8 100644 --- a/api/v3/test/filters_test.go +++ b/api/v3/test/filters_test.go @@ -30,6 +30,7 @@ type fieldFiltersTarget struct { ULID *filters.FilterULID `json:"ulid,omitempty"` DateTime *filters.FilterDateTime `json:"datetime,omitempty"` Labels *filters.FilterLabels `json:"labels,omitempty"` + Timestamp *time.Time `json:"timestamp,omitempty"` } // validatorErrorResponse mirrors the AIP-style error body produced by @@ -229,6 +230,52 @@ func TestFieldFilterValidation(t *testing.T) { wantReasonSubstr: "must be an object", }, + // Shared.DateTime scalar (plain timestamp field, not a filter wrapper). + // The OAS validator enforces RFC-3339 date-time format (kin-openapi regex). + // Valid: UTC (Z), positive/negative numeric offsets, sub-second precision, leap second. + {name: "timestamp valid UTC Z", query: "filter[timestamp]=2024-01-01T00:00:00Z", wantStatus: http.StatusNoContent}, + // + must be percent-encoded in query strings; raw + decodes to space. + {name: "timestamp valid positive offset", query: "filter[timestamp]=2024-06-15T12:30:00%2B05:30", wantStatus: http.StatusNoContent}, + {name: "timestamp valid negative offset", query: "filter[timestamp]=2024-06-15T12:30:00-07:00", wantStatus: http.StatusNoContent}, + {name: "timestamp valid sub-second precision", query: "filter[timestamp]=2024-01-02T03:04:05.999Z", wantStatus: http.StatusNoContent}, + {name: "timestamp valid leap second", query: "filter[timestamp]=2016-12-31T23:59:60Z", wantStatus: http.StatusNoContent}, + // Invalid: not a date-time string at all. + { + name: "timestamp invalid not a datetime", + query: "filter[timestamp]=not-a-datetime", + wantStatus: http.StatusBadRequest, + wantField: "timestamp", + wantRule: "format", + wantReasonSubstr: "date-time", + }, + // Invalid: date-only (missing time component). + { + name: "timestamp invalid date only", + query: "filter[timestamp]=2024-01-01", + wantStatus: http.StatusBadRequest, + wantField: "timestamp", + wantRule: "format", + wantReasonSubstr: "date-time", + }, + // Invalid: missing timezone designator. + { + name: "timestamp invalid no timezone", + query: "filter[timestamp]=2024-01-01T00:00:00", + wantStatus: http.StatusBadRequest, + wantField: "timestamp", + wantRule: "format", + wantReasonSubstr: "date-time", + }, + // Invalid: space separator instead of T. + { + name: "timestamp invalid space separator", + query: "filter[timestamp]=2024-01-01+00:00:00Z", + wantStatus: http.StatusBadRequest, + wantField: "timestamp", + wantRule: "format", + wantReasonSubstr: "date-time", + }, + // Multiple filters in one request — independent fields can be combined. { name: "combined boolean+numeric+string", @@ -528,6 +575,13 @@ func TestFieldFilterParse(t *testing.T) { wantParse: fieldFiltersTarget{Labels: &filters.FilterLabels{"key": {Contains: lo.ToPtr("team")}}}, }, + // Shared.DateTime scalar — plain *time.Time field, not a filter wrapper. + { + name: "timestamp short", + query: "filter[timestamp]=2024-01-02T03:04:05Z", + wantParse: fieldFiltersTarget{Timestamp: &dt}, + }, + // Multiple independent filters in one request. { name: "combined boolean+numeric+string", diff --git a/api/v3/test/openapi.test.yaml b/api/v3/test/openapi.test.yaml index 156ea8ce0c..6e70333c08 100644 --- a/api/v3/test/openapi.test.yaml +++ b/api/v3/test/openapi.test.yaml @@ -94,6 +94,8 @@ components: $ref: "#/components/schemas/DateTimeFieldFilter" labels: $ref: "#/components/schemas/LabelsFieldFilter" + timestamp: + $ref: "#/components/schemas/DateTime" additionalProperties: false description: Field filters with all supported types. LabelsFieldFilter: