| name | api-filters |
| description | Add, modify, or convert AIP-style query-parameter filters on v3 list endpoints. Use when adding filterable fields to a list API, wiring filter parsing into a handler, converting API filters into pkg/filter predicates, or debugging filter parsing/validation behavior. |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob, Agent |
v3 API Filter Parsing
You are helping the user add or modify AIP-style query-parameter filters on an OpenMeter v3 list endpoint.
OpenMeter follows the Kong AIP filter spec (NOT Google AIP-160 expression syntax). Filters use the deepObject query-parameter encoding ?filter[field][op]=value. The implementation is split across three layers:
api/v3/filters/ — API-layer filter types, Parse entry point, and FromAPI* converters
pkg/filter/ — internal predicate model with Validate(), Select(field), ApplyToQuery(...) helpers
- domain service input structs — hold the already-converted
*filter.* predicates
Relationship to other skills
Filtering straddles two layers that the repo skill set keeps separate:
- TypeSpec / OAS side —
Common.*FieldFilter types, Shared.ResourceFilters, deepObject exposure, label dot-notation. See ../api/rules/aip-160-filtering.md (the canonical Kong AIP-160 rule for OpenMeter). Use the /api skill when you also need to scaffold or modify the TypeSpec operation itself.
- Go implementation side — what this skill covers:
api/v3/filters.Parse, the API-layer filter structs, FromAPI* helpers, service input wiring, adapter filter.ApplyToQuery, gotchas.
If you are adding a brand-new filterable endpoint, invoke /api first to wire up the TypeSpec + handler shell, then come back here for the conversion + adapter code. If you are only adding/modifying filters on an existing endpoint, this skill is enough on its own.
Context
- API-layer package:
api/v3/filters/ — API-shaped filter structs and FromAPI* converters
- Internal predicate model:
pkg/filter/ — implements the Filter interface (Validate, Select, IsEmpty, …); used by Ent query builders
- Reference implementation in use:
api/v3/handlers/customers/list.go (handler) + openmeter/customer/adapter/customer.go (adapter) + openmeter/customer/customer.go (service input struct)
- Kong AIP spec for filtering:
../api/rules/aip-160-filtering.md
Architecture: three-layer conversion
TypeSpec Common.*FieldFilter
│ (make gen-api)
▼
api.Filter* (generated OAS types) ─┐
│ │ handler decode layer
api/v3/filters.Filter* (API-layer types) │ calls filters.FromAPIFilter*(...)
│ │
pkg/filter.Filter* (predicate model) ─┘ stored on service input struct
│
▼ adapter layer
filter.ApplyToQuery(query, input.Field, dbField)
Rules:
- The handler converts
params.Filter.X (API-shaped) → *filter.X (predicate) using filters.FromAPIFilter*.
- The service input struct holds
*filter.FilterString, *filter.FilterTime, *filter.FilterULID, etc. — NOT the API-layer types.
- The adapter calls
filter.ApplyToQuery(query, input.Field, dbField) to attach the predicate to the Ent query.
filters.Parse is called by the generated deepObject binding layer in api/v3/api.gen.go, not by handlers. Handlers receive params.Filter already populated.
Filter Grammar (Kong AIP)
Encoding: deepObject query parameters. Two-level brackets identify field and operator:
filter[field]=value # shorthand → eq
filter[field][eq]=value # exact match
filter[field][neq]=value # not equal (also returns NULLs)
filter[field][contains]=value # substring match (case-insensitive on strings)
filter[field][oeq]=a,b,c # one-of-equal (comma-separated, max 50 items)
filter[field][ocontains]=a,b # one-of-contains
filter[field][gt]=value # greater than
filter[field][gte]=value # greater than or equal
filter[field][lt]=value # less than
filter[field][lte]=value # less than or equal
filter[field] # bare key → exists=true (presence check)
filter[field][exists] # explicit existence check
filter[field][nexists] # absence check (only for additionalProperties maps like labels)
filter[labels.key_1][eq]=val # dot-notation: only the FIRST dot is a delimiter
Operator constants live in api/v3/filters/parse.go as OpEq, OpNeq, OpGt, OpGte, OpLt, OpLte, OpContains, OpOeq, OpOcontains, OpExists, OpNexists.
API-layer filter types (api/v3/filters/filter.go)
| Go type | Fields |
|---|
FilterBoolean | Eq |
FilterNumeric | Eq, Neq, Oeq, Gt, Gte, Lt, Lte |
FilterDateTime | Eq, Gt, Gte, Lt, Lte (all *time.Time; no Neq/Oeq) |
FilterString | Eq, Neq, Gt, Gte, Lt, Lte, Contains, Oeq, Ocontains, Exists |
FilterULID | Eq, Neq, Contains, Oeq, Ocontains, Exists (no range ops) |
FilterStringExact | Eq, Neq, Oeq (no Exists, no Contains) |
FilterLabel | Eq, Neq, Contains, Oeq, Ocontains (label map value predicates) |
FilterLabels | type alias: map[string]FilterLabel |
The wire operator for Exists is plain exists (see OpExists in api/v3/filters/parse.go), matching its json:"exists,omitempty" tag — don't confuse it with the unrelated $-prefixed Mongo-style tags used by the v1 API (api/api.gen.go).
Important: the API-layer types do NOT have Validate() methods. Validation (mutual exclusivity, complexity bounds, format checks) happens on the internal pkg/filter.* predicates — typically from the service input struct's own Validate(), calling f.Validate() on each non-nil filter.
pkg/filter predicates
| Predicate | Produced by converter | Notes |
|---|
*filter.FilterString | FromAPIFilterString | Also used by FromAPIFilterLabel, FromAPIFilterStringExact |
*filter.FilterULID | FromAPIFilterULID | Embeds FilterString |
*filter.FilterFloat | FromAPIFilterNumeric | (note: not FilterNumeric) |
*filter.FilterTime | FromAPIFilterDateTime | RFC-3339 already parsed to time.Time by Parse |
*filter.FilterBoolean | FromAPIFilterBoolean | |
map[string]filter.FilterString | FromAPIFilterLabels | Label map flatten |
The Filter interface (pkg/filter/filter.go:19) exposes Validate(), ValidateWithComplexity(maxDepth int), Select(field string) func(*sql.Selector), SelectWhereExpr(...), and IsEmpty().
Multi-filter semantics
- Multiple
filter[...] parameters with different fields combine with AND.
- A single field with
oeq / ocontains combines its values with OR (IN (...) or OR ILIKE ...).
- A single field with multiple operators (e.g. both
gte and lte) is wrapped by the converter into And{...} of single-operator pkg/filter nodes.
- The bare-key existence shortcut maps to
IS NOT NULL; nexists only works on schemaless maps (labels, metadata).
Validation is done by pkg/filter
Mutual-exclusivity and format rules (e.g. "multiple operators on one node", ULID format, complexity depth) are enforced by *filter.FilterX.Validate() — not by the API-layer types. A typical service input Validate() looks like:
if i.Key != nil {
if err := i.Key.Validate(); err != nil {
errs = append(errs, models.NewGenericValidationError(fmt.Errorf("invalid key filter: %w", err)))
}
}
Hard limits (security, api/v3/filters/parse.go:16-19)
- 1024 bytes per single value (
maxFilterValueLength)
- 50 items per comma-separated list (
maxCommaSeparatedItems)
- Repeated query params for the same key are rejected (e.g.,
?filter[f][eq]=a&filter[f][eq]=b)
- Unknown filter fields are rejected before any other validation (
checkUnknownFilterKeys)
Workflow
Follow these steps in order. Use the /api skill alongside this one when you also need to touch TypeSpec.
Step 1: Define the filterable fields in TypeSpec
In api/spec/packages/aip/src/<domain>/operations.tsp, define a named filter model for the list operation and expose it as filter with style: "deepObject", explode: true. Use the Common.*FieldFilter types from common/parameters.tsp — do not hand-roll filter models.
The canonical rule for which Common.*FieldFilter type to pick, the Shared.ResourceFilters spread, label dot-notation, and OAS documentation requirements is ../api/rules/aip-160-filtering.md. That rule also includes the TypeSpec type ↔ Go filters.Filter* mapping. Read it once before picking types — this skill is not the source of truth for the TypeSpec side.
The events list endpoint (api/spec/packages/aip/src/events/operations.tsp) and the customer list endpoint are the canonical worked examples.
After editing TypeSpec, run make gen-api so the generated params.Filter struct in api/v3/api.gen.go picks up the new fields.
Step 2: Store pkg/filter predicates on the service input struct
In your domain service input type, add fields typed as pkg/filter predicates, not API-layer types. Example from openmeter/customer/customer.go:296:
type ListCustomersInput struct {
Namespace string
pagination.Page
OrderBy string
Order sortx.Order
Key *filter.FilterString
Name *filter.FilterString
PrimaryEmail *filter.FilterString
}
func (i ListCustomersInput) Validate() error {
var errs []error
if i.Key != nil {
if err := i.Key.Validate(); err != nil {
errs = append(errs, models.NewGenericValidationError(fmt.Errorf("invalid key filter: %w", err)))
}
}
return models.NewNillableGenericValidationError(errors.Join(errs...))
}
Pick the narrowest predicate: filter.FilterString for strings, filter.FilterULID for ULID columns, filter.FilterFloat for numbers, filter.FilterTime for timestamps, filter.FilterBoolean for bools.
Step 3: Convert API filters in the HTTP handler
In the handler decoder (the first argument to httptransport.NewHandlerWithArgs), call the matching filters.FromAPIFilter* helper against the generated params.Filter.<field> and assign to the request. The canonical pattern is in api/v3/handlers/customers/list.go:
import (
"github.com/openmeterio/openmeter/api/v3/apierrors"
"github.com/openmeterio/openmeter/api/v3/filters"
)
if params.Filter != nil {
key, err := filters.FromAPIFilterString(params.Filter.Key)
if err != nil {
return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{Field: "filter[key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
})
}
req.Key = key
name, err := filters.FromAPIFilterString(params.Filter.Name)
if err != nil {
return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{Field: "filter[name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
})
}
req.Name = name
}
Notes:
- Handlers do not call
filters.Parse directly — the generated OAS binding layer does that and surfaces any parse/validation errors as InvalidParamFormatError before the handler runs.
- Every
FromAPIFilter* returns (*filter.X, error). The error channel is reserved for helpers that can fail (e.g. future format checks); today most helpers only return (nil, nil) on a nil input, but always handle the error for forward-compatibility.
- On error, wrap with
apierrors.NewBadRequestError(...) using Source: apierrors.InvalidParamSourceQuery and Field: "filter[<field>]".
Step 4: Apply to the query in the adapter
Adapters use filter.ApplyToQuery(query, input.Field, dbField) — a generic helper that:
- Returns the query unchanged when the predicate is nil.
- Builds an Ent predicate via
pkg/filter.SelectPredicate[P](...).
- Calls
q.Where(*p) when the predicate is non-empty.
From openmeter/customer/adapter/customer.go:52: