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
آخر نشاط في المصدر
٢٨ يوليو ٢٠٢٦ في ١٥:١٤
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٢٬٢٨١
التفرعات
٢١٣

خيارات التثبيت

يُحدَّد 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