Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 72 additions & 18 deletions api/spec/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,20 +150,71 @@ The hand-written baseline (kept under a temporary reference folder) defines the
exact shape the generator must reproduce. Its tests are the conformance target —
the generated SDK is "done" when it passes them.

### Casing: wire-native, no transformation

The AIP API is **snake_case end to end** (enforced by the AIP casing lint rule).
Request/response bodies are snake_case in TypeSpec, OpenAPI, and the emitted zod
schemas alike. There is **no snake↔camel transform layer** — the JS object shape
is the wire shape. Do not add `.transform()` or case-converting middleware.

### No runtime validation of responses

Responses are typed via ky's `.json<T>()`, never `schema.parse()`. Validating
responses turns additive (non-breaking) API fields into client breakage. zod is
retained only for type derivation (`z.input`/`z.output`) and the one
`baseError.safeParse` in the error path. Request bodies are likewise not parsed
(the server owns defaults and validation).
### 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.

### Response/request mapping drops unknown fields

`fromWire`/`toWire` **rename keys only** — they never call `schema.parse()`, never
apply zod defaults, and never coerce values. 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. Because the wire pass is the
same emitter walk as the camelCase pass (parameterized by key-casing + strictness +
a separate refkey namespace), the two are structurally identical except for casing,
**by construction** — no runtime schema derivation. 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.

### Documented types: generated from TypeSpec, verified against zod

Expand Down Expand Up @@ -368,8 +419,8 @@ if you rename it, update both the fence declarations and `operationsTable`'s pre
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 wire-native snake_case
(`event_type`, `value_property`) and the lowercase aggregation enum (`'sum'`),
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 .`
Expand Down Expand Up @@ -416,8 +467,11 @@ not "correct" them to mainline ky.
- 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` is a plain string `"<field> [asc|desc]"` (single space), not an object;
`encodeSort` flattens `{by, order}` to that form.
- `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`).

## Tests

Expand Down
4 changes: 3 additions & 1 deletion api/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
"format": "prettier --list-different --find-config-path --write . && pnpm --filter @openmeter/api-spec-aip run format",
"lint": "prettier --check . && pnpm --filter @openmeter/api-spec-legacy run lint && pnpm --filter @openmeter/api-spec-aip run lint",
"lint:fix": "prettier --write .",
"test:sdk": "vitest --run --root packages/aip-client-javascript"
"test:sdk": "vitest --run --root packages/aip-client-javascript",
"test:sdk:coverage": "vitest --run --coverage --root packages/aip-client-javascript"
},
"devDependencies": {
"@fetch-mock/vitest": "0.2.18",
"@typespec/prettier-plugin-typespec": "1.12.0",
"@vitest/coverage-v8": "4.1.8",
"prettier": "3.8.3",
"vitest": "4.1.8"
},
Expand Down
3 changes: 3 additions & 0 deletions api/spec/packages/aip-client-javascript/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
dist/
tsconfig.tsbuildinfo

# Test coverage output
coverage/

# Dependencies
node_modules/

Expand Down
4 changes: 4 additions & 0 deletions api/spec/packages/aip-client-javascript/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ src/
tests/
tsconfig.json
tsconfig.tsbuildinfo
vitest.config.ts
.gitignore

# Test coverage output
coverage/

# Source maps
*.map
4 changes: 2 additions & 2 deletions api/spec/packages/aip-client-javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ const meter = await client.meters.create({
name: 'Tokens',
key: 'tokens',
aggregation: 'sum',
event_type: 'request',
value_property: '$.tokens',
eventType: 'request',
valueProperty: '$.tokens',
})

