mit einem Klick
api
// Add or modify API endpoints using TypeSpec. Use when adding new API routes, modifying request/response types, or changing the OpenAPI spec.
// Add or modify API endpoints using TypeSpec. Use when adding new API routes, modifying request/response types, or changing the OpenAPI spec.
| name | api |
| description | Add or modify API endpoints using TypeSpec. Use when adding new API routes, modifying request/response types, or changing the OpenAPI spec. |
| user-invocable | true |
| argument-hint | [description of API change] |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob, Agent |
You are helping the user add or modify API endpoints in OpenMeter.
api/spec/packages/ — TypeSpec definitions (two packages: aip for v3, legacy for v1)http://localhost:8888/api/v3)api/spec/packages/aip/src/ — all new endpoints must be added hereapi/openapi.yaml, api/openapi.cloud.yaml — OpenAPI specsapi/client/javascript/, api/client/go/ — SDK clientsapi/api.gen.go, api/v3/api.gen.go — Go server code (oapi-codegen)api/spec/packages/aip/src/
├── main.tsp # Top-level imports
├── openmeter.tsp # Service definition, routes, and interface wiring
├── konnect.tsp # Konnect-specific service definition (must mirror openmeter.tsp's tags + route interfaces)
├── common/ # Shared types: errors, pagination, parameters
├── shared/ # Shared resources: ULID, request/response wrappers, tags
├── meters/ # Domain: models + operations
├── customers/ # Domain: models + operations
├── subscriptions/ # ...
├── billing/
├── apps/
├── currencies/
├── llmcost/
└── ...
Each domain typically has:
index.tsp — imports for the domain<resource>.tsp — model/type definitionsoperations.tsp — interface with CRUD operationsRoutes are wired in api/spec/packages/aip/src/openmeter.tsp via interface declarations with @route and @tag decorators.
api/spec/packages/aip/src/konnect.tsp is the parallel Konnect-flavoured service definition. The two files are not identical — Konnect has its own service metadata, namespace name, @useAuth configuration, security scheme models, and intentionally exposes a narrower subset of the OpenMeter surface. But for any domain that is exposed in both, every new domain import, @tagMetadata(...) entry, and @route / @tag interface must be added to both files in the same edit. Diff the two files before generating to spot accidental drift; existing differences are expected, but a tag/route you just added showing up in only one file is a bug.
Follow these steps in order:
For a new domain/resource:
api/spec/packages/aip/src/<domain>/index.tsp, model file(s), and operations.tspapi/spec/packages/aip/src/openmeter.tsp and api/spec/packages/aip/src/konnect.tsp (unless the domain is intentionally OpenMeter-only — confirm with the user before excluding it from Konnect)@tagMetadata) in both openmeter.tsp and konnect.tsp. After editing, run diff openmeter.tsp konnect.tsp and check that your new imports / tags / interfaces appear on both sides — pre-existing differences (service metadata, namespace name, @useAuth, security scheme models) are intentional and unrelated to your change.For modifying an existing endpoint:
api/spec/packages/aip/src/<domain>/Look at existing domains (e.g., meters/, customers/) for conventions:
Shared.CreateRequest<T>, Shared.GetResponse<T>, Shared.PagePaginatedResponse<T> wrappersShared.CursorPaginatedResponse<T> over endpoint-specific cursor meta models. In generated Go, this maps to api.CursorMetaPage, where next / previous are nullable.Nullable[string] and size is float32; handlers may still return opaque cursor tokens and leave first / last unset.Common.ErrorResponses, Common.NotFound for error typesCommon.PagePaginationQuery for list operations@operationId, @summary, @tag decorators on operationsShared.ULID for resource IDs in path parameters/openmeter/<resource>Run:
make gen-api
This generates the OpenAPI spec, SDK clients, and Go server stubs. Check that it completes without errors.
Then run:
make generate
This regenerates Go server code from the updated OpenAPI spec (oapi-codegen).
After generating, implement the handler package and wire it into the server.
Each handler domain lives at api/v3/handlers/<domain>/ and contains:
handler.go — Handler interface + constructor<operation>.go — One file per operation (create.go, list.go, get.go, delete.go)convert.go — Domain ↔ API type mapping functionsReference: api/v3/handlers/llmcost/
handler.go)package <domain>
type Handler interface {
List<Resource>s() List<Resource>sHandler
Create<Resource>() Create<Resource>Handler
Get<Resource>() Get<Resource>Handler
Delete<Resource>() Delete<Resource>Handler
}
type handler struct {
resolveNamespace func(ctx context.Context) (string, error)
service <domain>.Service
options []httptransport.HandlerOption
}
func New(
resolveNamespace func(ctx context.Context) (string, error),
service <domain>.Service,
options ...httptransport.HandlerOption,
) Handler {
return &handler{
resolveNamespace: resolveNamespace,
service: service,
options: options,
}
}
Reference: api/v3/handlers/llmcost/handler.go
<operation>.go)Each operation file uses httptransport.NewHandlerWithArgs with 4 arguments:
commonhttp.JSONResponseEncoderWithStatus[T](http.StatusXxx)httptransport.AppendOptions(h.options, httptransport.WithOperationName("..."), httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()))List endpoints with filtering: if the operation supports
?filter[...]query parameters, use the/api-filtersskill for the decoder and adapter wiring. It coversapi/v3/filters.Parse, the typed filter structs,Convert*helpers, range splitting, and the Ent.Select(field)application — everything this skill does not cover.
Type alias convention at top of file:
type (
List<Resource>sRequest = <domain>.List<Resource>sInput
List<Resource>sResponse = response.PagePaginationResponse[api.<Resource>]
List<Resource>sParams = api.List<Resource>sParams
List<Resource>sHandler = httptransport.HandlerWithArgs[List<Resource>sRequest, List<Resource>sResponse, List<Resource>sParams]
)
Full example:
func (h *handler) List<Resource>s() List<Resource>sHandler {
return httptransport.NewHandlerWithArgs(
// 1. Request decoder
func(ctx context.Context, r *http.Request, params List<Resource>sParams) (List<Resource>sRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return List<Resource>sRequest{}, err
}
req := List<Resource>sRequest{
Namespace: ns,
}
// Pagination
req.Page = pagination.NewPage(1, 20)
if params.Page != nil {
req.Page = pagination.NewPage(
lo.FromPtrOr(params.Page.Number, 1),
lo.FromPtrOr(params.Page.Size, 20),
)
if err := req.Page.Validate(); err != nil {
return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
})
}
}
// Sort
if params.Sort != nil {
sort, err := request.ParseSortBy(*params.Sort)
if err != nil {
return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
})
}
if !validSortField(sort.Field) {
return req, apierrors.NewBadRequestError(ctx, fmt.Errorf("unsupported sort field: %s", sort.Field), apierrors.InvalidParameters{
{Field: "sort", Reason: fmt.Sprintf("unsupported sort field %q", sort.Field), Source: apierrors.InvalidParamSourceQuery},
})
}
req.OrderBy = sort.Field
req.Order = sort.Order.ToSortxOrder()
}
return req, nil
},
// 2. Operation function
func(ctx context.Context, request List<Resource>sRequest) (List<Resource>sResponse, error) {
result, err := h.service.List<Resource>s(ctx, request)
if err != nil {
return List<Resource>sResponse{}, fmt.Errorf("failed to list: %w", err)
}
items := lo.Map(result.Items, func(item <domain>.<Resource>, _ int) api.<Resource> {
return domainToAPI(item)
})
return response.NewPagePaginationResponse(items, response.PageMetaPage{
Size: request.Page.PageSize,
Number: request.Page.PageNumber,
Total: lo.ToPtr(result.TotalCount),
}), nil
},
// 3. Response encoder
commonhttp.JSONResponseEncoderWithStatus[List<Resource>sResponse](http.StatusOK),
// 4. Options
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("list-<resource>s"),
httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
)...,
)
}
For handlers without params (e.g., Create), use httptransport.NewHandler (3 arguments, no params):
type (
Create<Resource>Handler = httptransport.Handler[Create<Resource>Request, Create<Resource>Response]
)
Reference: api/v3/handlers/llmcost/list_prices.go
Domain errors auto-map to HTTP status codes via the error encoder:
GenericNotFoundError → 404GenericValidationError → 400GenericConflictError → 409GenericForbiddenError → 403GenericPreConditionFailedError → 412No need to manually handle these — just return them from the service and the error encoder handles it.
In v3 API handlers, use models.ValidationIssue for structured validation errors with codes, field paths, and severity levels. This is the handler-layer pattern — service/adapter layers continue using models.NewGenericValidationError().
// Define validation issues as package-level variables
var errMissingName = models.NewValidationError("missing_name", "name is required")
var errInvalidCurrency = models.NewValidationWarning("invalid_currency", "currency not recognized")
// Use with field paths
err := errMissingName.WithPathString("body", "name")
// Convert from domain errors to structured issues
issues, err := models.AsValidationIssues(domainErr)
Key types from pkg/models/validationissue.go:
models.NewValidationError(code, message) — critical severitymodels.NewValidationWarning(code, message) — warning severitymodels.NewValidationIssue(code, message, opts...) — with options.WithPathString("body", "field") — attach JSONPath field location.WithComponent(component) — attach component namemodels.AsValidationIssues(err) — convert error tree to structured issuesThree files to modify:
1. api/v3/server/server.go:
Config structServer structNewServer() using <domain>handler.New(resolveNamespace, config.<Domain>Service, httptransport.WithErrorHandler(config.ErrorHandler))2. api/v3/server/routes.go:
// For operations WITH params (list, get by ID, delete by ID):
func (s *Server) List<Resource>s(w http.ResponseWriter, r *http.Request, params api.List<Resource>sParams) {
s.<domain>Handler.List<Resource>s().With(params).ServeHTTP(w, r)
}
func (s *Server) Get<Resource>(w http.ResponseWriter, r *http.Request, id api.ULID) {
s.<domain>Handler.Get<Resource>().With(id).ServeHTTP(w, r)
}
// For operations WITHOUT params (create):
func (s *Server) Create<Resource>(w http.ResponseWriter, r *http.Request) {
s.<domain>Handler.Create<Resource>().ServeHTTP(w, r)
}
3. Import the handler package in server.go.
Reference: api/v3/server/server.go:138-218, api/v3/server/routes.go
api/openapi.yaml or api/v3/api.gen.go to verify the endpoints look correctOpenMeter v3 APIs follow Kong's AIP conventions. Each rule lives in its own file under rules/ next to this SKILL — open the rule file you need for the task at hand.
| File | Covers |
|---|---|
rules/aip-122-naming.md | Naming conventions + base resource models (Shared.Resource) |
rules/aip-126-enums.md | Enum wire values, unknown zero member, prefer-enum-over-bool |
rules/aip-visibility.md | @visibility + Lifecycle.Read/Create/Update |
rules/aip-134-135-crud.md | Create/Get/Update/Upsert/Delete templates, PATCH rules, DELETE rules |
rules/aip-132-list.md | List endpoints, sort, trailing slash |
rules/aip-158-pagination.md | Page-based and cursor-based pagination |
rules/aip-160-filtering.md | Filter query syntax, Common.*FieldFilter types, label dot-notation |
rules/aip-129-labels.md | Label key constraints, PATCH-with-null semantics |
rules/aip-193-errors.md | AIP-193 RFC-7807 error responses, invalid_parameters, 403-before-404 rule |
rules/openmeter-error-types.md | OpenMeter Common.* error types wiring AIP-193 onto operations |
rules/inline-errors.md | Inline (partial / non-fatal) errors via Shared.BaseError<T> for 2xx responses |
rules/aip-composition.md | Composition-over-inheritance (spread, model is, @discriminator) |
rules/aip-docs.md | @doc//** */ requirements, @operationId, @summary |
rules/aip-181-stability.md | x-private / x-unstable / x-internal stability markers |
rules/aip-142-time.md | RFC-3339 timestamps, ISO-8601 duration deviation |
rules/aip-137-content-type.md | Content-Type validation, 415 on unsupported |
rules/aip-235-bulk-delete.md | POST .../bulk-delete transactional vs 207 partial |
rules/aip-3101-versioning.md | URL-path versioning, per-resource versioning |
rules/aip-3106-empty-fields.md | Always return all fields, null / [] / {} for empty |
For filtering specifically, rules/aip-160-filtering.md covers the TypeSpec side (which Common.*FieldFilter to pick, Shared.ResourceFilters, label dot-notation, deepObject exposure). The Go implementation side — parsing deepObject query params into typed filters, converting to pkg/filter, and applying Ent predicates — is in the /api-filters skill.
api/spec/packages/aip/src/)api/spec/packages/legacy/src/ — avoid adding new endpoints thereapi/openapi.yaml, api/client/, api/*.gen.go)make gen-api to generate Go types/service skill for service/adapter patternsWrite tests for OpenMeter services following project conventions. Use when creating unit tests, integration tests, or service tests.
Work with the subscription sync bridge in `openmeter/billing/worker/subscriptionsync/...`. Use when modifying how subscription target state is reconciled into billing artifacts such as invoice lines, split-line groups, or charges; when changing persisted-state loading, reconciler patch routing, or subscription sync tests; and when reasoning about the bridge between subscription views and billing state.
Use when writing or refactoring Go code in OpenMeter that can use github.com/samber/lo helpers, especially trivial slice-to-map, map keys/values, mapping, filtering, grouping, uniqueness, set-like conversions, and map entry transformations.
Work with OpenMeter billing charges, including the root charges facade, charge meta queries, charge creation and advancement, usage-based lifecycle state machines, realization runs, and charges test setup. Use when modifying `openmeter/billing/charges/...` or charge-related tests.
Work with the OpenMeter ledger package. Use when modifying ledger code, writing ledger tests, or debugging ledger issues.
Work with the OpenMeter billing package. Use this skill whenever touching invoice lifecycle, billing profiles, customer overrides, invoice line items, gathering invoices, standard invoices, the invoice state machine, billing validation issues, billing-subscription sync, the billing worker, invoice calculation, rating/pricing engine, or tax config on billing objects. Also use when writing or debugging billing integration tests (BaseSuite, SubscriptionMixin), billing adapter (Ent queries), billing HTTP handlers, or the subscription→billing sync algorithm. Trigger this skill for any file under `openmeter/billing/`, `openmeter/billing/worker/`, `openmeter/billing/service/`, `openmeter/billing/adapter/`, `openmeter/billing/rating/`, `test/billing/`, or `cmd/billing-worker/`.