| # OpenMeter API Spec & SDK Generator |
|
|
| This workspace holds the TypeSpec API definitions and SDK generators. For |
| repo-wide guidance see the root [AGENTS.md](../../AGENTS.md); this |
| file covers only what is specific to `api/spec`. |
|
|
| ## Layout |
|
|
| ``` |
| packages/ |
| aip/ # AIP TypeSpec source (api definitions, linter rules) |
| legacy/ # legacy OpenAPI output |
| typespec-typescript/ # the SDK generator (TypeSpec emitter, Alloy-based) |
| typespec-go/ # the Go SDK generator (TypeSpec emitter, Alloy-based) |
| aip-client-javascript/ # generator OUTPUT: the emitted TypeScript SDK |
| ``` |
|
|
| The **runtime templates** (the fixed SDK runtime files + conformance tests the |
| generator reproduces verbatim) live as real, reviewable files under |
| `typespec-typescript/templates/` — not embedded blobs and not a separate |
| baseline directory. `typespec-typescript/src/runtime-templates.ts` reads them |
| via `readFileSync` at build time and emits them into the generated SDK. To |
| edit the runtime templates or tests, edit the files under `templates/` |
| directly, then run `make -C api/spec generate`. |
|
|
| - `typespec-typescript` is a TypeSpec **emitter** built on `@alloy-js` + |
| `@typespec/emitter-framework`. It walks HTTP operations and emits the full |
| SDK. |
| - `aip-client-javascript` is its **output directory** (`emitter-output-dir` in |
| `packages/aip/tspconfig.yaml` points here). Everything it contains is |
| regenerable — never hand-edit it. A single `generate` emits the complete SDK |
| (schemas, runtime, per-namespace surface, barrel) plus the conformance tests. |
| - `typespec-go` is a TypeSpec **emitter** built on `@alloy-js/go` + |
| `@typespec/emitter-framework`. It emits the Go SDK into `api/v3/client`, which |
| is also fully regenerable generated output. |
|
|
| ### How the emitter is structured |
|
|
| - `emitter.tsx` — `$onEmit`: emits `schemas.ts` (Alloy components, the original |
| path), the static runtime files, and the per-namespace surface files, all as |
| sibling `<ts.SourceFile>` children of one `<Output>`. |
| - `runtime-templates.ts` — reads the fixed runtime files (`core.ts`, `lib/*`, |
| `models/errors.ts`) and the conformance tests verbatim, via `readFileSync`, |
| from the committed `templates/runtime/` and `templates/tests/` directories |
| at build time. Edit those files directly to change the runtime or tests; |
| `templates/` is excluded from this package's own `tsconfig.json` `include` |
| (only checked downstream, as part of the generated `aip-client-javascript` |
| package's typecheck/test suite). |
| - New runtime helpers that don't fit the fixed `templates/` set (e.g. |
| `lib/wire.ts`) are authored as a real `.ts` file under `src/runtime/` |
| instead (type-checked and unit-tested by the emitter package's own tooling) |
| and embedded verbatim via `readFileSync` at build time (see |
| `src/wire-runtime.ts`), not as a template-string constant — backticks/`${` |
| inside the runtime source collide with the template-literal delimiters. |
| - `sdk-operations.ts` — operation discovery: namespace grouping, per-op metadata |
| (path/query/body/response), and naming (func name, facade method name via |
| resource-noun stripping, namespace names). |
| - `pagination.ts` — structural detection of page-number vs cursor list |
| operations (see "Pagination companions" below). |
| - `sdk-files.ts` — string generators for the spec-derived surface files |
| (operations types, funcs, facades, root client, barrels). |
| - `readme.ts` — builds the package `README.md` (emitted at the package root, not |
| under `src/`) from the same grouped `SdkOperation[]` the SDK files use, so its |
| documented call paths and routes always match the emitted client. |
|
|
| ### Grouping (reproduce this) |
|
|
| The `OpenMeter` service surfaces every operation through an `*Endpoints` |
| interface that `extends` the resource's interface in its **source** namespace |
| (e.g. `OpenMeter.PlansEndpoints extends ProductCatalog.PlanOperations`). The op |
| walked lives on the `*Endpoints` interface, so its own `namespace` is |
| `OpenMeter` — the meaningful grouping is on `op.interface.sourceInterfaces[0]`. |
|
|
| Group by the **top of the source namespace chain** so multi-interface |
| namespaces stay one client: `MetersEndpoints` + `MetersQueryEndpoints` → |
| `meters` (keeps `meters.query`); all `Customer*Endpoints` → `customers`. |
| `ProductCatalog` is the exception (in `SPLIT_BY_INTERFACE`): it splits by source |
| interface → `plans`, `addons`, `planAddons`. Do NOT group by `@tag` (the tag is |
| a display string like "Metering Events" → stutter) or by `op.namespace` (always |
| `OpenMeter`). |
|
|
| ### Nested sub-clients (reproduce this) |
|
|
| The SDK nests sub-clients (`customers.charges.list()`, |
| `customers.credits.grants.list()`) from the **source namespace chain** below the |
| group's top namespace. Walking `sourceInterfaces[0].namespace` up to the global |
| root yields e.g. `['Customers', 'Charges']` → group `customers`, nest path |
| `['charges']`; `['Customers', 'Credits', 'Grants']` → `customers.credits.grants`. |
| `facadeFile` builds a tree from these paths, emitting one class per node with |
| lazy sub-client getters that share the parent's `Client`. |
|
|
| Because grouping follows the source namespace (not the route), an operation |
| routed under one resource but defined in another's namespace lands under the |
| latter — by design. `list-customer-entitlement-access` is routed under |
| `/customers/` but its interface lives in `Entitlements`, so it is |
| `entitlements.listCustomerAccess()`, NOT `customers.entitlements.*`. This is a |
| deliberate decision (the op is genuinely an Entitlements operation); do not |
| "fix" it to nest under customers. |
|
|
| This nesting is **driven by the TypeSpec source structure, not API routes** — to |
| nest a resource, wrap its `*Operations` interface in a sub-namespace |
| (`namespace Charges { interface CustomerChargesOperations { … } }` inside a file |
| that declares `namespace Customers;`), and update the `extends` reference in |
| **both** `openmeter.tsp` and `konnect.tsp` to the nested path. Wrap ONLY the |
| operation interface — leave models in the parent namespace so their schema names |
| (and OpenAPI output) are unchanged. The method-name strip set includes the nest |
| segments, so `create-credit-grant` under `credits.grants` → `create`. |
|
|
| **OpenAPI invariance is the hard gate** for any `.tsp` change: regenerate and |
| confirm the `output/definitions/.../*.yaml` hashes are unchanged. Namespace |
| nesting of operation interfaces is OpenAPI-neutral (paths/tags/operationIds are |
| explicit); moving a _model_ is not. Watch for namespace collisions — nesting |
| `Customers.Billing` shadows the global `Billing` namespace for unqualified refs |
| in `Customers`-scoped files; alias around it (`Common.BillingRoot`) rather than |
| renaming. |
|
|
| ### Naming rules (reproduce these) |
|
|
| - **func name** = full camelCase operationId: `get-meter` → `getMeter`. |
| - **facade method** = operationId with the group's resource noun(s) and the |
| cross-cutting `metering` qualifier stripped: `get-meter` → `get`; |
| `get-customer-billing` → `getBilling`; `ingest-metering-events` → `ingest`. |
| Singular/plural folded. The resource name is split into strip-words on |
| separators **and** case boundaries — both camelCase (`PlanAddons` → `plan`, |
| `addons`) and acronym→word (`LLMCost` → `llm`, `cost`) — so multi-word and |
| acronym-prefixed namespaces strip fully (`create-plan-addon` → `create`, |
| `create-llm-cost-override` → `createOverride`). When the operationId noun is |
| not the namespace's own resource word it is kept as a disambiguator |
| (`llmCost.listPrices`/`listOverrides`, `subscriptions.listAddons`). |
| - **namespace** = source namespace (already plural, e.g. `Meters`, `Events`, |
| `Customers`) or a pluralized split interface resource (`Plan` → `Plans`); |
| PascalCase class / camelCase getter. |
| - **request type** composed from direct-TS parts (no `z.input`): query-only → |
| `<Base>Query`; body-only → the body interface (its `…Input` variant when the |
| body diverges on input); path-only → `{ id: string }`; path+body → |
| `{ id; body }`; path+query → `<Base>Query & { id }`; body+query → |
| `{ body } & <Base>Query` (body nested so query fields don't leak into the JSON |
| body). Path params are ULIDs, typed `string`. See "Request types" below. |
|
|
| ### TypeSpec style constraints |
|
|
| - When adding query decorators (for example `@query`) to a TypeSpec file that |
| does not already use HTTP decorators, import `@typespec/http` and add |
| `using TypeSpec.Http;` in that file; otherwise compilation fails with |
| `Unknown decorator @query`. |
|
|
| ## Commands |
|
|
| | Task | Command | |
| | ----------------------------- | ---------------------------------------------------- | |
| | Build all TypeSpec emitters | `pnpm run build` | |
| | Regenerate SDK from TypeSpec | `pnpm --filter @openmeter/api-spec-aip run generate` | |
| | Run the SDK conformance tests | `pnpm run test:sdk` | |
| | Install / refresh lockfile | `pnpm install --config.confirmModulesPurge=false` | |
|
|
| The emitters are bound by **package name** (`@openmeter/typespec-typescript`, |
| `@openmeter/typespec-go`) in `packages/aip/tspconfig.yaml` (both the `emit:` list |
| and the `options:` keys). The internal lib names in each `src/lib.ts` and their |
| `…:` state keys are separate identities used for diagnostics/state and have no |
| cross-package references. |
|
|
| ## The emitted SDK: conventions the generator must reproduce |
|
|
| The hand-written runtime files and conformance tests under |
| `typespec-typescript/templates/` define the exact shape the generator must |
| reproduce. The tests are the conformance target — the generated SDK is "done" |
| when it passes them. |
|
|
| ### Casing: camelCase public surface, snake_case wire |
| |
| The AIP API is **snake_case on the wire** (TypeSpec, OpenAPI, and the casing lint |
| rule stay snake). The generated JS SDK exposes a **camelCase** public surface — the |
| TS interfaces and zod schemas are camelCase — and a boundary mapper |
| (`src/lib/wire.ts`) translates at the edge: `toWire` (camelCase → snake_case) on |
| request bodies and query objects, `fromWire` (snake_case → camelCase) on responses. |
| |
| camelCase is the **TypeScript-specific** public surface, not a wire change — the |
| wire stays snake_case for every SDK. Other language generators are expected to apply |
| their own idiomatic surface transformation over the same snake_case wire: a Go SDK |
| would use exported UpperCamelCase fields with `json:"snake_case"` tags, a Python SDK |
| would keep snake_case (already idiomatic), etc. Keep casing decisions in the |
| per-language emitter; do not push a language's casing into TypeSpec, OpenAPI, or the |
| wire. |
| |
| The translation is a **deterministic casing rule**, not a per-field map: every wire |
| name round-trips through `toSnakeCase(toCamelCase(name))`, enforced at codegen by a |
| gate (`assertCasingDerivable`) that fails the build for any non-derivable name. The |
| public key is `toCamelCase(resolveEncodedName(...))`, so the wire key the mapper |
| emits is exactly the OpenAPI name. The mapper is **schema-driven**: it walks the zod |
| schema alongside the data so `Record<string, …>` keys that are user data (label |
| names, meter dimension names) are preserved verbatim, while typed field keys |
| (including AIP `filter[field]` names and `sort.by`) are translated. |
| |
| The same gate (`assertCasingDerivable`) also **fails the build for a non-discriminated |
| union with two or more object variants reachable from a request body or success |
| response** — the mapper cannot pick a variant without a discriminator, and does not |
| guess. Use `@discriminated` for such unions (scalar-vs-object unions, and `T | T[]` |
| single-or-batch bodies, are fine — distinguished at runtime by JS type). Discriminated |
| unions dispatch via a memoized literal→variant map keyed on the (camel public / snake |
| wire) discriminator value. |
| |
| ### Dates: `Date` public surface, RFC 3339 wire, requests also take strings |
| |
| Every date-time in the AIP spec is the shared `DateTime` scalar (`utcDateTime` with |
| `@encode(rfc3339)`). The wire stays the RFC 3339 string; the generated TS surface |
| types these fields **`Date`** — in interfaces, query types, and the camelCase zod |
| schemas (`z.date()`) — while the `…Wire` schemas keep `z.string().datetime()`. |
| The boundary mapper converts alongside the casing pass: `toWire` serializes any |
| `Date` instance to `toISOString()` wherever it sits (bodies and query objects alike, |
| and before `…Wire` validation, so `validate` checks the wire form), and `fromWire` |
| revives strings into `Date`s at date-typed schema nodes, including record/array |
| values. A datetime behind a union (`DateTime | null` on `event.time`, |
| enum-or-`DateTime` on subscription `timing`) is revived only when the date variant is |
| the string's sole plausible owner — enum literals, matching string literals, and |
| plain-string variants pass through untouched (fail-open, same policy as unmatched |
| union variants). |
| |
| **Requests additionally accept RFC 3339 strings**: each body/query-bearing |
| `…Request` alias is wrapped in `AcceptDateStrings<T>` (exported from `lib/wire.ts`), |
| a recursive mapped type turning every `Date` into `Date | DateString`, where |
| `DateString = string & Record<never, never>` — assignable from any string but |
| immune to union absorption, so literal siblings of a `Date` (subscription |
| `timing`'s `'immediate' | 'next_billing_cycle'`) keep their autocomplete instead |
| of collapsing into `string`. The widening |
| lives on the request alias only — domain interfaces and `…Query` interfaces stay |
| `Date`, because they also describe responses and are pinned to the schemas by the |
| model conformance guard and the per-op query input guards; widening them (or forking |
| input variants per model) is exactly what this avoids. At runtime a request string |
| passes through the mapper verbatim (never re-parsed or normalized — a non-UTC |
| offset or malformed string reaches the server as-is unless `validate` is on, where |
| the wire schema's UTC `datetime()` check rejects it). |
| |
| ### Response/request mapping drops unknown fields |
| |
| `fromWire`/`toWire` **rename keys and map date values only** (`Date` ↔ RFC 3339 |
| string, see above) — they never call `schema.parse()`, never apply zod defaults, and |
| never coerce any other value. A field not present in the schema shape |
| is **dropped**, so the mapped object exactly matches the typed interface (a |
| server-added field is not in the type and does not survive). This is a deliberate |
| choice for strict typing over forward-compatibility. zod is retained for type |
| derivation (`z.input`/`z.output`), query/path coercion, mapper structure, and the |
| one `baseError.safeParse` in the error path. Error responses bypass the mapper |
| (`toError` reads the raw snake body; `HTTPError.getField` is a raw, untyped escape |
| hatch). |
| |
| ### Optional wire-payload validation (`validate` option) |
| |
| `SDKOptions.validate` (default **off**) turns on schema validation of the actual |
| `snake_case` wire payload: the request body after `toWire` (before sending) and the |
| raw response body before `fromWire`. Validation uses the generated **`…Wire` |
| schemas** in `models/schemas.ts` — every model and per-op body/response is emitted a |
| second time in a `snake_case` "wire" pass (`WireModeContext` in the emitter), keyed by |
| the raw JSON wire name and made `z.strictObject`, so a wrong-shaped or |
| leaked-camelCase wire field is **rejected, not silently stripped**. Open models |
| (record spread, `emitsAsIntersection`, e.g. `baseError`) stay non-strict — strict |
| would defeat the record arm that exists to accept them. The wire pass uses the same |
| emitter walk as the camelCase pass (parameterized by direction + key-casing + |
| strictness + a separate refkey namespace), but it must describe the value **after** |
| transport encoding: date-time values are strings, SDK-coded query parameters use |
| their declared transport type, and defaults are absent. A failure throws |
| `ValidationError`, which `request()` surfaces as `Result.error` (request validation |
| runs _inside_ the `request()` closure so it does not throw synchronously). |
| **Enabling `validate` re-introduces exactly the rejection the default policy |
| avoids**: a strict wire schema rejects additive/unknown server fields and unknown |
| enum values. It is opt-in defense-in-depth, not the default, precisely because the |
| default contract must not break on additive fields. |
|
|
| Models decorated with `@useRef` still need a local TypeSpec shape that matches the |
| referenced OpenAPI schema. The TypeScript and Go emitters walk the local TypeSpec |
| AST; `@useRef` only changes the emitted OpenAPI reference and does not import the |
| referenced schema's requiredness or nullability into language-specific SDKs. |
|
|
| TypeSpec defaults belong only on public schemas. `toWire` reads the public schema to |
| materialize required request defaults before wire validation; `…Wire` schemas must |
| not use Zod `.default(...)`, because the same schema validates responses and a |
| default wrapper would accept a required field the server omitted. The query |
| parameter name `sort` is reserved: it must use `Common.SortQuery` directly (not an |
| alias), enforced by the AIP `sort-query-type` linter rule. Both SDK emitters select |
| the sort codec from that validated HTTP parameter name. The TypeScript emitter must |
| validate the public property schema before encoding and the encoded value against |
| the operation's wire schema afterward. |
| Generated path-parameter schemas are part of the same boundary: in strict mode, |
| map path values to their transport representation, validate the mapped object, |
| then interpolate and URL-encode it. Preserve path binding names during mapping; |
| unlike JSON object keys, they must not be snake-cased. Keep mapping conditional so |
| `validate: false` retains its established runtime behavior. |
|
|
| ### Documented types: generated from TypeSpec, verified against zod |
|
|
| **zod schemas and TypeScript types are separate artifacts with one source.** Both |
| are generated from the same TypeSpec, but neither is derived from the other at the |
| type level: |
|
|
| - `models/schemas.ts` — zod schemas (runtime validation in the error path, query/ |
| path coercion). The runtime artifact. |
| - `models/types.ts` — concrete TypeScript interfaces (the public surface that |
| `.json<T>()` is typed against). **Self-contained: it imports neither `zod` nor |
| `schemas.ts`.** Field types are walked directly from the TypeSpec AST by |
| `tsTypeOf` in `ts-types.ts`, which mirrors the leaf decisions of |
| `zodBaseSchemaParts` (the zod walker) so the two stay type-equivalent. |
| - `models/types.assert.ts` — the inferred types are used **only for verification**: |
| a mutual-assignability guard ties `types.ts` to `schemas.ts` at build time. |
|
|
| Why not infer `types.ts` from `z.output<typeof schema>`? zod strips `.describe()` |
| at the type level, so an inferred type has the shape but no docs; and indexed |
| access (`Meter['name']`) couples the public types to the runtime schemas. Walking |
| TypeSpec directly gives clean concrete types (`id: string`, |
| `aggregation: "sum" | "count" | …`, `labels?: Labels`) with `/** … */` JSDoc from |
| the TypeSpec `@doc`, decoupled from zod. |
|
|
| `tsTypeOf` leaf mapping (must match `zodBaseSchemaParts` or the guard fails): |
|
|
| - scalars → `string` / `number` / `boolean`; **int64/uint64 → `bigint`** (zod uses |
| `z.coerce.bigint()`); everything else numeric → `number`. |
| - **dates/times/durations → `string`** (wire-native; RFC 3339, never `Date`). |
| - enums → inlined literal unions (`"a" | "b"`); never collected as named |
| interfaces. A **named** TypeSpec `union` (`union Price { free: PriceFree, … }`) |
| refs its own `types.ts` alias when reachable (see "Named union aliases" |
| below); an anonymous union expression (`A | B` written inline) still inlines |
| its variants. |
| - named models (incl. named records like `Labels`) → ref the interface; anonymous |
| models → inlined object literal; arrays → `T[]` (parenthesized when `T` is a |
| union: `(A | B)[]`); open records → `Record<string, V>`. |
|
|
| Structural rules the interface emitter follows: |
|
|
| - **Optionality follows OUTPUT**: a defaulted field is optional-in / required-out, |
| so `prop.optional && prop.defaultValue === undefined` decides the `?`. |
| - **No-wire-prop models alias** to their mapped structure |
| (`export type Labels = Record<string, string>`), never an empty permissive |
| `interface {}`. The alias excludes the model from its own ref resolution so it |
| does not become `type Labels = Labels`. |
| - **`extends`** the base interface when the model has a `baseModel`, so inherited |
| fields/docs propagate (`BadRequest extends BaseError`). |
| - **Open records** (`...Record<…>`) get an index signature (`[key: string]: V`). |
| - **Named union aliases.** Every named TypeSpec `union` that is reachable from |
| an operation on an included service gets its own |
| `export type <Name> = <Variant1> | <Variant2> | …` in `types.ts` (`interface-types.ts`, |
| `unionVariantsType` in `ts-types.ts`) — variants resolve through the same |
| `RefName`/`refNameInput` machinery as model properties, so a model-variant is |
| named (`PriceFree`) and an anonymous-object variant inlines. The alias gets the |
| same conformance guard as a model interface, and an `…Input` variant |
| (`computeDivergentUnions` in `input-variants.ts`) only when at least one variant |
| is itself a divergent model (e.g. `WorkflowPaymentSettingsInput`, because |
| `WorkflowPaymentSendInvoiceSettings` has a defaulted field) — a union with no |
| divergent variant (e.g. `WorkflowCollectionAlignment`) has no `…Input` alias. |
| **Reachability gate:** a union can be declared in TypeSpec (and still get a zod |
| schema, since `getAllDataTypes` walks the whole namespace tree) without |
| anything in the actual SDK surface referencing it — `computeReachableUnions` in |
| `emitter.tsx` walks every collected operation's request body, query |
| parameters, and response body (success and error) and only aliases unions it |
| reaches. Every operation counts as a reachability root: `x-internal` and |
| `x-private` operations are emitted under the `client.internal.*` surface, so |
| the unions they reach are aliased too (`Invoice`/`InvoiceLine`/ |
| `UpdateInvoiceRequest` via the `x-private` invoice operations, `Currency` via |
| the `x-internal` currency operations). `PriceUsageBased`, |
| `ULIDOrResourceKey`, and `ULIDOrExternalResourceKey` are declared but never |
| referenced by anything, so they stay zod-only (aliasing them would export a |
| degenerate type like `string | string`); models are never reachability-gated |
| — only the union alias pass is. This is a deliberately narrower policy |
| than models', to avoid exporting unions nothing in the shipped client can |
| ever produce or accept. |
| - **Response wiring picks up named unions too.** Because a named union now |
| resolves through the same `resolveInterface`/`emittedInterfaceNames` path as a |
| model, an operation whose success body is directly a reachable named union |
| (e.g. `get-app` → `App`) wires its `…Response` alias to the union alias instead |
| of falling back to `z.output<typeof schemas...>` — see "Response wiring" below. |
|
|
| **Conformance guard (the oracle).** Every emitted type — both `interface`s **and** |
| the no-wire-prop `type` aliases — is paired with a mutual-assignability check in |
| `models/types.assert.ts` |
| (`[X] extends [z.output<…>] ? [z.output<…>] extends [X] ? true : {__error}`). This |
| is the _only_ place `types.ts` and `schemas.ts` meet: it proves the directly-walked |
| TS type is type-equivalent to the zod schema, turning any divergence (wrong leaf, |
| wrong optionality, header leak, open-record gap) into a **build error**. `tsc` is |
| the oracle. The alias branch must guard too: unlike a former `z.output` alias |
| (tautological), a `tsTypeOf`-walked alias like `LabelsFieldFilter` is an |
| independent claim that can diverge. One blind spot remains by nature: the check is |
| vacuous when either side is `any` — so the output is also grepped for `: any` (the |
| AIP spec uses `unknown`, never `any`, so no field hits it). |
|
|
| **Response wiring.** Per-operation `…Response` aliases point at the documented |
| interface when the success body resolves to a named model **or named union** |
| (e.g. `get-app` → `App`; see "Named union aliases" above). The extracted HTTP body |
| of a list endpoint is **anonymous** (TypeSpec strips the envelope identity during |
| body extraction), so `sdkOperation` falls back to the 2xx **response envelope** |
| (`HttpOperationResponse.type`), whose `@friendlyName` survives — e.g. |
| `PagePaginatedResponse<Meter>` → `MeterPagePaginatedResponse`. This reuses the |
| already-emitted, already-guarded paginated interfaces (no synthesis). Net: ~70/81 |
| responses wired to interfaces, 10 void, 1 text (CSV) — none fall back to |
| `z.output<typeof schemas...>` now that a directly-returned named union resolves |
| to its own alias instead. |
|
|
| Compared to the `zod-to-ts` npm package (which also walks a zod schema to a TS |
| type with JSDoc from `.describe()`): that lib **inlines** nested objects and emits |
| `prop?: T | undefined` for optionals, sourcing docs from `.describe()`. This |
| generator instead **refs** named interfaces (better for a published SDK), emits |
| clean `prop?: T` output-shaped optionality (defaulted fields required, no |
| `| undefined`), and sources docs from TypeSpec `@doc` — so the emitter does not |
| depend on `.describe()` surviving into the runtime schemas. |
|
|
| ### Factoring: what the generator emits, and how often |
|
|
| - **ONCE** (shared runtime in `lib/` + `core.ts`): the base `Client`/transport |
| (one `ky.create`), the `request()` envelope, `Result`/`ok`/`err`/`unwrap`, |
| the curated `RequestOptions`, the encoders (`encodePath`, `toURLSearchParams`, |
| `encodeSort`, `querySerializer`), `toError`, and the `HTTPError` class. |
| - **PER-NAMESPACE** (per resource/tag): one façade class that **composes** a |
| `Client` (holds a reference — it does **not** `extends Client`) plus one |
| memoized lazy getter on the root `OpenMeter`. |
| - **PER-OPERATION** (×~83): one standalone func = path/query/body assembly + |
| `request(() => http(client).<verb>(…).json<R>())`, plus a one-line façade |
| wrapper. The request/response type aliases and per-op `…Query` types live in |
| `models/operations/<ns>.ts` (their guards in `models/operations/<ns>.assert.ts`); |
| `funcs/<ns>.ts` imports `…Request`/`…Response` from there and holds only |
| functions, so the funcs modules stay free of type declarations and guards. |
|
|
| ### Void responses must not call `.json()` |
|
|
| The 10 operations whose `Response` is `void` (`!op.hasResponse` — every |
| `delete*` plus `events.ingest`, which return `204 No Content` / `202 Accepted` |
| with an empty body) terminate with `request(async () => { await http(client).<verb>(…) })`, |
| **not** `.json<R>()`. ky's `.json()` throws `SyntaxError: Unexpected end of JSON input` |
| on an empty body (and explicitly on `204`), so calling it on a successful |
| void response rejects a request that actually succeeded server-side — `ingest` |
| (the product's hot path) and every delete. Awaiting the `ResponsePromise` |
| without parsing still rejects on non-2xx (ky's `throwHttpErrors` default is on), |
| so error propagation is preserved. `funcBody` branches on `op.hasResponse` for |
| this; non-void funcs keep the `.json<R>()` terminal unchanged. |
| `tests/void-responses.spec.ts` is the regression guard: success on empty |
| 202/204, still-rejects on a 500 (status-only fallback), and **full problem+json |
| error fidelity** preserved on a void op (the ky fork populates `e.data` at throw |
| time regardless of `.json()`, so `to-error.ts` recovers `title`/`detail`/`type` |
| identically to non-void ops). Note `baseError.safeParse` requires `instance`, so |
| a problem+json mock without it falls through to the status-only error — include |
| `instance` to exercise the structured branch. The test is **hand-maintained** |
| (it isn't part of the `templates/tests/` set `runtime-templates.ts` emits), so |
| it lives directly in `tests/` and is not re-emitted by `generate` — do not |
| delete it expecting a regen to restore it. |
|
|
| ### Request types: direct TS, input-variant interfaces |
|
|
| Request/response types are direct TS, not `z.input`/`z.output`, and live in |
| `models/operations/<ns>.ts` (re-exported from the barrel under their existing |
| public names). The split mirrors the model types: |
|
|
| - **Response** → the documented output interface (a model or, since named |
| unions are aliased too, a union like `App`), `void`, or `string` (text/CSV). |
| - **Request body** → the body model's (or named union's) interface, or its |
| **`…Input` variant** when the body's input shape diverges from its output. A |
| model diverges iff a defaulted field — anywhere in its reachable subtree — |
| flips from required (output) to optional (input); `computeDivergentModels` (in |
| `input-variants.ts`) is the transitive fixpoint. A union diverges iff at least |
| one of its own variants is a divergent model (`computeDivergentUnions`, same |
| file — shallow, not transitive, since a union carries no properties of its |
| own). `interface-types.ts` emits an `XInput` interface/alias (relaxed |
| optionality, refing child `YInput` variants) for each divergent model or union |
| — e.g. `create-customer-charges`'s body resolves to the union alias |
| `CreateChargeRequest`. ~12 request bodies diverge directly; their closure is |
| ~51 `…Input` interfaces. |
| - **Query** → a per-op `<Base>Query` interface walked from the query parameter |
| leaves in input mode (in `models/operations/<ns>.ts`). |
| - **Path** params are ULIDs → `string`. |
| - **Shared-route JSON body override** → a `@sharedRoute` endpoint declares one |
| operation per content type (e.g. `events.ingest`: a single-event |
| `cloudevents+json`, a batch `cloudevents-batch+json`, and a single-or-batch |
| `application/json` union). `collectHttpOperations` keeps the **first** variant |
| (for its doc/summary/response/202), which is the single-event one — so without |
| intervention the request body would be `EventInput` only. `jsonBodyOverrides` |
| (in `sdk-operations.ts`) maps such an endpoint to its `application/json` body |
| type when that differs from the kept variant's; `request-types.ts` then renders |
| the body with `tsTypeOf(..., 'input')` (→ `EventInput | EventInput[]`) instead |
| of a single named-interface import. Trigger is narrow (only ingest today); the |
| func/facade are unchanged — `json: req` serializes an object or array |
| identically, so widening only the request **type** is sufficient. |
|
|
| Two traps the generator handles: |
|
|
| - **Name collision**: the op request type `<Base>Request` collides with a body |
| model interface of the same name (e.g. `CreateMeterRequest`). The body is |
| imported under a `<Name>Body` alias so the local request declaration owns the |
| name (`import type { CreateMeterRequest as CreateMeterRequestBody }`). |
| - **Coerced leaves**: `z.input` of a `z.coerce.*` leaf is the loose `unknown` |
| (zod 4). The emitted input type deliberately keeps the **strict** leaf |
| (`bigint`/`number`/…) rather than propagate the `unknown` wart. So input |
| variants and `…Query` types are guarded **one-directionally** |
| (`[XInput] extends [z.input<…>]` — "is a valid input"), not bidirectionally. |
| Output interfaces keep the full bidirectional guard. The `…Query` guards live |
| in a sibling `models/operations/<ns>.assert.ts`, matching how model guards live |
| in `types.assert.ts`. |
|
|
| **Selection is unguarded by the shipped guards — verify it separately.** A |
| too-strict request type (refing the output `X` where `XInput` was needed) still |
| satisfies the one-directional `[X] extends [z.input]`, so the shipped guards |
| can't catch picking the wrong variant or `computeDivergentModels` under-marking. |
| Two independent checks close this: |
|
|
| - The 20 conformance tests construct real requests (end-to-end). |
| - **Coverage probe** (the authoritative recipe — do NOT use a regex reachability |
| tracer; it false-matches identifiers inside `.regex(/…/)` and `.describe()`): |
| for every model reachable from a `*Body` schema that has an output interface, |
| assert `[X] extends [z.input<typeof schemas.x>]`. If `X` is too strict (an |
| `XInput` was needed but missing), this fails. Run it as a temporary probe file |
| compiled by tsc; zero failures = every request-reachable model is covered. |
|
|
| ### Dual surface |
|
|
| Every operation exists twice: a standalone func in `funcs/` returning |
| `Result<T>` (tree-shakeable, non-throwing) and a thin method on the namespace |
| façade in `sdk/` that `unwrap`s and throws. Both call the same func. |
|
|
| ### Pagination companions (`<method>All`) |
|
|
| Every page-number or cursor **list** operation gets a companion facade method |
| — `<method>All` alongside `<method>` (e.g. `client.meters.listAll()` next to |
| `client.meters.list()`) — that returns `AsyncIterable<Item>` and fetches |
| following pages lazily as the iterable is consumed. This is purely additive: |
| existing `list()`/`funcs.listX()` signatures and behavior are untouched; only |
| the facade layer (`sdk-files.ts`) gains the extra method. No standalone-func |
| equivalent is emitted — the companion is facade-only, matching the "thin |
| codegen, shared runtime" split below. |
|
|
| **Detection is structural, by AST node identity, not by name.** Both |
| pagination styles are TypeSpec generic response templates in |
| `shared/responses.tsp`: `Shared.PagePaginatedResponse<T>` (`meta: |
| Common.PageMeta`, i.e. `{ page: { number, size, total } }`) and |
| `Shared.CursorPaginatedResponse<T>` (`meta: Common.CursorMeta`, i.e. `{ page: |
| { next?, previous?, first?, last?, size? } }`). `pagination.ts` resolves |
| these two template declarations once per emit |
| (`program.getGlobalNamespaceType().namespaces.get('Shared')`, then |
| `.models.get('PagePaginatedResponse'|'CursorPaginatedResponse')`) and matches |
| each operation's success response envelope (`successResponseEnvelope`, |
| exported from `sdk-operations.ts`) against them by `.node` identity — every |
| instantiation of a TypeSpec generic model shares the declaration's syntax |
| node, so this is exact regardless of the instantiation's own |
| (`@friendlyName`-interpolated) name. `getPagingOperation`/`@pageItems` from |
| `@typespec/compiler` was evaluated and rejected: in this spec `@pageItems` is |
| the only paging decorator actually used, so it can confirm "this operation is |
| paginated" but cannot distinguish the two styles — node identity subsumes it |
| and is the only structural signal that does distinguish them. The item type |
| `T` comes from `envelope.templateMapper.args[0]`, resolved to its documented |
| interface name via the same `resolveInterface` every other response uses — an |
| item type with no documented interface (should never happen for a real list |
| op) gets no companion rather than an untyped one. `Shared` is looked up by |
| name because TypeSpec has no other way to name "the two templates this |
| emitter builds pagination around" (same precedent as `SPLIT_BY_INTERFACE`); |
| the per-operation match itself is never name-based. |
|
|
| **Runtime helpers, not per-operation loop bodies.** The iteration logic lives |
| once in `templates/runtime/paginate.ts` → generated `src/lib/paginate.ts`: |
| `paginatePages` advances `request.page.number`, stopping on a page shorter |
| than the server's own reported `meta.page.size` (including an empty page) or |
| once the running item count reaches `meta.page.total`; `paginateCursor` |
| follows `meta.page.next` — an **opaque cursor token** fed back verbatim as |
| `page.after` (despite `next`/`previous`/`first`/`last` carrying a `format: |
| uri` annotation in the spec — confirmed against the server's own handlers, |
| e.g. `api/v3/handlers/customers/credits/list_transactions.go`: "We |
| intentionally expose opaque cursor tokens instead of URI links" — do not |
| "fix" this by having the helper fetch `next` as a URL). Both helpers accept a |
| generic `fetchPage: (req, options) => Promise<Result<Envelope>>` and unwrap |
| each page internally (facades throw `HTTPError`, matching every other |
| facade method), cap iteration at `MAX_PAGINATION_PAGES` (10,000) and throw |
| `PaginationLimitExceededError` rather than loop forever on a misbehaving |
| server (mirroring `DepthLimitExceededError` in `wire.ts`), and forward the |
| caller's `RequestOptions` (including `signal`) to every page fetch. The |
| generated companion only wires the right helper to the right func, binding |
| `this._client` in a closure — `sdk-files.ts`'s `emitPaginationMethod`; no |
| per-operation loop code is emitted. `PaginationLimitExceededError` is |
| exported from the package root (`indexFile` in `sdk-files.ts`) alongside the |
| other typed runtime errors. |
|
|
| Coverage: `paginate.ts` joins `wire.ts` in the generated package's |
| `vitest.config.ts` coverage `include` at the same 100% |
| statement/function/line threshold (85% branch, matching `wire.ts`) — it has |
| no compile-time guard either, so its behavior must be covered entirely by |
| `tests/paginate.spec.ts` (both helpers: multi-page iteration, early-break |
| fires no extra requests, empty/short/exact-total page termination, absent- |
| next-cursor termination, filter/sort/page-size preserved across pages, |
| `AbortSignal` propagation, and the `PaginationLimitExceededError` cap for |
| both styles — the cap tests drive `paginatePages`/`paginateCursor` directly |
| with an in-memory stub `fetchPage`, not through `fetch-mock`, so 10,000 |
| iterations stay fast). |
|
|
| ### Method/function JSDoc |
|
|
| Every emitted facade method (`sdk/*.ts`) and standalone function (`funcs/*.ts`) |
| carries a JSDoc comment, built by `operationJsDoc` in `sdk-operations.ts`: the |
| `@summary` decorator text (`SdkOperation.summary`, short one-liner) followed by |
| the `@doc` description body (`SdkOperation.doc`, longer prose) when it differs |
| from the summary, and always a final line naming the HTTP route |
| (`POST /openmeter/meters`). The route line is unconditional, so every operation |
| gets a useful IDE hover even the rare one with neither a TypeSpec `@doc` nor a |
| `@summary` — the generator never emits a hollow JSDoc block. Summary and |
| description appear only when the TypeSpec source declares them; the generator |
| never fabricates prose, so a method whose JSDoc lacks a description is a |
| spec-authoring gap (add `@doc` to the operation), not an emitter bug. `*Input` variant |
| interfaces in `models/types.ts` (`interface-types.ts`) inherit the base |
| interface's doc comment verbatim (no doc on the base → none on the variant). |
| The shared `jsdoc()` helper (`utils.tsx`) escapes any literal `*/` in |
| doc/summary text so it cannot prematurely close the emitted comment; do not |
| bypass this helper when adding new doc-emitting call sites. |
|
|
| ### README |
|
|
| `readme.ts` emits the package `README.md` at the package root (`emitter-output-dir` |
| is the package root, so non-`src/` paths land there; `package.json`/`tests/` |
| survive because `writeOutput` only writes listed paths). It is built from the |
| same grouped `SdkOperation[]` as the SDK files, in `groupOperations` insertion |
| order (matching `index.ts`), so the "Available Resources and Operations" table's |
| call paths (`getter` + `nestPath` + `methodName`, e.g. `customers.credits.grants.create`), |
| HTTP routes, and per-op summaries (`$.type.getDoc(op)`, carried on `SdkOperation.doc`) |
| always equal the emitted client. The install/import package name comes from the |
| **required** `package-name` emitter option (`context.options['package-name']`, |
| declared in `lib.ts` with `required: ['package-name']` and set in `aip/tspconfig.yaml`) |
| — never hardcode it in `readme.ts`, and there is no fallback: omitting the option |
| fails the whole compile with an `invalid-schema` diagnostic (verified by removing |
| it from tspconfig), so a missing name can never leak `undefined` into the README. |
| The example client variable is `client` (matching the table prefix `client.<path>`); |
| if you rename it, update both the fence declarations and `operationsTable`'s prefix |
| together. The table-of-contents anchors and the headings |
| are produced by one `slug()` so TOC links never break. Every code fence is |
| self-contained (constructs its own `client`) and typechecks against the real |
| generated types; the `meters.create` payload uses the camelCase public surface |
| (`eventType`, `valueProperty`) and the lowercase aggregation enum (`'sum'`), |
| matching `CreateMeterRequest`. The README is emitted raw (compact markdown |
| tables); the generated `aip-client-javascript` output and the emitter's own |
| `typespec-typescript/src` are **not** prettier-clean on HEAD (`prettier --check .` |
| is already red for both subtrees), so do not pre-align tables in the emitter or |
| single out the README in `.prettierignore`. |
|
|
| ### RequestOptions is curated |
|
|
| `RequestOptions = Pick<Options, 'signal' | 'headers' | 'timeout' | 'retry'>`. |
| Do not widen it to the full ky `Options` — exposing `searchParams`/`json`/`hooks`/ |
| `fetch`/`prefix` per call lets callers clobber transport internals. |
|
|
| ### Errors |
|
|
| `toError` maps ky failures to the domain `HTTPError` (RFC7807 `problem+json`, |
| charset-tolerant Content-Type match; status-only fallback otherwise). `Result`'s |
| error type stays `Error` — the ky fork also throws `TimeoutError`/`NetworkError`, |
| so narrowing to `HTTPError` would be unsound. Callers narrow with |
| `instanceof HTTPError`. A single `HTTPError` class (no per-status hierarchy); |
| field-level validation errors are reachable via `getField('invalid_parameters')`. |
|
|
| ### Server URL templating |
|
|
| `baseUrl` is **required** (no default). It may be a `ServerList` template with |
| `{region}`/`{port}` variables resolved via `encodePath(baseUrl, serverVariables)`, |
| a concrete URL, or a `URL` object. `region` is typed to the enumerated `Regions`. |
| Missing template variables throw (fail-loud, never a literal `{region}` on the |
| wire). The SDK owns URL construction: it pins `baseUrl` (trailing-slash |
| normalized) and `prefix: undefined` **after** spreading user options so a |
| user-supplied `prefix` cannot redirect requests; the auth hook is appended |
| **after** user `beforeRequest` hooks so SDK auth wins. |
|
|
| ### ky is a fork — preserve its option names |
|
|
| The vendored `ky` uses `baseUrl`/`prefix`/`totalTimeout`/`retryOnTimeout` (not |
| mainline ky's `prefixUrl`). The emitter's runtime must use the fork's names; do |
| not "correct" them to mainline ky. |
|
|
| ## Query serialization (verified against the server) |
|
|
| `api/v3/filters/parse.go` is the source of truth for filter encoding: |
|
|
| - deep objects: `page[size]`, `filter[key][eq]` (bracketed) |
| - scalar `filter[key]=v` is shorthand for `filter[key][eq]=v` |
| - array operands (`oeq`/`ocontains`) are **comma-joined into one param**; the |
| server **rejects repeated** query params. Never emit `k=a&k=b`. |
| - `sort` serializes to a plain string `"<field> [asc|desc]"` (single space) on the |
| wire; the SDK accepts a `{by, order}` object and `encodeSort` flattens it. `by` is |
| a **camelCase** field name in the SDK and is `toSnakeCase`-translated to the wire |
| field name (the server validates snake field names; see |
| `api/v3/handlers/.../convert.go`). Every query parameter named `sort` must use |
| `Common.SortQuery` directly; the AIP `sort-query-type` rule protects the |
| name-based codec selection used by both SDK emitters. |
|
|
| ## Tests |
|
|
| The conformance tests (Vitest + `@fetch-mock/vitest`, matching the legacy SDK's |
| stack) live under `typespec-typescript/templates/tests/` and are emitted into |
| the generated SDK by `runtime-templates.ts`. They are the generator's spec: it |
| is "done" when these tests pass against the emitted `aip-client-javascript` |
| output. |
|
|
| `pnpm run test:sdk` roots at `packages/aip-client-javascript` and runs the |
| **generated** tests against the **generated** SDK, so `generate` followed by |
| `test:sdk` is fully self-contained. The generated package is never |
| hand-edited; to change the runtime or tests, edit the files under |
| `typespec-typescript/templates/` and re-run `generate` (see the layout note |
| above). |
|
|
| Vitest strips types without checking them, so the package `typecheck` script |
| runs twice: `tsc --noEmit` (the build tsconfig, `src/` only, keeps declaration |
| diagnostics) and `tsc -p tsconfig.tests.json` (adds `tests/`, no emit, |
| `skipLibCheck` because `@fetch-mock/vitest`'s own d.ts imports the undeclared |
| jest `expect` package). Without the second run, test files are never |
| type-checked by any gate — type-level probes placed in `tests/` prove nothing. |
| `tsconfig.tests.json` is hand-maintained at the package root (like |
| `package.json`/`vitest.config.ts`, it survives regeneration) and is |
| `.npmignore`d. |
|
|
| The meters namespace is behaviorally verified end-to-end by these 19 tests. The |
| other namespaces are generated and type-checked (`tsc` clean across all 13) but |
| not yet behaviorally tested — add a smoke test per namespace if broader runtime |
| coverage is wanted. |
|
|
| ### Emitter-level tests (in-memory compile harness) |
|
|
| `typespec-typescript/test/emit.ts` builds an `EmitterTester` with |
| `createTester` from `@typespec/compiler/testing`: it compiles a fixture |
| TypeSpec program in-memory, runs the emitter through the compiler's real emit |
| pipeline, and returns the emitted files as `outputs: Record<path, content>` |
| (paths relative to the emitter output dir, e.g. `src/sdk/internal.ts`). Use it |
| to pin generator behavior that should be caught before regenerating the real |
| client — `test/internal-surface.test.ts` (the x-private/x-internal routing to |
| the `client.internal.*` surface) is the model. Constraints: |
|
|
| - The tester resolves the emitter by its package name through `package.json` |
| exports, i.e. it runs the **built** `dist/` — the package `test` script runs |
| `alloy build` first for exactly this reason. A stale manual `vitest` run |
| tests stale code. |
| - Fixture specs must author operations via the same `extends` pattern the real |
| spec uses (`interface Endpoints extends Domain.Operations {}` inside a |
| `@service` namespace) or grouping falls into `ungrouped-operation`. |
| Pagination detection requires a top-level `Shared` namespace declaring |
| `PagePaginatedResponse`/`CursorPaginatedResponse`. |
| - The harness is what surfaced the unawaited-`writeOutput` race in |
| `$onEmit`: the tsp CLI keeps the process alive past the pending writes, but |
| in-memory compilation returns immediately, observing a partial output dir. |
| Keep `writeOutput` awaited. |
|
|
| `make -C api/spec test` runs `pnpm --filter @openmeter/typespec-typescript run |
| check` (typecheck + these tests) alongside `test:sdk:coverage`, and the |
| `aip-npm-release` workflow runs that target before publishing. |
|
|
| ## Go SDK emitter |
|
|
| ### Output and wiring |
|
|
| - `typespec-go` emits a single-package Go SDK (`package openmeter`) into |
| `api/v3/client` at the **repo root** — not under `api/spec/packages/`. It is |
| a standalone nested Go module, `github.com/openmeterio/openmeter/api/v3/client`, |
| with its own `go.mod`/`go.sum` (sole dependency: |
| `github.com/oapi-codegen/nullable`). The root `go test ./...` never reaches |
| it; use `make test-go-sdk` at the repo root. |
| - Wiring lives in `packages/aip/tspconfig.yaml` under `@openmeter/typespec-go`: |
| `emitter-output-dir: '{output-dir}/../../../v3/client'` plus the options |
| `module-path`, `package-name: 'openmeter'`, `include-services: ['OpenMeter']`, |
| `strip-name-prefixes`, and `readme-note`. `sdk-version` is deliberately not |
| set there, so day-to-day regeneration stamps the `0.0.0-dev` placeholder; the |
| release process sets it (see Releases below). The full option surface is |
| declared in `typespec-go/src/lib.ts`. |
| - Never hand-edit generated files in `api/v3/client`. Change `typespec-go` |
| emitter components or `src/runtime-templates.ts`, then regenerate. The output |
| cleaner deletes previously generated entries before emission (so file renames |
| cannot leave duplicate declarations) but preserves `*_test.go` files and |
| `testdata/`: hand-written Go wire tests live in `api/v3/client` alongside the |
| generated files and survive regeneration. |
| - Grouping and nesting follow the same TypeSpec source-namespace rules as the |
| TypeScript SDK. Public Go names use PascalCase fields and methods with |
| `json:"snake_case"` tags; there is no runtime casing mapper. |
| - Static Go runtime files live as reviewable TypeScript template strings in |
| `typespec-go/src/runtime-templates.ts`. Do not place Go files, `go.mod`, or |
| `go.sum` under a `typespec-go/runtime/` directory; that makes the emitter |
| source tree look like a standalone Go package. |
| - Every generated `.go` file carries the |
| `// Code generated by @openmeter/typespec-go. DO NOT EDIT.` header **before** |
| the package clause, and generation gofmt-formats the output (a runnable |
| `gofmt` on PATH is a hard requirement of generation). |
|
|
| ### Model projection rules |
|
|
| - Model emission is payload-context aware. The response reachability walk |
| filters properties by `Lifecycle.Read` visibility, so create-only fields do |
| not leak into read models. A model reachable only from requests emits its |
| input projection under its natural name (e.g. `CreateMeterRequest`); a model |
| reachable from both requests and responses emits one declaration when the |
| projections agree, or a read declaration plus an `Input` twin when they |
| diverge (e.g. `Event` and `EventInput`). See `src/projections.ts`. |
| - Structural dedupe collapses visibility-projection twins onto canonical types: |
| a `Create`/`Update`/`Upsert`-prefixed declaration whose rendered shape is |
| structurally identical to another emitted declaration is dropped and every |
| reference is redirected to the canonical name, so read-modify-write flows |
| need no type mapping (`computeStructuralAliases` in `src/projections.ts`). |
| - Anonymous inline models are promoted to deterministic names derived from the |
| enclosing type plus field (`SubscriptionCreate.customer` → |
| `SubscriptionCreateCustomer`); a promoted-name collision is a generation |
| error, resolved with `@friendlyName`. |
| - Named `*FieldFilter` unions (the `StringFieldFilter` family) are |
| runtime-backed: an exact-name map in `src/go-types.tsx` |
| (`runtimeFilterTypesByUnionName`) resolves them to the static runtime filter |
| types (`StringFilter`, `StringExactFilter`, `DateTimeFilter`, `NumericFilter`, |
| `BooleanFilter`), and they are excluded from the model reachability walk so |
| their variants never emit dead declarations. An unmapped `*FieldFilter` union |
| name fails generation instead of guessing. |
| - Formatless TypeSpec `integer` (and `safeint`) map to `int64`; neither fits a |
| narrower sized Go integer by declaration. |
|
|
| ### Wire-shape rules |
|
|
| - Shared-route representations are retained when media type or body shape |
| differs. Events ingest intentionally emits `Events.IngestEvent`, |
| `Events.IngestEvents`, and `Events.IngestEventsJSON`, each with its own |
| request `Content-Type`. Response-only siblings such as meter CSV can reuse the |
| JSON request body while keeping a distinct response `Accept`. |
| - TypeSpec `T | null` emits value-typed `Nullable[T]` backed by |
| `github.com/oapi-codegen/nullable`, not `*Nullable[T]`. Optional nullable |
| fields rely on `omitempty` for the unspecified state while still preserving |
| explicit `null` and concrete values on marshal/unmarshal. |
| - Optional maps and slices in request input models emit as pointers |
| (`*map[...]...`, `*[]...`) so callers can distinguish omission from an explicit |
| empty object/array. Keep this input-only through the projection rules above |
| so response models remain ergonomic value maps/slices. |
| - Go string enum constants stay prefixed as `<Type><Value>` and every generated |
| enum exposes `Valid() bool`; unknown wire values must still decode and |
| re-encode unchanged for forward compatibility. |
| - Union wrappers are raw-preserving: `UnmarshalJSON` and `MarshalJSON` copy the |
| payload with cloned buffers (`append([]byte(nil), ...)`), the zero-value |
| union marshals as JSON `null`, and unknown discriminator values round-trip |
| unchanged. `<Union>From<Variant>` constructors stamp the variant's |
| discriminator field before marshaling, keeping request construction ergonomic |
| without weakening unknown-discriminator round-tripping. |
| - `<List>All` iterator methods are emitted only for list responses with the |
| canonical `{data, meta}` page envelope. A paginated response carrying any |
| extra top-level field gets only the plain method returning the full envelope, |
| because the iterator surfaces page elements alone. |
|
|
| ### Releases |
|
|
| - The `sdk-version` emitter option stamps `const Version` in |
| `api/v3/client/option.go` (also the default `User-Agent` version); it |
| defaults to `0.0.0-dev`. |
| - A release is an `api/v3/client/vX.Y.Z` git tag (`-dev.N`/`-beta.N` prerelease |
| suffixes are also accepted). `.github/workflows/release-go-sdk.yaml` gates |
| the tag: it verifies the stamped `Version` constant matches the tag version, |
| runs `make test-go-sdk`, and creates a GitHub release for visibility. |
| - Release steps: set `sdk-version` under the `@openmeter/typespec-go` options |
| in `packages/aip/tspconfig.yaml`, regenerate (`make gen-api`), commit the |
| stamped output, then push the matching `api/v3/client/vX.Y.Z` tag. |
|
|
| ### Verification |
|
|
| Verify Go emitter changes with (first two from `api/spec`, third from the repo |
| root): |
|
|
| ```bash |
| pnpm --filter @openmeter/typespec-go run check |
| pnpm --filter @openmeter/api-spec-aip run generate # or: make gen-api (repo root) |
| (cd api/v3/client && gofmt -l . && go build ./... && go vet ./... && go test ./...) |
| ``` |
|
|
| `make test-go-sdk` at the repo root is the build/vet/test part of the last |
| line. In CI, the `generators-openapi` job runs the generated-output drift check |
| (`make update-openapi` + clean git diff) and the emitter's `check` script, and |
| the `go-sdk` job runs `make test-go-sdk`. |
|
|