const meters = await client.meters.list()
Expand Down
152 changes: 114 additions & 38 deletions api/spec/packages/aip-client-javascript/src/funcs/addons.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { type Client, http } from '../core.js'
import { type Result, type RequestOptions } from '../lib/types.js'
import { request } from '../lib/request.js'
import { encodePath, toURLSearchParams, encodeSort } from '../lib/encodings.js'
import { toURLSearchParams, encodeSort } from '../lib/encodings.js'
import { toWire, fromWire, assertValid, toSnakeCase } from '../lib/wire.js'
import * as schemas from '../models/schemas.js'
import type {
ListAddonsRequest,
ListAddonsResponse,
Expand All @@ -24,65 +26,117 @@ export function listAddons(
req: ListAddonsRequest = {},
options?: RequestOptions,
): Promise<Result<ListAddonsResponse>> {
const searchParams = toURLSearchParams({
page: req.page,
sort: encodeSort(req.sort),
filter: req.filter,
})
return request(() =>
http(client)
return request(() => {
const query = toWire(
{
page: req.page,
sort: encodeSort(req.sort, toSnakeCase),
filter: req.filter,
},
schemas.listAddonsQueryParams,
)
if (client._options.validate) {
assertValid(schemas.listAddonsQueryParamsWire, query)
}
const searchParams = toURLSearchParams(query)
return http(client)
.get('openmeter/addons', { ...options, searchParams })
.json<ListAddonsResponse>(),
)
.json()
.then((data) => {
if (client._options.validate) {
assertValid(schemas.listAddonsResponseWire, data)
}
return fromWire(data, schemas.listAddonsResponse)
})
})
}

export function createAddon(
client: Client,
req: CreateAddonRequest,
options?: RequestOptions,
): Promise<Result<CreateAddonResponse>> {
return request(() =>
http(client)
.post('openmeter/addons', { ...options, json: req })
.json<CreateAddonResponse>(),
)
return request(() => {
const body = toWire(req, schemas.createAddonBody)
if (client._options.validate) {
assertValid(schemas.createAddonBodyWire, body)
}
return http(client)
.post('openmeter/addons', { ...options, json: body })
.json()
.then((data) => {
if (client._options.validate) {
assertValid(schemas.createAddonResponseWire, data)
}
return fromWire(data, schemas.createAddonResponse)
})
})
}

export function updateAddon(
client: Client,
req: UpdateAddonRequest,
options?: RequestOptions,
): Promise<Result<UpdateAddonResponse>> {
const path = encodePath('openmeter/addons/{addonId}', {
addonId: req.addonId,
return request(() => {
const path = `openmeter/addons/${(() => {
if (req.addonId === undefined) {
throw new Error('missing path parameter: addonId')
}
return encodeURIComponent(String(req.addonId))
})()}`
const body = toWire(req.body, schemas.updateAddonBody)
if (client._options.validate) {
assertValid(schemas.updateAddonBodyWire, body)
}
return http(client)
.put(path, { ...options, json: body })
.json()
.then((data) => {
if (client._options.validate) {
assertValid(schemas.updateAddonResponseWire, data)
}
return fromWire(data, schemas.updateAddonResponse)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
return request(() =>
http(client)
.put(path, { ...options, json: req.body })
.json<UpdateAddonResponse>(),
)
}

export function getAddon(
client: Client,
req: GetAddonRequest,
options?: RequestOptions,
): Promise<Result<GetAddonResponse>> {
const path = encodePath('openmeter/addons/{addonId}', {
addonId: req.addonId,
return request(() => {
const path = `openmeter/addons/${(() => {
if (req.addonId === undefined) {
throw new Error('missing path parameter: addonId')
}
return encodeURIComponent(String(req.addonId))
})()}`
return http(client)
.get(path, options)
.json()
.then((data) => {
if (client._options.validate) {
assertValid(schemas.getAddonResponseWire, data)
}
return fromWire(data, schemas.getAddonResponse)
})
})
return request(() => http(client).get(path, options).json<GetAddonResponse>())
}

export function deleteAddon(
client: Client,
req: DeleteAddonRequest,
options?: RequestOptions,
): Promise<Result<DeleteAddonResponse>> {
const path = encodePath('openmeter/addons/{addonId}', {
addonId: req.addonId,
})
return request(async () => {
const path = `openmeter/addons/${(() => {
if (req.addonId === undefined) {
throw new Error('missing path parameter: addonId')
}
return encodeURIComponent(String(req.addonId))
})()}`
await http(client).delete(path, options)
})
}
Expand All @@ -92,23 +146,45 @@ export function archiveAddon(
req: ArchiveAddonRequest,
options?: RequestOptions,
): Promise<Result<ArchiveAddonResponse>> {
const path = encodePath('openmeter/addons/{addonId}/archive', {
addonId: req.addonId,
return request(() => {
const path = `openmeter/addons/${(() => {
if (req.addonId === undefined) {
throw new Error('missing path parameter: addonId')
}
return encodeURIComponent(String(req.addonId))
})()}/archive`
return http(client)
.post(path, options)
.json()
.then((data) => {
if (client._options.validate) {
assertValid(schemas.archiveAddonResponseWire, data)
}
return fromWire(data, schemas.archiveAddonResponse)
})
})
return request(() =>
http(client).post(path, options).json<ArchiveAddonResponse>(),
)
}

export function publishAddon(
client: Client,
req: PublishAddonRequest,
options?: RequestOptions,
): Promise<Result<PublishAddonResponse>> {
const path = encodePath('openmeter/addons/{addonId}/publish', {
addonId: req.addonId,
return request(() => {
const path = `openmeter/addons/${(() => {
if (req.addonId === undefined) {
throw new Error('missing path parameter: addonId')
}
return encodeURIComponent(String(req.addonId))
})()}/publish`
return http(client)
.post(path, options)
.json()
.then((data) => {
if (client._options.validate) {
assertValid(schemas.publishAddonResponseWire, data)
}
return fromWire(data, schemas.publishAddonResponse)
})
})
return request(() =>
http(client).post(path, options).json<PublishAddonResponse>(),
)
}
Loading
Loading