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).
Comment on lines +200 to +213

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the broken Markdown formatting here.

snake*case and \_inside* currently render like malformed emphasis, so this part of the contract reads oddly in the docs. I'd switch them to snake_case and _inside_.

As per path instructions, **/*.md: "Assess the documentation for misspellings, grammatical errors, missing documentation and correctness".

Suggested tweak
-`SDKOptions.validate` (default **off**) turns on schema validation of the actual
-snake*case wire payload: the request body after `toWire` (before sending) and the
+`SDKOptions.validate` (default **off**) turns on schema validation of the actual
+`snake_case` wire payload: the request body after `toWire` (before sending) and the
@@
-`ValidationError`, which `request()` surfaces as `Result.error` (request validation
-runs \_inside* the `request()` closure so it does not throw synchronously).
+`ValidationError`, which `request()` surfaces as `Result.error` (request validation
+runs _inside_ the `request()` closure so it does not throw synchronously).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`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).
`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).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/spec/AGENTS.md` around lines 200 - 213, The Markdown in this contract
section is broken because the asterisks in snake*case and \_inside* are being
interpreted as emphasis instead of literal text. Update the AGENTS.md prose near
SDKOptions.validate and request() to use proper inline code or plain Markdown
emphasis for the intended terms, and verify the surrounding wording still reads
correctly after replacing those malformed fragments.

Source: Path instructions

**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
150 changes: 113 additions & 37 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,64 +26,116 @@ 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,
const path = `openmeter/addons/${(() => {
if (req.addonId === undefined) {
throw new Error('missing path parameter: addonId')
}
return encodeURIComponent(String(req.addonId))
})()}`
return request(() => {
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)
})
})
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(() => http(client).get(path, options).json<GetAddonResponse>())
const path = `openmeter/addons/${(() => {
if (req.addonId === undefined) {
throw new Error('missing path parameter: addonId')
}
return encodeURIComponent(String(req.addonId))
})()}`
return request(() =>
http(client)
.get(path, options)
.json()
.then((data) => {
if (client._options.validate) {
assertValid(schemas.getAddonResponseWire, data)
}
return fromWire(data, schemas.getAddonResponse)
}),
)
}

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

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