Skip to content

Commit 375c6b3

Browse files
committed
chore: WIP
1 parent c750c28 commit 375c6b3

13 files changed

Lines changed: 246 additions & 185 deletions

File tree

api/spec/AGENTS.md

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ it.
3232
(schemas, runtime, per-namespace surface, barrel) plus the conformance tests.
3333

3434
### How the emitter is structured
35+
3536
- `emitter.tsx``$onEmit`: emits `schemas.ts` (Alloy components, the original
3637
path), the static runtime files, and the per-namespace surface files, all as
3738
sibling `<ts.SourceFile>` children of one `<Output>`.
@@ -49,6 +50,7 @@ it.
4950
documented call paths and routes always match the emitted client.
5051

5152
### Grouping (reproduce this)
53+
5254
The `OpenMeter` service surfaces every operation through an `*Endpoints`
5355
interface that `extends` the resource's interface in its **source** namespace
5456
(e.g. `OpenMeter.PlansEndpoints extends ProductCatalog.PlanOperations`). The op
@@ -64,6 +66,7 @@ a display string like "Metering Events" → stutter) or by `op.namespace` (alway
6466
`OpenMeter`).
6567

6668
### Nested sub-clients (reproduce this)
69+
6770
The SDK nests sub-clients (`customers.charges.list()`,
6871
`customers.credits.grants.list()`) from the **source namespace chain** below the
6972
group's top namespace. Walking `sourceInterfaces[0].namespace` up to the global
@@ -92,12 +95,13 @@ segments, so `create-credit-grant` under `credits.grants` → `create`.
9295
**OpenAPI invariance is the hard gate** for any `.tsp` change: regenerate and
9396
confirm the `output/definitions/.../*.yaml` hashes are unchanged. Namespace
9497
nesting of operation interfaces is OpenAPI-neutral (paths/tags/operationIds are
95-
explicit); moving a *model* is not. Watch for namespace collisions — nesting
98+
explicit); moving a _model_ is not. Watch for namespace collisions — nesting
9699
`Customers.Billing` shadows the global `Billing` namespace for unqualified refs
97100
in `Customers`-scoped files; alias around it (`Common.BillingRoot`) rather than
98101
renaming.
99102

100103
### Naming rules (reproduce these)
104+
101105
- **func name** = full camelCase operationId: `get-meter``getMeter`.
102106
- **facade method** = operationId with the group's resource noun(s) and the
103107
cross-cutting `metering` qualifier stripped: `get-meter``get`;
@@ -121,12 +125,12 @@ renaming.
121125

122126
## Commands
123127

124-
| Task | Command |
125-
|------|---------|
126-
| Build the emitter | `pnpm --filter @openmeter/typespec-typescript run build` |
127-
| Regenerate SDK from TypeSpec | `pnpm --filter @openmeter/api-spec-aip run generate` |
128-
| Run the SDK conformance tests | `pnpm run test:sdk` |
129-
| Install / refresh lockfile | `pnpm install --config.confirmModulesPurge=false` |
128+
| Task | Command |
129+
| ----------------------------- | -------------------------------------------------------- |
130+
| Build the emitter | `pnpm --filter @openmeter/typespec-typescript run build` |
131+
| Regenerate SDK from TypeSpec | `pnpm --filter @openmeter/api-spec-aip run generate` |
132+
| Run the SDK conformance tests | `pnpm run test:sdk` |
133+
| Install / refresh lockfile | `pnpm install --config.confirmModulesPurge=false` |
130134

131135
The emitter is bound by **package name** (`@openmeter/typespec-typescript`) in
132136
`packages/aip/tspconfig.yaml` (both the `emit:` list and the `options:` key). The
@@ -140,22 +144,26 @@ exact shape the generator must reproduce. Its tests are the conformance target
140144
the generated SDK is "done" when it passes them.
141145

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

148153
### No runtime validation of responses
154+
149155
Responses are typed via ky's `.json<T>()`, never `schema.parse()`. Validating
150156
responses turns additive (non-breaking) API fields into client breakage. zod is
151157
retained only for type derivation (`z.input`/`z.output`) and the one
152158
`baseError.safeParse` in the error path. Request bodies are likewise not parsed
153159
(the server owns defaults and validation).
154160

