Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/spec/packages/aip/src/test.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
37 changes: 35 additions & 2 deletions api/v3/filters/parse.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package filters

import (
"encoding"
"errors"
"fmt"
"net/url"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions api/v3/filters/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions api/v3/test/filters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions api/v3/test/openapi.test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading