Skip to content

Commit f49d9e7

Browse files
authored
fix(sdk): TypeSpec emitters (#4719)
1 parent 62b7ce9 commit f49d9e7

51 files changed

Lines changed: 2224 additions & 332 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/spec/AGENTS.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -253,17 +253,38 @@ second time in a `snake_case` "wire" pass (`WireModeContext` in the emitter), ke
253253
the raw JSON wire name and made `z.strictObject`, so a wrong-shaped or
254254
leaked-camelCase wire field is **rejected, not silently stripped**. Open models
255255
(record spread, `emitsAsIntersection`, e.g. `baseError`) stay non-strict — strict
256-
would defeat the record arm that exists to accept them. Because the wire pass is the
257-
same emitter walk as the camelCase pass (parameterized by key-casing + strictness +
258-
a separate refkey namespace), the two are structurally identical except for casing,
259-
**by construction** — no runtime schema derivation. A failure throws
256+
would defeat the record arm that exists to accept them. The wire pass uses the same
257+
emitter walk as the camelCase pass (parameterized by direction + key-casing +
258+
strictness + a separate refkey namespace), but it must describe the value **after**
259+
transport encoding: date-time values are strings, SDK-coded query parameters use
260+
their declared transport type, and defaults are absent. A failure throws
260261
`ValidationError`, which `request()` surfaces as `Result.error` (request validation
261262
runs _inside_ the `request()` closure so it does not throw synchronously).
262263
**Enabling `validate` re-introduces exactly the rejection the default policy
263264
avoids**: a strict wire schema rejects additive/unknown server fields and unknown
264265
enum values. It is opt-in defense-in-depth, not the default, precisely because the
265266
default contract must not break on additive fields.
266267

268+
Models decorated with `@useRef` still need a local TypeSpec shape that matches the
269+
referenced OpenAPI schema. The TypeScript and Go emitters walk the local TypeSpec
270+
AST; `@useRef` only changes the emitted OpenAPI reference and does not import the
271+
referenced schema's requiredness or nullability into language-specific SDKs.
272+
273+
TypeSpec defaults belong only on public schemas. `toWire` reads the public schema to
274+
materialize required request defaults before wire validation; `…Wire` schemas must
275+
not use Zod `.default(...)`, because the same schema validates responses and a
276+
default wrapper would accept a required field the server omitted. The query
277+
parameter name `sort` is reserved: it must use `Common.SortQuery` directly (not an
278+
alias), enforced by the AIP `sort-query-type` linter rule. Both SDK emitters select
279+
the sort codec from that validated HTTP parameter name. The TypeScript emitter must
280+
validate the public property schema before encoding and the encoded value against
281+
the operation's wire schema afterward.
282+
Generated path-parameter schemas are part of the same boundary: in strict mode,
283+
map path values to their transport representation, validate the mapped object,
284+
then interpolate and URL-encode it. Preserve path binding names during mapping;
285+
unlike JSON object keys, they must not be snake-cased. Keep mapping conditional so
286+
`validate: false` retains its established runtime behavior.
287+
267288
### Documented types: generated from TypeSpec, verified against zod
268289

269290
**zod schemas and TypeScript types are separate artifacts with one source.** Both
@@ -651,7 +672,9 @@ not "correct" them to mainline ky.
651672
wire; the SDK accepts a `{by, order}` object and `encodeSort` flattens it. `by` is
652673
a **camelCase** field name in the SDK and is `toSnakeCase`-translated to the wire
653674
field name (the server validates snake field names; see
654-
`api/v3/handlers/.../convert.go`).
675+
`api/v3/handlers/.../convert.go`). Every query parameter named `sort` must use
676+
`Common.SortQuery` directly; the AIP `sort-query-type` rule protects the
677+
name-based codec selection used by both SDK emitters.
655678

656679
## Tests
657680

api/spec/Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ generate: ## Generate OpenAPI spec
3131
test: ## Run AIP TypeScript SDK and emitter tests
3232
$(call print-target)
3333
pnpm --frozen-lockfile install
34+
pnpm --filter @openmeter/api-spec-aip run test
3435
pnpm run test:sdk:coverage
3536
pnpm --filter @openmeter/typespec-typescript run check
3637

api/spec/packages/aip-client-javascript/src/funcs/addons.ts

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { type Client, http } from '../core.js'
44
import { type Result, type RequestOptions } from '../lib/types.js'
55
import { request } from '../lib/request.js'
66
import { toURLSearchParams, encodeSort } from '../lib/encodings.js'
7-
import { toWire, fromWire, assertValid, toSnakeCase } from '../lib/wire.js'
7+
import {
8+
toWire,
9+
toPathWire,
10+
fromWire,
11+
assertValid,
12+
toSnakeCase,
13+
} from '../lib/wire.js'
814
import * as schemas from '../models/schemas.js'
915
import type {
1016
ListAddonsRequest,
@@ -36,6 +42,9 @@ export function listAddons(
3642
options?: RequestOptions,
3743
): Promise<Result<ListAddonsResponse>> {
3844
return request(() => {
45+
if (client._options.validate && req.sort !== undefined) {
46+
assertValid(schemas.listAddonsQueryParams.shape.sort, req.sort)
47+
}
3948
const query = toWire(
4049
{
4150
page: req.page,
@@ -102,11 +111,20 @@ export function updateAddon(
102111
options?: RequestOptions,
103112
): Promise<Result<UpdateAddonResponse>> {
104113
return request(() => {
114+
const pathParamsInput = {
115+
addonId: req.addonId,
116+
}
117+
const pathParams = client._options.validate
118+
? toPathWire(pathParamsInput, schemas.updateAddonPathParams)
119+
: pathParamsInput
120+
if (client._options.validate) {
121+
assertValid(schemas.updateAddonPathParamsWire, pathParams)
122+
}
105123
const path = `openmeter/addons/${(() => {
106-
if (req.addonId === undefined) {
124+
if (pathParams.addonId === undefined) {
107125
throw new Error('missing path parameter: addonId')
108126
}
109-
return encodeURIComponent(String(req.addonId))
127+
return encodeURIComponent(String(pathParams.addonId))
110128
})()}`
111129
const body = toWire(req.body, schemas.updateAddonBody)
112130
if (client._options.validate) {
@@ -137,11 +155,20 @@ export function getAddon(
137155
options?: RequestOptions,
138156
): Promise<Result<GetAddonResponse>> {
139157
return request(() => {
158+
const pathParamsInput = {
159+
addonId: req.addonId,
160+
}
161+
const pathParams = client._options.validate
162+
? toPathWire(pathParamsInput, schemas.getAddonPathParams)
163+
: pathParamsInput
164+
if (client._options.validate) {
165+
assertValid(schemas.getAddonPathParamsWire, pathParams)
166+
}
140167
const path = `openmeter/addons/${(() => {
141-
if (req.addonId === undefined) {
168+
if (pathParams.addonId === undefined) {
142169
throw new Error('missing path parameter: addonId')
143170
}
144-
return encodeURIComponent(String(req.addonId))
171+
return encodeURIComponent(String(pathParams.addonId))
145172
})()}`
146173
return http(client)
147174
.get(path, options)
@@ -168,11 +195,20 @@ export function deleteAddon(
168195
options?: RequestOptions,
169196
): Promise<Result<DeleteAddonResponse>> {
170197
return request(async () => {
198+
const pathParamsInput = {
199+
addonId: req.addonId,
200+
}
201+
const pathParams = client._options.validate
202+
? toPathWire(pathParamsInput, schemas.deleteAddonPathParams)
203+
: pathParamsInput
204+
if (client._options.validate) {
205+
assertValid(schemas.deleteAddonPathParamsWire, pathParams)
206+
}
171207
const path = `openmeter/addons/${(() => {
172-
if (req.addonId === undefined) {
208+
if (pathParams.addonId === undefined) {
173209
throw new Error('missing path parameter: addonId')
174210
}
175-
return encodeURIComponent(String(req.addonId))
211+
return encodeURIComponent(String(pathParams.addonId))
176212
})()}`
177213
await http(client).delete(path, options)
178214
})
@@ -191,11 +227,20 @@ export function archiveAddon(
191227
options?: RequestOptions,
192228
): Promise<Result<ArchiveAddonResponse>> {
193229
return request(() => {
230+
const pathParamsInput = {
231+
addonId: req.addonId,
232+
}
233+
const pathParams = client._options.validate
234+
? toPathWire(pathParamsInput, schemas.archiveAddonPathParams)
235+
: pathParamsInput
236+
if (client._options.validate) {
237+
assertValid(schemas.archiveAddonPathParamsWire, pathParams)
238+
}
194239
const path = `openmeter/addons/${(() => {
195-
if (req.addonId === undefined) {
240+
if (pathParams.addonId === undefined) {
196241
throw new Error('missing path parameter: addonId')
197242
}
198-
return encodeURIComponent(String(req.addonId))
243+
return encodeURIComponent(String(pathParams.addonId))
199244
})()}/archive`
200245
return http(client)
201246
.post(path, options)
@@ -222,11 +267,20 @@ export function publishAddon(
222267
options?: RequestOptions,
223268
): Promise<Result<PublishAddonResponse>> {
224269
return request(() => {
270+
const pathParamsInput = {
271+
addonId: req.addonId,
272+
}
273+
const pathParams = client._options.validate
274+
? toPathWire(pathParamsInput, schemas.publishAddonPathParams)
275+
: pathParamsInput
276+
if (client._options.validate) {
277+
assertValid(schemas.publishAddonPathParamsWire, pathParams)
278+
}
225279
const path = `openmeter/addons/${(() => {
226-
if (req.addonId === undefined) {
280+
if (pathParams.addonId === undefined) {
227281
throw new Error('missing path parameter: addonId')
228282
}
229-
return encodeURIComponent(String(req.addonId))
283+
return encodeURIComponent(String(pathParams.addonId))
230284
})()}/publish`
231285
return http(client)
232286
.post(path, options)

api/spec/packages/aip-client-javascript/src/funcs/apps.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import { type Client, http } from '../core.js'
44
import { type Result, type RequestOptions } from '../lib/types.js'
55
import { request } from '../lib/request.js'
6-
import { toURLSearchParams, encodeSort } from '../lib/encodings.js'
7-
import { toWire, fromWire, assertValid } from '../lib/wire.js'
6+
import { toURLSearchParams } from '../lib/encodings.js'
7+
import { toWire, toPathWire, fromWire, assertValid } from '../lib/wire.js'
88
import * as schemas from '../models/schemas.js'
99
import type {
1010
ListAppsRequest,
@@ -61,11 +61,20 @@ export function getApp(
6161
options?: RequestOptions,
6262
): Promise<Result<GetAppResponse>> {
6363
return request(() => {
64+
const pathParamsInput = {
65+
appId: req.appId,
66+
}
67+
const pathParams = client._options.validate
68+
? toPathWire(pathParamsInput, schemas.getAppPathParams)
69+
: pathParamsInput
70+
if (client._options.validate) {
71+
assertValid(schemas.getAppPathParamsWire, pathParams)
72+
}
6473
const path = `openmeter/apps/${(() => {
65-
if (req.appId === undefined) {
74+
if (pathParams.appId === undefined) {
6675
throw new Error('missing path parameter: appId')
6776
}
68-
return encodeURIComponent(String(req.appId))
77+
return encodeURIComponent(String(pathParams.appId))
6978
})()}`
7079
return http(client)
7180
.get(path, options)

api/spec/packages/aip-client-javascript/src/funcs/billing.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import { type Client, http } from '../core.js'
44
import { type Result, type RequestOptions } from '../lib/types.js'
55
import { request } from '../lib/request.js'
6-
import { toURLSearchParams, encodeSort } from '../lib/encodings.js'
7-
import { toWire, fromWire, assertValid } from '../lib/wire.js'
6+
import { toURLSearchParams } from '../lib/encodings.js'
7+
import { toWire, toPathWire, fromWire, assertValid } from '../lib/wire.js'
88
import * as schemas from '../models/schemas.js'
99
import type {
1010
ListBillingProfilesRequest,
@@ -101,11 +101,20 @@ export function getBillingProfile(
101101
options?: RequestOptions,
102102
): Promise<Result<GetBillingProfileResponse>> {
103103
return request(() => {
104+
const pathParamsInput = {
105+
id: req.id,
106+
}
107+
const pathParams = client._options.validate
108+
? toPathWire(pathParamsInput, schemas.getBillingProfilePathParams)
109+
: pathParamsInput
110+
if (client._options.validate) {
111+
assertValid(schemas.getBillingProfilePathParamsWire, pathParams)
112+
}
104113
const path = `openmeter/profiles/${(() => {
105-
if (req.id === undefined) {
114+
if (pathParams.id === undefined) {
106115
throw new Error('missing path parameter: id')
107116
}
108-
return encodeURIComponent(String(req.id))
117+
return encodeURIComponent(String(pathParams.id))
109118
})()}`
110119
return http(client)
111120
.get(path, options)
@@ -132,11 +141,20 @@ export function updateBillingProfile(
132141
options?: RequestOptions,
133142
): Promise<Result<UpdateBillingProfileResponse>> {
134143
return request(() => {
144+
const pathParamsInput = {
145+
id: req.id,
146+
}
147+
const pathParams = client._options.validate
148+
? toPathWire(pathParamsInput, schemas.updateBillingProfilePathParams)
149+
: pathParamsInput
150+
if (client._options.validate) {
151+
assertValid(schemas.updateBillingProfilePathParamsWire, pathParams)
152+
}
135153
const path = `openmeter/profiles/${(() => {
136-
if (req.id === undefined) {
154+
if (pathParams.id === undefined) {
137155
throw new Error('missing path parameter: id')
138156
}
139-
return encodeURIComponent(String(req.id))
157+
return encodeURIComponent(String(pathParams.id))
140158
})()}`
141159
const body = toWire(req.body, schemas.updateBillingProfileBody)
142160
if (client._options.validate) {
@@ -173,11 +191,20 @@ export function deleteBillingProfile(
173191
options?: RequestOptions,
174192
): Promise<Result<DeleteBillingProfileResponse>> {
175193
return request(async () => {
194+
const pathParamsInput = {
195+
id: req.id,
196+
}
197+
const pathParams = client._options.validate
198+
? toPathWire(pathParamsInput, schemas.deleteBillingProfilePathParams)
199+
: pathParamsInput
200+
if (client._options.validate) {
201+
assertValid(schemas.deleteBillingProfilePathParamsWire, pathParams)
202+
}
176203
const path = `openmeter/profiles/${(() => {
177-
if (req.id === undefined) {
204+
if (pathParams.id === undefined) {
178205
throw new Error('missing path parameter: id')
179206
}
180-
return encodeURIComponent(String(req.id))
207+
return encodeURIComponent(String(pathParams.id))
181208
})()}`
182209
await http(client).delete(path, options)
183210
})

api/spec/packages/aip-client-javascript/src/funcs/currencies.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { type Client, http } from '../core.js'
44
import { type Result, type RequestOptions } from '../lib/types.js'
55
import { request } from '../lib/request.js'
66
import { toURLSearchParams, encodeSort } from '../lib/encodings.js'
7-
import { toWire, fromWire, assertValid, toSnakeCase } from '../lib/wire.js'
7+
import {
8+
toWire,
9+
toPathWire,
10+
fromWire,
11+
assertValid,
12+
toSnakeCase,
13+
} from '../lib/wire.js'
814
import * as schemas from '../models/schemas.js'
915
import type {
1016
ListCurrenciesRequest,
@@ -30,6 +36,9 @@ export function listCurrencies(
3036
options?: RequestOptions,
3137
): Promise<Result<ListCurrenciesResponse>> {
3238
return request(() => {
39+
if (client._options.validate && req.sort !== undefined) {
40+
assertValid(schemas.listCurrenciesQueryParams.shape.sort, req.sort)
41+
}
3342
const query = toWire(
3443
{
3544
page: req.page,
@@ -98,11 +107,20 @@ export function listCostBases(
98107
options?: RequestOptions,
99108
): Promise<Result<ListCostBasesResponse>> {
100109
return request(() => {
110+
const pathParamsInput = {
111+
currencyId: req.currencyId,
112+
}
113+
const pathParams = client._options.validate
114+
? toPathWire(pathParamsInput, schemas.listCostBasesPathParams)
115+
: pathParamsInput
116+
if (client._options.validate) {
117+
assertValid(schemas.listCostBasesPathParamsWire, pathParams)
118+
}
101119
const path = `openmeter/currencies/custom/${(() => {
102-
if (req.currencyId === undefined) {
120+
if (pathParams.currencyId === undefined) {
103121
throw new Error('missing path parameter: currencyId')
104122
}
105-
return encodeURIComponent(String(req.currencyId))
123+
return encodeURIComponent(String(pathParams.currencyId))
106124
})()}/cost-bases`
107125
const query = toWire(
108126
{
@@ -140,11 +158,20 @@ export function createCostBasis(
140158
options?: RequestOptions,
141159
): Promise<Result<CreateCostBasisResponse>> {
142160
return request(() => {
161+
const pathParamsInput = {
162+
currencyId: req.currencyId,
163+
}
164+
const pathParams = client._options.validate
165+
? toPathWire(pathParamsInput, schemas.createCostBasisPathParams)
166+
: pathParamsInput
167+
if (client._options.validate) {
168+
assertValid(schemas.createCostBasisPathParamsWire, pathParams)
169+
}
143170
const path = `openmeter/currencies/custom/${(() => {
144-
if (req.currencyId === undefined) {
171+
if (pathParams.currencyId === undefined) {
145172
throw new Error('missing path parameter: currencyId')
146173
}
147-
return encodeURIComponent(String(req.currencyId))
174+
return encodeURIComponent(String(pathParams.currencyId))
148175
})()}/cost-bases`
149176
const body = toWire(req.body, schemas.createCostBasisBody)
150177
if (client._options.validate) {

0 commit comments

Comments
 (0)