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.
76
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
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* converterspkg/filter/ — internal predicate model with Validate(), Select(field), ApplyToQuery(...) helpers*filter.* predicatesFiltering straddles two layers that the repo skill set keeps separate:
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.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.
api/v3/filters/ — API-shaped filter structs and FromAPI* converterspkg/filter/ — implements the Filter interface (Validate, Select, IsEmpty, …); used by Ent query buildersapi/v3/handlers/customers/list.go (handler) + openmeter/customer/adapter/customer.go (adapter) + openmeter/customer/customer.go (service input struct)../api/rules/aip-160-filtering.mdTypeSpec 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:
params.Filter.X (API-shaped) → *filter.X (predicate) using filters.FromAPIFilter*.*filter.FilterString, *filter.FilterTime, *filter.FilterULID, etc. — NOT the API-layer types.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.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 delimiterOperator constants live in api/v3/filters/parse.go as OpEq, OpNeq, OpGt, OpGte, OpLt, OpLte, OpContains, OpOeq, OpOcontains, OpExists, OpNexists.
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().
filter[...] parameters with different fields combine with AND.oeq / ocontains combines its values with OR (IN (...) or OR ILIKE ...).gte and lte) is wrapped by the converter into And{...} of single-operator pkg/filter nodes.IS NOT NULL; nexists only works on schemaless maps (labels, metadata).pkg/filterMutual-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)))
}
}api/v3/filters/parse.go:16-19)maxFilterValueLength)maxCommaSeparatedItems)?filter[f][eq]=a&filter[f][eq]=b)checkUnknownFilterKeys)Follow these steps in order. Use the /api skill alongside this one when you also need to touch 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.
pkg/filter predicates on the service input structIn 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.
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:
filters.Parse directly — the generated OAS binding layer does that and surfaces any parse/validation errors as InvalidParamFormatError before the handler runs.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.apierrors.NewBadRequestError(...) using Source: apierrors.InvalidParamSourceQuery and Field: "filter[<field>]".Adapters use filter.ApplyToQuery(query, input.Field, dbField) — a generic helper that:
pkg/filter.SelectPredicate[P](...).q.Where(*p) when the predicate is non-empty.From openmeter/customer/adapter/customer.go:52:
query = filter.ApplyToQuery(query, input.Key, customerdb.FieldKey)
query = filter.ApplyToQuery(query, input.Name, customerdb.FieldName)
query = filter.ApplyToQuery(query, input.PrimaryEmail, customerdb.FieldPrimaryEmail)Important behaviors baked into the converter + ApplyToQuery pipeline:
gte+lte) are packed into FilterX{And: &parts} by the FromAPIFilter* helper.Oeq → In: comma-separated equals becomes a SQL IN (...) via filter.FilterString{In: ...}.Ocontains → Or of Contains: becomes an OR ILIKE chain via FilterString{Or: ...}.FilterLabels is special: convert with FromAPIFilterLabels and then apply each entry against the JSONB key in the adapter — ApplyToQuery does not handle map-shaped predicates on its own.time.Time by Parse. FromAPIFilterDateTime cannot fail on format anymore, but still returns error for the interface.ALWAYS add service level tests for the list function in the domain's service_test.go. These tests should:
testCases := []struct{...}).FilterByID, FilterByKey, FilterByStatus).SortByNameDesc).Write tests at three additional layers:
api/v3/filters/parse_test.go): the parse layer is already covered for the generic operator surface; only add cases when introducing a new filter type or operator.api/v3/filters/convert_test.go): only when adding a new FromAPIFilter* helper.adapter_test.go): If you made changes to the adapter (e.g., adding filter.ApplyToQuery for new fields) that are not already covered by existing tests, add specific test cases to verify the Ent query generation for these fields.?filter[...]= query strings produce the expected results. Cover at minimum:
filter[name]=foofilter[name][eq]=foofilter[name][contains]=...filter[name][oeq]=a,b,cfilter[created_at][gte]=...&filter[created_at][lte]=...labels if applicableUse httptest.NewRequest and assert on the response body or the captured service input.
| You want… | API type | Predicate |
|---|---|---|
| Equality + contains + ranges on a string column | FilterString | *filter.FilterString |
| ULID column (eq/neq/contains/oeq/ocontains/exists) | FilterULID | *filter.FilterULID |
| Equality + neq + IN-list on an enum-like string column | FilterStringExact | *filter.FilterString |
| Numeric column with ranges | FilterNumeric | *filter.FilterFloat |
| Timestamp column with ranges | FilterDateTime | *filter.FilterTime |
| Boolean flag column | FilterBoolean | *filter.FilterBoolean |
| Single label map key | FilterLabel | *filter.FilterString |
| Full labels map | FilterLabels | map[string]filter.FilterString |
filter[labels.env][eq]=prod is supported by treating the first . as the delimiter; the remainder is the map key. . is itself a legal label-key character, so anything after the first dot is the key verbatim.labels field into dot-filtering (the struct field must be typed as FilterLabels); otherwise dot-notation against a regular field is rejected.nexists is only valid on additionalProperties maps (labels, metadata) — do not document it for normal columns.FilterDateTime holds *time.Time and Parse rejects malformed RFC-3339 strings at parse time (via ErrInvalidDateTime). The converter cannot produce format errors; its error return is a forward-compat hook.
?filter[f][eq]=a&filter[f][eq]=b is rejected — this is intentional. To express OR semantics use oeq. Do not try to "fix" the parser to merge repeated keys.
Multiple range operators on the same field (e.g. gte+lte) are packed into And{gte, lte} by the converter. Validation of pathological combinations (e.g. both gt and gte) lives in pkg/filter.FilterX.Validate(); the service input's own Validate() surfaces those errors.
contains / ocontains are case-insensitive (ILIKE under the hood).eq / neq are case-sensitive by default. If a column should match case-insensitively, document that in TypeSpec and either lowercase the value before storing it or use a different operator. Per the AIP spec, fields that are case-sensitive must be explicitly stated as such in the OAS.any / all) on list fieldsThe Kong AIP spec allows ?filter[tags][eq][any]=urgent and [all] quantifiers on list-typed fields. The current OpenMeter implementation does NOT support quantifiers. If a request comes in for a list field, raise this with the user before attempting to add it — this is a parser-level extension, not a per-endpoint change.
name = "x" AND age > 5)startsWith(...), etc.)oeq / ocontains / converter-built And chains provideIf a customer asks for any of these, treat it as a feature request, not a bug fix.
filters.Parse errors (from the generated binding) surface as InvalidParamFormatError{ParamName: "filter"} and are translated to 400 by the API error encoder.FromAPIFilter* errors (today mostly unreachable, but the surface exists) should be wrapped with apierrors.NewBadRequestError using apierrors.InvalidParamSourceQuery and Field: "filter[<field>]".pkg/filter.Validate() errors (from the service input's own Validate()) surface as models.GenericValidationError and are translated by the handler's error encoder. The caller does not need special casing.Representative error messages from Parse:
unknown filter field(s): foo, bar — client used a field not declared on the input structunsupported operator — client used an operator outside the supported setfilter[count][eq]: invalid number "abc" — type coercion failedfilter[field]: only one filter can be set / gt and gte are mutually exclusive — validation rejected the combination (raised in pkg/filter.Validate)filter parameter "...": value too long (max 1024 bytes) / too many comma-separated items (max 50) — security caps trippedfilter parameter "...": repeated query parameter not allowed (got 2 values) — duplicate keysapi/v3/filters/filter.go — API-layer filter structs (no methods; plain data shapes)api/v3/filters/parse.go — Parse entry point, operator constants, per-type parsers, security caps (lines 16–19)api/v3/filters/convert.go — FromAPIFilter* helpers (String, ULID, Label, Labels, StringExact, Numeric, DateTime, Boolean)api/v3/filters/parse_test.go, api/v3/filters/convert_test.go — canonical examples of supported syntaxpkg/filter/filter.go — Filter interface, predicate types, Validate, Select, ApplyToQuery (line 743)api/v3/handlers/customers/list.go — reference handler using FromAPIFilterStringopenmeter/customer/customer.go:296 — reference service input struct typed with *filter.FilterString fields and a Validate() methodopenmeter/customer/adapter/customer.go:52 — reference adapter using filter.ApplyToQuery../api/rules/aip-160-filtering.md — TypeSpec-side rule: Common.*FieldFilter ↔ Go filters.Filter* mapping, Shared.ResourceFilters, label dot-notation*filter.* predicates, not *filters.* API types. Conversion is the handler's job.filter.ApplyToQuery(query, input.Field, dbField) in adapters, not .Select(...) by hand — the helper handles nil-skip and predicate construction.params.Filter struct (from TypeSpec) for handlers to see it. Run make gen-api after editing TypeSpec.pkg/filter.* predicate (called from the service input's Validate()), not on the API-layer types.api/v3/filters/parse.go, pkg/filter.FilterX.Validate, and the matching FromAPIFilter* — the parser is the contract.parse_test.go and convert_test.go — they are the executable spec.5936703
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.