155161
### Documented types: generated from TypeSpec, verified against zod
162+
156163
**zod schemas and TypeScript types are separate artifacts with one source.** Both
157164
are generated from the same TypeSpec, but neither is derived from the other at the
158165
type level:
166+
159167
- `models/schemas.ts` — zod schemas (runtime validation in the error path, query/
160168
path coercion). The runtime artifact.
161169
- `models/types.ts` — concrete TypeScript interfaces (the public surface that
@@ -174,6 +182,7 @@ TypeSpec directly gives clean concrete types (`id: string`,
174182
the TypeSpec `@doc`, decoupled from zod.
175183

176184
`tsTypeOf` leaf mapping (must match `zodBaseSchemaParts` or the guard fails):
185+
177186
- scalars → `string` / `number` / `boolean`; **int64/uint64 → `bigint`** (zod uses
178187
`z.coerce.bigint()`); everything else numeric → `number`.
179188
- **dates/times/durations → `string`** (wire-native; RFC 3339, never `Date`).
@@ -184,6 +193,7 @@ the TypeSpec `@doc`, decoupled from zod.
184193
union: `(A | B)[]`); open records → `Record<string, V>`.
185194

186195
Structural rules the interface emitter follows:
196+
187197
- **Optionality follows OUTPUT**: a defaulted field is optional-in / required-out,
188198
so `prop.optional && prop.defaultValue === undefined` decides the `?`.
189199
- **No-wire-prop models alias** to their mapped structure
@@ -193,14 +203,14 @@ Structural rules the interface emitter follows:
193203
- **`extends`** the base interface when the model has a `baseModel`, so inherited
194204
fields/docs propagate (`BadRequest extends BaseError`).
195205
- **Open records** (`...Record<…>`) get an index signature (`[key: string]: V`).
196-
- **Unions stay on `z.output`** at the *response* layer (e.g. `GetAppResponse`) — a
206+
- **Unions stay on `z.output`** at the _response_ layer (e.g. `GetAppResponse`) — a
197207
discriminated union has no single interface, so wiring it to one would be wrong.
198208

199209
**Conformance guard (the oracle).** Every emitted type — both `interface`s **and**
200210
the no-wire-prop `type` aliases — is paired with a mutual-assignability check in
201211
`models/types.assert.ts`
202212
(`[X] extends [z.output<…>] ? [z.output<…>] extends [X] ? true : {__error}`). This
203-
is the *only* place `types.ts` and `schemas.ts` meet: it proves the directly-walked
213+
is the _only_ place `types.ts` and `schemas.ts` meet: it proves the directly-walked
204214
TS type is type-equivalent to the zod schema, turning any divergence (wrong leaf,
205215
wrong optionality, header leak, open-record gap) into a **build error**. `tsc` is
206216
the oracle. The alias branch must guard too: unlike a former `z.output` alias
@@ -227,6 +237,7 @@ clean `prop?: T` output-shaped optionality (defaulted fields required, no
227237
depend on `.describe()` surviving into the runtime schemas.
228238

229239
### Factoring: what the generator emits, and how often
240+
230241
- **ONCE** (shared runtime in `lib/` + `core.ts`): the base `Client`/transport
231242
(one `ky.create`), the `request()` envelope, `Result`/`ok`/`err`/`unwrap`,
232243
the curated `RequestOptions`, the encoders (`encodePath`, `toURLSearchParams`,
@@ -242,6 +253,7 @@ depend on `.describe()` surviving into the runtime schemas.
242253
functions, so the funcs modules stay free of type declarations and guards.
243254

244255
### Void responses must not call `.json()`
256+
245257
The 10 operations whose `Response` is `void` (`!op.hasResponse` — every
246258
`delete*` plus `events.ingest`, which return `204 No Content` / `202 Accepted`
247259
with an empty body) terminate with `request(async () => { await http(client).<verb>(…) })`,
@@ -264,9 +276,11 @@ directly in `tests/` and is not re-emitted by `generate` — do not delete it
264276
expecting a regen to restore it.
265277

266278
### Request types: direct TS, input-variant interfaces
279+
267280
Request/response types are direct TS, not `z.input`/`z.output`, and live in
268281
`models/operations/<ns>.ts` (re-exported from the barrel under their existing
269282
public names). The split mirrors the model types:
283+
270284
- **Response** → the documented output interface (or `void`, or the one
271285
`z.output` union `GetAppResponse`).
272286
- **Request body** → the body model's interface, or its **`…Input` variant**
@@ -293,6 +307,7 @@ public names). The split mirrors the model types:
293307
identically, so widening only the request **type** is sufficient.
294308

295309
Two traps the generator handles:
310+
296311
- **Name collision**: the op request type `<Base>Request` collides with a body
297312
model interface of the same name (e.g. `CreateMeterRequest`). The body is
298313
imported under a `<Name>Body` alias so the local request declaration owns the
@@ -311,6 +326,7 @@ too-strict request type (refing the output `X` where `XInput` was needed) still
311326
satisfies the one-directional `[X] extends [z.input]`, so the shipped guards
312327
can't catch picking the wrong variant or `computeDivergentModels` under-marking.
313328
Two independent checks close this:
329+
314330
- The 20 conformance tests construct real requests (end-to-end).
315331
- **Coverage probe** (the authoritative recipe — do NOT use a regex reachability
316332
tracer; it false-matches identifiers inside `.regex(/…/)` and `.describe()`):
@@ -320,11 +336,13 @@ Two independent checks close this:
320336
compiled by tsc; zero failures = every request-reachable model is covered.
321337

322338
### Dual surface
339+
323340
Every operation exists twice: a standalone func in `funcs/` returning
324341
`Result<T>` (tree-shakeable, non-throwing) and a thin method on the namespace
325342
façade in `sdk/` that `unwrap`s and throws. Both call the same func.
326343

327344
### README
345+
328346
`readme.ts` emits the package `README.md` at the package root (`emitter-output-dir`
329347
is the package root, so non-`src/` paths land there; `package.json`/`tests/`
330348
survive because `writeOutput` only writes listed paths). It is built from the
@@ -352,11 +370,13 @@ is already red for both subtrees), so do not pre-align tables in the emitter or
352370
single out the README in `.prettierignore`.
353371

354372
### RequestOptions is curated
373+
355374
`RequestOptions = Pick<Options, 'signal' | 'headers' | 'timeout' | 'retry'>`.
356375
Do not widen it to the full ky `Options` — exposing `searchParams`/`json`/`hooks`/
357376
`fetch`/`prefix` per call lets callers clobber transport internals.
358377

359378
### Errors
379+
360380
`toError` maps ky failures to the domain `HTTPError` (RFC7807 `problem+json`,
361381
charset-tolerant Content-Type match; status-only fallback otherwise). `Result`'s
362382
error type stays `Error` — the ky fork also throws `TimeoutError`/`NetworkError`,
@@ -365,6 +385,7 @@ so narrowing to `HTTPError` would be unsound. Callers narrow with
365385
field-level validation errors are reachable via `getField('invalid_parameters')`.
366386

367387
### Server URL templating
388+
368389
`baseUrl` is **required** (no default). It may be a `ServerList` template with
369390
`{region}`/`{port}` variables resolved via `encodePath(baseUrl, serverVariables)`,
370391
a concrete URL, or a `URL` object. `region` is typed to the enumerated `Regions`.
@@ -375,12 +396,15 @@ user-supplied `prefix` cannot redirect requests; the auth hook is appended
375396
**after** user `beforeRequest` hooks so SDK auth wins.
376397

377398
### ky is a fork — preserve its option names
399+
378400
The vendored `ky` uses `baseUrl`/`prefix`/`totalTimeout`/`retryOnTimeout` (not
379401
mainline ky's `prefixUrl`). The emitter's runtime must use the fork's names; do
380402
not "correct" them to mainline ky.
381403

382404
## Query serialization (verified against the server)
405+
383406
`api/v3/filters/parse.go` is the source of truth for filter encoding:
407+
384408
- deep objects: `page[size]`, `filter[key][eq]` (bracketed)
385409
- scalar `filter[key]=v` is shorthand for `filter[key][eq]=v`
386410
- array operands (`oeq`/`ocontains`) are **comma-joined into one param**; the
@@ -389,6 +413,7 @@ not "correct" them to mainline ky.
389413
`encodeSort` flattens `{by, order}` to that form.
390414

391415
## Tests
416+
392417
The conformance tests (Vitest + `@fetch-mock/vitest`, matching the legacy SDK's
393418
stack) are embedded in `runtime-templates.ts` and emitted into the generated
394419
SDK. They are the generator's spec: it is "done" when these tests pass against

api/spec/packages/aip-client-javascript/src/models/schemas.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@ import { z } from "zod";
33
export const labels = z
44
.record(z.string(), z.string())
55

6-
.describe("Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. Keys must be of length 1-63 characters, and cannot start with \"kong\", \"konnect\", \"mesh\", \"kic\", or \"\_\".");
6+
.describe("Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. Keys must be of length 1-63 characters, and cannot start with \"kong\", \"konnect\", \"mesh\", \"kic\", or \"\\_\".");
77

88
export const currencyCode = z
99
.string()
1010
.min(3)
1111
.max(3)
12-
.regex(/^[A-Z]{3}$/)
12+
.regex(new RegExp("^[A-Z]{3}$"))
1313

1414
.describe("Three-letter [ISO4217](https://www.iso.org/iso-4217-currency-codes.html) currency code. Custom three-letter currency codes are also supported for convenience.");
1515

1616
export const numeric = z
1717
.string()
18-
.regex(/^\-?[0-9]+(\.[0-9]+)?$/)
18+
.regex(new RegExp("^\\-?[0-9]+(\\.[0-9]+)?$"))
1919
.describe("Numeric represents an arbitrary precision number.");
2020

2121
export const cursorPaginationQueryPage = z
@@ -96,7 +96,7 @@ export const stringFieldFilter = z
9696

9797
export const ulid = z
9898
.string()
99-
.regex(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/)
99+
.regex(new RegExp("^[0-7][0-9A-HJKMNP-TV-Z]{25}$"))
100100
.describe("ULID (Universally Unique Lexicographically Sortable Identifier).");
101101

102102
export const dateTime = z
@@ -223,7 +223,7 @@ export const resourceKey = z
223223
.string()
224224
.min(1)
225225
.max(64)
226-
.regex(/^[a-z0-9]+(?:_[a-z0-9]+)*$/)
226+
.regex(new RegExp("^[a-z0-9]+(?:_[a-z0-9]+)*$"))
227227
.describe("A key is a unique string that is used to identify a resource.");
228228

229229
export const meterAggregation = z
@@ -321,7 +321,7 @@ export const countryCode = z
321321
.string()
322322
.min(2)
323323
.max(2)
324-
.regex(/^[A-Z]{2}$/)
324+
.regex(new RegExp("^[A-Z]{2}$"))
325325

326326
.describe("[ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 country code. Custom two-letter country codes are also supported for convenience.");
327327

@@ -435,7 +435,7 @@ export const entitlementType = z
435435
export const createLabels = z
436436
.record(z.string(), z.string())
437437

438-
.describe("Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. Keys must be of length 1-63 characters, and cannot start with \"kong\", \"konnect\", \"mesh\", \"kic\", or \"\_\".");
438+
.describe("Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. Keys must be of length 1-63 characters, and cannot start with \"kong\", \"konnect\", \"mesh\", \"kic\", or \"\\_\".");
439439

440440
export const creditFundingMethod = z
441441
.enum(["none", "invoice", "external"])
@@ -455,7 +455,7 @@ export const taxBehavior = z
455455
export const iso8601Duration = z
456456
.string()
457457

458-
.regex(/^P(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?$/)
458+
.regex(new RegExp("^P(?:\\d+(?:\\.\\d+)?Y)?(?:\\d+(?:\\.\\d+)?M)?(?:\\d+(?:\\.\\d+)?W)?(?:\\d+(?:\\.\\d+)?D)?(?:T(?:\\d+(?:\\.\\d+)?H)?(?:\\d+(?:\\.\\d+)?M)?(?:\\d+(?:\\.\\d+)?S)?)?$"))
459459

460460
.describe("[ISO 8601 Duration](https://docs.digi.com/resources/documentation/digidocs/90001488-13/reference/r_iso_8601_duration_format.htm) string.");
461461

@@ -526,7 +526,7 @@ export const taxConfigStripe = z
526526
.object({
527527
code: z
528528
.string()
529-
.regex(/^txcd_\d{8}$/)
529+
.regex(new RegExp("^txcd_\\d{8}$"))
530530
.describe("Product [tax code](https://docs.stripe.com/tax/tax-codes)."),
531531
})
532532
.describe("The tax config for Stripe.");

api/spec/packages/aip-client-javascript/tests/void-responses.spec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,20 @@ describe('void operations do not parse the response body', () => {
3131
it('resolves ingest of a batch of events (array body)', async () => {
3232
fetchMock.route('*', { status: 202, body: '' })
3333
const result = await funcs.ingestMeteringEvents(client(), [
34-
{ specversion: '1.0', id: 'evt-1', source: 'test', type: 'request', subject: 'customer-1' },
35-
{ specversion: '1.0', id: 'evt-2', source: 'test', type: 'request', subject: 'customer-2' },
34+
{
35+
specversion: '1.0',
36+
id: 'evt-1',
37+
source: 'test',
38+
type: 'request',
39+
subject: 'customer-1',
40+
},
41+
{
42+
specversion: '1.0',
43+
id: 'evt-2',
44+
source: 'test',
45+
type: 'request',
46+
subject: 'customer-2',
47+
},
3648
])
3749
expect(result.ok).toBe(true)
3850
expect(result.value).toBeUndefined()

api/spec/packages/typespec-typescript/scripts/gen-runtime-templates.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,6 @@ export const RUNTIME_TEMPLATES: Record<string, string> = Object.fromEntries(
6464
`
6565

6666
writeFileSync(join(here, '../src/runtime-templates.ts'), out)
67-
console.log(`Wrote src/runtime-templates.ts (${entries.split('\n').length} files)`)
67+
console.log(
68+
`Wrote src/runtime-templates.ts (${entries.split('\n').length} files)`,
69+
)

api/spec/packages/typespec-typescript/src/readme.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,7 @@ function summaryCell(op: SdkOperation): string {
3737
}
3838

3939
function operationsTable(getter: string, ops: SdkOperation[]): string {
40-
const header = [
41-
'| Method | HTTP | Description |',
42-
'| --- | --- | --- |',
43-
]
40+
const header = ['| Method | HTTP | Description |', '| --- | --- | --- |']
4441
const rows = ops.map((op) => {
4542
const call = `\`client.${callPath(getter, op)}\``
4643
const http = `\`${op.verb.toUpperCase()} ${op.path}\``
@@ -113,7 +110,7 @@ function initialization(packageName: string): string {
113110
'',
114111
'const client = new OpenMeter({',
115112
" baseUrl: 'https://openmeter.cloud/api/v3',",
116-
" apiKey: process.env.OPENMETER_API_KEY,",
113+
' apiKey: process.env.OPENMETER_API_KEY,',
117114
'})',
118115
'```',
119116
'',
@@ -126,7 +123,7 @@ function initialization(packageName: string): string {
126123
'const client = new OpenMeter({',
127124
' baseUrl: ServerList[0],',
128125
" serverVariables: { region: 'eu' },",
129-
" apiKey: process.env.OPENMETER_API_KEY,",
126+
' apiKey: process.env.OPENMETER_API_KEY,',
130127
'})',
131128
'```',
132129
'',
@@ -147,7 +144,7 @@ function usage(packageName: string): string {
147144
'',
148145
'const client = new OpenMeter({',
149146
" baseUrl: 'https://openmeter.cloud/api/v3',",
150-
" apiKey: process.env.OPENMETER_API_KEY,",
147+
' apiKey: process.env.OPENMETER_API_KEY,',
151148
'})',
152149
'',
153150
'const meter = await client.meters.create({',
@@ -192,7 +189,7 @@ function errorHandling(packageName: string): string {
192189
'',
193190
'const client = new OpenMeter({',
194191
" baseUrl: 'https://openmeter.cloud/api/v3',",
195-
" apiKey: process.env.OPENMETER_API_KEY,",
192+
' apiKey: process.env.OPENMETER_API_KEY,',
196193
'})',
197194
'',
198195
'try {',
@@ -218,7 +215,7 @@ function standaloneFunctions(packageName: string): string {
218215
'',
219216
'const client = new Client({',
220217
" baseUrl: 'https://openmeter.cloud/api/v3',",
221-
" apiKey: process.env.OPENMETER_API_KEY,",
218+
' apiKey: process.env.OPENMETER_API_KEY,',
222219
'})',
223220
'',
224221
'const result = await funcs.listMeters(client)',

0 commit comments

Comments
 (0)