Skip to main content

api-filters

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.

跳到安装

来源信息

仓库
openmeterio/openmeter
最近来源活动
2026年7月28日 15:14
检测到的 SKILL.md 语言
英语
星标
2,281
分支
213

安装方式

默认使用会先检查来源的 Prompt;你也可以切换为直接命令,或下载本地副本。

检查来源文件

决定是否安装前,请先阅读 SKILL.md,以及 SkillsMP 当前展示的配套文件。

正在显示 SKILL.md

SKILL.md
来源说明 · 只读预览
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: ```go 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`: ```go 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`: ```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: 1. Returns the query unchanged when the predicate is nil. 2. Builds an Ent predicate via `pkg/filter.SelectPredicate[P](...)`. 3. Calls `q.Where(*p)` when the predicate is non-empty. From `openmeter/customer/adapter/customer.go:52`:
在 GitHub 查看
这个 SKILL.md 很大,SkillsMP 这里只预览前一段内容。 在 GitHub 查看