CtrlK
BlogDocsLog inGet started
Tessl Logo

api

Add or modify API endpoints using TypeSpec. Use when adding new API routes, modifying request/response types, or changing the OpenAPI spec.

70

Quality

87%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

API Development

You are helping the user add or modify API endpoints in OpenMeter.

Context

  • API spec source: api/spec/packages/ — TypeSpec definitions (two packages: aip for v3, legacy for v1)
  • Local server port: The server runs on port 8888 by default locally (http://localhost:8888/api/v3)
  • New APIs go in AIP package: api/spec/packages/aip/src/ — all new endpoints must be added here
  • Generated outputs (DO NOT edit manually):
    • api/openapi.yaml, api/openapi.cloud.yaml — OpenAPI specs
    • api/client/javascript/, api/client/go/ — SDK clients
    • api/api.gen.go, api/v3/api.gen.go — Go server code (oapi-codegen)

AIP (v3) API Structure

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 definitions
  • operations.tsp — interface with CRUD operations

Routes 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.

Workflow

Follow these steps in order:

Step 1: Edit the TypeSpec API spec

For a new domain/resource:

  1. Create a new directory under api/spec/packages/aip/src/<domain>/
  2. Add index.tsp, model file(s), and operations.tsp
  3. Import the domain in api/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)
  4. Wire up the route interface (and any new @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:

  1. Find the relevant files under api/spec/packages/aip/src/<domain>/
  2. Edit the model or operations as needed

Look at existing domains (e.g., meters/, customers/) for conventions:

  • Use Shared.CreateRequest<T>, Shared.GetResponse<T>, Shared.PagePaginatedResponse<T> wrappers
  • For v3 cursor-based list endpoints, prefer Shared.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.
  • Use Common.ErrorResponses, Common.NotFound for error types
  • Use Common.PagePaginationQuery for list operations
  • Use @operationId, @summary, @tag decorators on operations
  • Use Shared.ULID for resource IDs in path parameters
  • Routes follow the pattern /openmeter/<resource>

Step 2: Generate API code

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).

Step 3: Implement the handler

After generating, implement the handler package and wire it into the server.

Handler Package Structure

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 functions

Reference: api/v3/handlers/llmcost/

Handler Interface & Constructor (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

Handler Operation Pattern (<operation>.go)

Each operation file uses httptransport.NewHandlerWithArgs with 4 arguments:

  1. Request decoder — parse HTTP request → domain input, resolve namespace
  2. Operation function — call service, map result to API response type
  3. Response encodercommonhttp.JSONResponseEncoderWithStatus[T](http.StatusXxx)
  4. Optionshttptransport.AppendOptions(h.options, httptransport.WithOperationName("..."), httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()))

List endpoints with filtering: if the operation supports ?filter[...] query parameters, use the /api-filters skill for the decoder and adapter wiring. It covers api/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

Error Mapping

Domain errors auto-map to HTTP status codes via the error encoder:

  • GenericNotFoundError → 404
  • GenericValidationError → 400
  • GenericConflictError → 409
  • GenericForbiddenError → 403
  • GenericPreConditionFailedError → 412

No need to manually handle these — just return them from the service and the error encoder handles it.

Structured Validation Errors (ValidationIssue)

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 severity
  • models.NewValidationWarning(code, message) — warning severity
  • models.NewValidationIssue(code, message, opts...) — with options
  • .WithPathString("body", "field") — attach JSONPath field location
  • .WithComponent(component) — attach component name
  • models.AsValidationIssues(err) — convert error tree to structured issues

Step 4: Wire Handler into Server

Three files to modify:

1. api/v3/server/server.go:

  • Add service to Config struct
  • Add handler field to Server struct
  • Instantiate handler in NewServer() using <domain>handler.New(resolveNamespace, config.<Domain>Service, httptransport.WithErrorHandler(config.ErrorHandler))

2. api/v3/server/routes.go:

  • Add route methods that delegate to handler:
// 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

Step 5: Review

  • Check the generated api/openapi.yaml or api/v3/api.gen.go to verify the endpoints look correct
  • Present a summary of the API changes to the user

AIP Standards (Kong AIP)

OpenMeter 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.

Rule index

FileCovers
rules/aip-122-naming.mdNaming conventions + base resource models (Shared.Resource)
rules/aip-126-enums.mdEnum wire values, unknown zero member, prefer-enum-over-bool
rules/aip-visibility.md@visibility + Lifecycle.Read/Create/Update
rules/aip-134-135-crud.mdCreate/Get/Update/Upsert/Delete templates, PATCH rules, DELETE rules
rules/aip-132-list.mdList endpoints, sort, trailing slash
rules/aip-158-pagination.mdPage-based and cursor-based pagination
rules/aip-160-filtering.mdFilter query syntax, Common.*FieldFilter types, label dot-notation
rules/aip-129-labels.mdLabel key constraints, PATCH-with-null semantics
rules/aip-193-errors.mdAIP-193 RFC-7807 error responses, invalid_parameters, 403-before-404 rule
rules/openmeter-error-types.mdOpenMeter Common.* error types wiring AIP-193 onto operations
rules/inline-errors.mdInline (partial / non-fatal) errors via Shared.BaseError<T> for 2xx responses
rules/aip-composition.mdComposition-over-inheritance (spread, model is, @discriminator)
rules/aip-docs.md@doc//** */ requirements, @operationId, @summary
rules/aip-181-stability.mdx-private / x-unstable / x-internal stability markers
rules/aip-142-time.mdRFC-3339 timestamps, ISO-8601 duration deviation
rules/aip-137-content-type.mdContent-Type validation, 415 on unsupported
rules/aip-235-bulk-delete.mdPOST .../bulk-delete transactional vs 207 partial
rules/aip-3101-versioning.mdURL-path versioning, per-resource versioning
rules/aip-3106-empty-fields.mdAlways 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.

Important Reminders

  • All new APIs go in the AIP package (api/spec/packages/aip/src/)
  • Legacy/v1 APIs are in api/spec/packages/legacy/src/ — avoid adding new endpoints there
  • Never edit generated files manually (api/openapi.yaml, api/client/, api/*.gen.go)
  • Run make gen-api to generate Go types
  • Follow existing TypeSpec patterns and conventions from other AIP domains
  • When implementing the service layer, follow docs/development/service-patterns.md
Repository
openmeterio/openmeter
Last updated
First committed

Is this your skill?

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.