@workos/oagen-emitters 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint-pr-title.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-C408Wh-o.mjs → plugin-eCuvoL1T.mjs} +3914 -2121
- package/dist/plugin-eCuvoL1T.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/package.json +10 -10
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +345 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +540 -351
- package/src/node/naming.ts +119 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/resources.ts +455 -46
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +108 -83
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/src/rust/fixtures.ts +87 -1
- package/src/rust/models.ts +17 -2
- package/src/rust/resources.ts +697 -62
- package/src/rust/tests.ts +540 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +376 -2036
- package/test/node/tests.test.ts +119 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/test/rust/fixtures.test.ts +227 -0
- package/test/rust/models.test.ts +38 -0
- package/test/rust/resources.test.ts +505 -2
- package/test/rust/tests.test.ts +504 -0
- package/dist/plugin-C408Wh-o.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
package/src/node/utils.ts
CHANGED
|
@@ -20,7 +20,6 @@ import { assignModelsToServices } from '@workos/oagen';
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Compute a relative import path between two files within the generated SDK.
|
|
23
|
-
* Strips .ts extension from the result.
|
|
24
23
|
*/
|
|
25
24
|
export function relativeImport(fromFile: string, toFile: string): string {
|
|
26
25
|
const fromDir = fromFile.split('/').slice(0, -1);
|
|
@@ -44,8 +43,6 @@ export function relativeImport(fromFile: string, toFile: string): string {
|
|
|
44
43
|
|
|
45
44
|
/**
|
|
46
45
|
* Render a JSDoc comment block from a description string.
|
|
47
|
-
* Handles multiline descriptions by prefixing each line with ` * `.
|
|
48
|
-
* Returns the lines with the given indent (default 0 spaces).
|
|
49
46
|
*/
|
|
50
47
|
export function docComment(description: string, indent = 0): string[] {
|
|
51
48
|
const pad = ' '.repeat(indent);
|
|
@@ -62,11 +59,7 @@ export function docComment(description: string, indent = 0): string[] {
|
|
|
62
59
|
}
|
|
63
60
|
|
|
64
61
|
/**
|
|
65
|
-
* Build a map from model name
|
|
66
|
-
* E.g., Profile<CustomAttributesType = Record<string, unknown>>
|
|
67
|
-
* → Map { 'Profile' → '<Record<string, unknown>>' }
|
|
68
|
-
*
|
|
69
|
-
* Non-generic models are not included in the map.
|
|
62
|
+
* Build a map from model name -> default type args string for generic models.
|
|
70
63
|
*/
|
|
71
64
|
export function buildGenericModelDefaults(models: Model[]): Map<string, string> {
|
|
72
65
|
const result = new Map<string, string>();
|
|
@@ -80,12 +73,8 @@ export function buildGenericModelDefaults(models: Model[]): Map<string, string>
|
|
|
80
73
|
|
|
81
74
|
/**
|
|
82
75
|
* Remove unused imports from generated source code.
|
|
83
|
-
* Scans the non-import body for each imported identifier and drops
|
|
84
|
-
* individual names that are never referenced. Removes entire import
|
|
85
|
-
* statements when no names are used.
|
|
86
76
|
*/
|
|
87
77
|
export function pruneUnusedImports(lines: string[]): string[] {
|
|
88
|
-
// Split lines into imports and body
|
|
89
78
|
const importLines: string[] = [];
|
|
90
79
|
const bodyLines: string[] = [];
|
|
91
80
|
let inBody = false;
|
|
@@ -106,10 +95,8 @@ export function pruneUnusedImports(lines: string[]): string[] {
|
|
|
106
95
|
kept.push(line);
|
|
107
96
|
continue;
|
|
108
97
|
}
|
|
109
|
-
// Extract imported names from the import statement
|
|
110
98
|
const match = line.match(/\{([^}]+)\}/);
|
|
111
99
|
if (!match) {
|
|
112
|
-
// Non-destructured import (e.g., import X from '...') — keep
|
|
113
100
|
kept.push(line);
|
|
114
101
|
continue;
|
|
115
102
|
}
|
|
@@ -117,20 +104,14 @@ export function pruneUnusedImports(lines: string[]): string[] {
|
|
|
117
104
|
.split(',')
|
|
118
105
|
.map((n) => n.trim())
|
|
119
106
|
.filter(Boolean);
|
|
120
|
-
// Filter to only names that appear in the body
|
|
121
107
|
const usedNames = names.filter((name) => {
|
|
122
108
|
const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
123
109
|
return re.test(body);
|
|
124
110
|
});
|
|
125
|
-
if (usedNames.length === 0)
|
|
126
|
-
// No names used — drop entire import
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
111
|
+
if (usedNames.length === 0) continue;
|
|
129
112
|
if (usedNames.length === names.length) {
|
|
130
|
-
// All names used — keep original line
|
|
131
113
|
kept.push(line);
|
|
132
114
|
} else {
|
|
133
|
-
// Some names unused — reconstruct import with only used names
|
|
134
115
|
const isTypeImport = line.startsWith('import type');
|
|
135
116
|
const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
|
|
136
117
|
if (fromMatch) {
|
|
@@ -145,7 +126,7 @@ export function pruneUnusedImports(lines: string[]): string[] {
|
|
|
145
126
|
return [...kept, ...bodyLines];
|
|
146
127
|
}
|
|
147
128
|
|
|
148
|
-
/** Built-in TypeScript types that are always available
|
|
129
|
+
/** Built-in TypeScript types that are always available. */
|
|
149
130
|
export const TS_BUILTINS = new Set([
|
|
150
131
|
'Record',
|
|
151
132
|
'Promise',
|
|
@@ -167,9 +148,7 @@ export const TS_BUILTINS = new Set([
|
|
|
167
148
|
]);
|
|
168
149
|
|
|
169
150
|
/**
|
|
170
|
-
* Detect whether the existing SDK uses string
|
|
171
|
-
* date-time fields. Checks if any baseline interface has a date-time IR field
|
|
172
|
-
* typed as plain `string` (not `Date`).
|
|
151
|
+
* Detect whether the existing SDK uses string representation for date-time fields.
|
|
173
152
|
*/
|
|
174
153
|
export function detectStringDateConvention(models: Model[], ctx: EmitterContext): boolean {
|
|
175
154
|
if (!ctx.apiSurface?.interfaces) return false;
|
|
@@ -190,8 +169,6 @@ export function detectStringDateConvention(models: Model[], ctx: EmitterContext)
|
|
|
190
169
|
|
|
191
170
|
/**
|
|
192
171
|
* Build a comprehensive set of all known type names from the IR and baseline.
|
|
193
|
-
* Used to identify type parameters by elimination — any PascalCase name not in
|
|
194
|
-
* this set is likely a generic type parameter.
|
|
195
172
|
*/
|
|
196
173
|
export function buildKnownTypeNames(models: Model[], ctx: EmitterContext): Set<string> {
|
|
197
174
|
const knownNames = new Set<string>();
|
|
@@ -211,8 +188,11 @@ export function buildKnownTypeNames(models: Model[], ctx: EmitterContext): Set<s
|
|
|
211
188
|
|
|
212
189
|
/**
|
|
213
190
|
* Create a service directory resolver bundle.
|
|
214
|
-
*
|
|
215
|
-
*
|
|
191
|
+
*
|
|
192
|
+
* When `ctx.apiSurface` is populated, the baseline `sourceFile` of an
|
|
193
|
+
* existing interface wins over the IR-derived first-reference assignment.
|
|
194
|
+
* This keeps generated imports pointing at the existing live SDK location
|
|
195
|
+
* instead of duplicating a model into a different service directory.
|
|
216
196
|
*/
|
|
217
197
|
export function createServiceDirResolver(
|
|
218
198
|
models: Model[],
|
|
@@ -225,19 +205,68 @@ export function createServiceDirResolver(
|
|
|
225
205
|
} {
|
|
226
206
|
const modelToService = assignModelsToServices(models, services, ctx.modelHints);
|
|
227
207
|
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
228
|
-
|
|
229
|
-
|
|
208
|
+
|
|
209
|
+
// Per-name → directory override, harvested from the live SDK surface.
|
|
210
|
+
// Stored under a sentinel "" service key in modelToService so resolveDir
|
|
211
|
+
// can dispatch on it without a separate map. Implementation: model name ->
|
|
212
|
+
// baseline directory string (e.g., "user-management"). The override map is
|
|
213
|
+
// attached by tagging the model name with a directory prefix that bypasses
|
|
214
|
+
// the IR-service lookup. Concretely we keep a side map.
|
|
215
|
+
const baselineDirByModel = new Map<string, string>();
|
|
216
|
+
const recordSource = (name: string, info: { sourceFile?: string } | undefined) => {
|
|
217
|
+
const sourceFile = info?.sourceFile;
|
|
218
|
+
if (!sourceFile) return;
|
|
219
|
+
const m = sourceFile.match(/^src\/([^/]+)\//);
|
|
220
|
+
if (!m) return;
|
|
221
|
+
baselineDirByModel.set(name, m[1]);
|
|
222
|
+
};
|
|
223
|
+
// Both interfaces and type aliases can shadow IR model names — e.g.
|
|
224
|
+
// `type Role = EnvironmentRole | OrganizationRole;` is the live SDK's
|
|
225
|
+
// canonical Role definition even though the IR represents Role as a model.
|
|
226
|
+
for (const [name, info] of Object.entries(ctx.apiSurface?.interfaces ?? {})) {
|
|
227
|
+
recordSource(name, info as { sourceFile?: string });
|
|
228
|
+
}
|
|
229
|
+
for (const [name, info] of Object.entries(ctx.apiSurface?.typeAliases ?? {})) {
|
|
230
|
+
if (!baselineDirByModel.has(name)) {
|
|
231
|
+
recordSource(name, info as { sourceFile?: string });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Override modelToService for any IR model that has a baseline sourceFile.
|
|
236
|
+
// We invent a synthetic IR-service key that maps directly to the baseline
|
|
237
|
+
// directory via serviceNameMap so resolveDir returns the correct dir.
|
|
238
|
+
for (const [modelName] of modelToService) {
|
|
239
|
+
const dir = baselineDirByModel.get(modelName);
|
|
240
|
+
if (!dir) continue;
|
|
241
|
+
const synthetic = `__baseline_dir__:${dir}`;
|
|
242
|
+
modelToService.set(modelName, synthetic);
|
|
243
|
+
if (!serviceNameMap.has(synthetic)) {
|
|
244
|
+
// resolveServiceDir is identity on already-kebab-case names, so storing
|
|
245
|
+
// the dir directly keeps round-tripping through the resolver clean.
|
|
246
|
+
serviceNameMap.set(synthetic, dir);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const resolveDir = (irService: string | undefined) => {
|
|
251
|
+
if (!irService) return 'common';
|
|
252
|
+
if (irService.startsWith('__baseline_dir__:')) return irService.slice('__baseline_dir__:'.length);
|
|
253
|
+
return resolveServiceDir(serviceNameMap.get(irService) ?? irService);
|
|
254
|
+
};
|
|
230
255
|
return { modelToService, serviceNameMap, resolveDir };
|
|
231
256
|
}
|
|
232
257
|
|
|
233
258
|
/**
|
|
234
|
-
* Check if
|
|
235
|
-
*
|
|
259
|
+
* Check if baseline interface fields appear to contain generic type parameters.
|
|
260
|
+
*
|
|
261
|
+
* Heuristic: strip string literals first (so `'GoogleSAML'` is not mistaken
|
|
262
|
+
* for a type name), then look for any PascalCase token that isn't a known
|
|
263
|
+
* type — those indicate an unbound generic parameter like `TCustomAttributes`.
|
|
236
264
|
*/
|
|
237
265
|
export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: Set<string>): boolean {
|
|
238
266
|
for (const [, bf] of Object.entries(fields)) {
|
|
239
|
-
const
|
|
240
|
-
const
|
|
267
|
+
const rawType = (bf as { type: string }).type;
|
|
268
|
+
const stripped = rawType.replace(/'[^']*'/g, '').replace(/"[^"]*"/g, '');
|
|
269
|
+
const typeNames = stripped.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
|
|
241
270
|
if (!typeNames) continue;
|
|
242
271
|
for (const tn of typeNames) {
|
|
243
272
|
if (TS_BUILTINS.has(tn)) continue;
|
|
@@ -248,13 +277,8 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
|
|
|
248
277
|
return false;
|
|
249
278
|
}
|
|
250
279
|
|
|
251
|
-
// Re-export shared model detection utilities
|
|
252
280
|
export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
|
|
253
281
|
|
|
254
|
-
/**
|
|
255
|
-
* Compute a structural fingerprint for a model based on its fields.
|
|
256
|
-
* Two models with identical fingerprints are structurally equivalent.
|
|
257
|
-
*/
|
|
258
282
|
function modelFingerprint(model: Model): string {
|
|
259
283
|
const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
|
|
260
284
|
return fields.join('|');
|
|
@@ -262,12 +286,6 @@ function modelFingerprint(model: Model): string {
|
|
|
262
286
|
|
|
263
287
|
/**
|
|
264
288
|
* Find structurally identical models and build a deduplication map.
|
|
265
|
-
* Also deduplicates models that resolve to the same interface name across
|
|
266
|
-
* services — when a `$ref` schema is used by multiple tags, the IR may
|
|
267
|
-
* produce per-tag copies that diverge slightly. The version with the most
|
|
268
|
-
* fields is chosen as canonical.
|
|
269
|
-
*
|
|
270
|
-
* Returns a Map from duplicate model name → canonical model name.
|
|
271
289
|
*/
|
|
272
290
|
export function buildDeduplicationMap(
|
|
273
291
|
models: Model[],
|
|
@@ -276,19 +294,15 @@ export function buildDeduplicationMap(
|
|
|
276
294
|
): Map<string, string> {
|
|
277
295
|
const dedup = new Map<string, string>();
|
|
278
296
|
|
|
279
|
-
// Pass 1: structural fingerprint dedup
|
|
280
|
-
// When a reachability set is provided, prefer reachable models as canonicals
|
|
281
|
-
// so that aliases always point to models that will actually be generated.
|
|
297
|
+
// Pass 1: structural fingerprint dedup
|
|
282
298
|
const fingerprints = new Map<string, string>();
|
|
283
299
|
for (const model of models) {
|
|
284
300
|
if (model.fields.length === 0) continue;
|
|
285
301
|
const fp = modelFingerprint(model);
|
|
286
302
|
const existing = fingerprints.get(fp);
|
|
287
303
|
if (existing) {
|
|
288
|
-
// If the existing canonical is unreachable but this model is reachable,
|
|
289
|
-
// swap: make this model the canonical and demote the old one to alias.
|
|
290
304
|
if (reachable && !reachable.has(existing) && reachable.has(model.name)) {
|
|
291
|
-
dedup.delete(existing);
|
|
305
|
+
dedup.delete(existing);
|
|
292
306
|
dedup.set(existing, model.name);
|
|
293
307
|
fingerprints.set(fp, model.name);
|
|
294
308
|
} else {
|
|
@@ -299,15 +313,12 @@ export function buildDeduplicationMap(
|
|
|
299
313
|
}
|
|
300
314
|
}
|
|
301
315
|
|
|
302
|
-
// Pass 2: name-based dedup
|
|
303
|
-
// name across services. Only applies when context with name resolution is
|
|
304
|
-
// available. Picks the model with the most fields as canonical, preferring
|
|
305
|
-
// reachable models when a reachability set is provided.
|
|
316
|
+
// Pass 2: name-based dedup
|
|
306
317
|
if (ctx) {
|
|
307
318
|
const byDomainName = new Map<string, Model[]>();
|
|
308
319
|
for (const model of models) {
|
|
309
320
|
if (model.fields.length === 0) continue;
|
|
310
|
-
if (dedup.has(model.name)) continue;
|
|
321
|
+
if (dedup.has(model.name)) continue;
|
|
311
322
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
312
323
|
const group = byDomainName.get(domainName);
|
|
313
324
|
if (group) {
|
|
@@ -318,7 +329,6 @@ export function buildDeduplicationMap(
|
|
|
318
329
|
}
|
|
319
330
|
for (const [, group] of byDomainName) {
|
|
320
331
|
if (group.length < 2) continue;
|
|
321
|
-
// Choose canonical: prefer reachable, then most fields, then alphabetically
|
|
322
332
|
group.sort((a, b) => {
|
|
323
333
|
if (reachable) {
|
|
324
334
|
const aReach = reachable.has(a.name) ? 0 : 1;
|
|
@@ -340,23 +350,8 @@ export function buildDeduplicationMap(
|
|
|
340
350
|
/**
|
|
341
351
|
* Check whether a service's endpoints are already fully covered by existing
|
|
342
352
|
* hand-written service classes.
|
|
343
|
-
*
|
|
344
|
-
* A service is considered "covered" when:
|
|
345
|
-
* 1. **Every** operation in it appears in `overlayLookup.methodByOperation`
|
|
346
|
-
* 2. The overlay maps those operations to a class that exists in the baseline
|
|
347
|
-
* `apiSurface` (confirming the hand-written class is actually present)
|
|
348
|
-
*
|
|
349
|
-
* Services with zero operations are never considered covered (nothing to
|
|
350
|
-
* deduplicate). When no `apiSurface` is available, the overlay alone is
|
|
351
|
-
* used as the coverage signal (the overlay is only built from existing code).
|
|
352
|
-
*
|
|
353
|
-
* This prevents the emitter from generating resource classes like `Connections`
|
|
354
|
-
* that would duplicate hand-written modules like `SSO` for the same API
|
|
355
|
-
* endpoints (e.g., `GET /connections`).
|
|
356
353
|
*/
|
|
357
354
|
export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
|
|
358
|
-
// A service is "covered" when its mountOn differs from its own name,
|
|
359
|
-
// meaning its operations are mounted on a different (existing) class.
|
|
360
355
|
const mountTarget = getMountTarget(service, ctx);
|
|
361
356
|
if (mountTarget !== toPascalCase(service.name)) return true;
|
|
362
357
|
|
|
@@ -364,15 +359,10 @@ export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext
|
|
|
364
359
|
if (!overlay || overlay.size === 0) return false;
|
|
365
360
|
if (service.operations.length === 0) return false;
|
|
366
361
|
|
|
367
|
-
// Collect the set of existing class names from the baseline surface.
|
|
368
|
-
// When no apiSurface is available, the overlay alone cannot confirm that
|
|
369
|
-
// a hand-written class exists — it may only carry naming hints.
|
|
370
362
|
const baselineClasses = ctx.apiSurface?.classes;
|
|
371
363
|
if (!baselineClasses) return false;
|
|
372
364
|
const existingClassNames = new Set(Object.keys(baselineClasses));
|
|
373
365
|
|
|
374
|
-
// Check that every operation is in the overlay AND the overlay's target class
|
|
375
|
-
// exists in the baseline.
|
|
376
366
|
return service.operations.every((op: Operation) => {
|
|
377
367
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
378
368
|
const match = overlay.get(httpKey);
|
|
@@ -383,20 +373,16 @@ export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext
|
|
|
383
373
|
|
|
384
374
|
/**
|
|
385
375
|
* Check whether a fully-covered service has operations whose overlay-mapped
|
|
386
|
-
* methods are missing from the baseline class.
|
|
387
|
-
* one operation maps to a method name that the baseline class does not have,
|
|
388
|
-
* meaning the merger needs to add new methods (skipIfExists must be removed).
|
|
376
|
+
* methods are missing from the baseline class.
|
|
389
377
|
*/
|
|
390
378
|
export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterContext): boolean {
|
|
391
379
|
const baselineClasses = ctx.apiSurface?.classes;
|
|
392
380
|
if (!baselineClasses) return false;
|
|
393
381
|
|
|
394
|
-
// When a service mounts on a different class (via mount rules), check
|
|
395
|
-
// each operation's resolved method name against the target class directly.
|
|
396
382
|
const mountTarget = getMountTarget(service, ctx);
|
|
397
383
|
if (mountTarget !== toPascalCase(service.name)) {
|
|
398
384
|
const cls = baselineClasses[mountTarget];
|
|
399
|
-
if (!cls) return true;
|
|
385
|
+
if (!cls) return true;
|
|
400
386
|
for (const op of service.operations) {
|
|
401
387
|
const method = resolveMethodName(op, service, ctx);
|
|
402
388
|
if (!cls.methods?.[method]) return true;
|
|
@@ -404,7 +390,6 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
|
|
|
404
390
|
return false;
|
|
405
391
|
}
|
|
406
392
|
|
|
407
|
-
// Default overlay-based detection
|
|
408
393
|
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
409
394
|
if (!overlay) return false;
|
|
410
395
|
|
|
@@ -421,28 +406,36 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
|
|
|
421
406
|
|
|
422
407
|
/**
|
|
423
408
|
* Check whether an IR model has fields not present in the baseline interface.
|
|
424
|
-
*
|
|
425
|
-
*
|
|
409
|
+
*
|
|
410
|
+
* When the live SDK exposes the same name as a type alias (e.g.
|
|
411
|
+
* `type Role = EnvironmentRole | OrganizationRole;`), treat it as already
|
|
412
|
+
* fully covered — generating an interface against an existing alias would
|
|
413
|
+
* collide. The alias's referenced types still get generated independently
|
|
414
|
+
* and serve as the canonical implementation.
|
|
426
415
|
*/
|
|
427
416
|
export function modelHasNewFields(model: Model, ctx: EmitterContext): boolean {
|
|
428
|
-
if (!ctx.apiSurface?.interfaces) return true;
|
|
417
|
+
if (!ctx.apiSurface?.interfaces && !ctx.apiSurface?.typeAliases) return true;
|
|
429
418
|
|
|
430
419
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
431
|
-
|
|
432
|
-
if (
|
|
420
|
+
|
|
421
|
+
if (ctx.apiSurface?.typeAliases?.[domainName]) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const baseline = ctx.apiSurface?.interfaces?.[domainName];
|
|
426
|
+
if (!baseline?.fields) return true;
|
|
433
427
|
|
|
434
428
|
for (const field of model.fields) {
|
|
435
429
|
const camelName = fieldName(field.name);
|
|
436
|
-
if (!baseline.fields[camelName]) return true;
|
|
430
|
+
if (!baseline.fields[camelName]) return true;
|
|
437
431
|
}
|
|
438
432
|
|
|
439
|
-
return false;
|
|
433
|
+
return false;
|
|
440
434
|
}
|
|
441
435
|
|
|
442
436
|
/**
|
|
443
437
|
* Return operations in a service that are NOT covered by existing hand-written
|
|
444
|
-
* service classes.
|
|
445
|
-
* For partially covered services, returns only the uncovered operations.
|
|
438
|
+
* service classes.
|
|
446
439
|
*/
|
|
447
440
|
export function uncoveredOperations(service: Service, ctx: EmitterContext): Operation[] {
|
|
448
441
|
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
@@ -455,19 +448,13 @@ export function uncoveredOperations(service: Service, ctx: EmitterContext): Oper
|
|
|
455
448
|
return service.operations.filter((op: Operation) => {
|
|
456
449
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
457
450
|
const match = overlay.get(httpKey);
|
|
458
|
-
if (!match) return true;
|
|
459
|
-
return !existingClassNames.has(match.className);
|
|
451
|
+
if (!match) return true;
|
|
452
|
+
return !existingClassNames.has(match.className);
|
|
460
453
|
});
|
|
461
454
|
}
|
|
462
455
|
|
|
463
456
|
/**
|
|
464
457
|
* Compute the set of model names reachable from non-event service operations.
|
|
465
|
-
* The Events service pulls in hundreds of webhook payload models that the
|
|
466
|
-
* existing SDK handles via hand-written event types, so those models are
|
|
467
|
-
* excluded from generation.
|
|
468
|
-
*
|
|
469
|
-
* Shared between model generation, barrel generation, dedup, and tests to
|
|
470
|
-
* ensure consistency: every module agrees on which models will be generated.
|
|
471
458
|
*/
|
|
472
459
|
export function computeNonEventReachable(services: Service[], models: Model[]): Set<string> {
|
|
473
460
|
const seeds = new Set<string>();
|
package/src/node/wrappers.ts
CHANGED
|
@@ -7,12 +7,6 @@ import { buildNodePathExpression } from './path-expression.js';
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Generate TypeScript wrapper method lines for union split operations.
|
|
10
|
-
*
|
|
11
|
-
* Each wrapper is a typed convenience method that:
|
|
12
|
-
* - Accepts only the exposed params (not the full union body)
|
|
13
|
-
* - Injects constant defaults (e.g., grant_type)
|
|
14
|
-
* - Reads inferred fields from client config (e.g., clientId)
|
|
15
|
-
* - Delegates to the HTTP client with the constructed body
|
|
16
10
|
*/
|
|
17
11
|
export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
|
|
18
12
|
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
@@ -29,7 +23,6 @@ export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: Emitt
|
|
|
29
23
|
|
|
30
24
|
/**
|
|
31
25
|
* Collect response model names referenced by wrappers on a resolved operation.
|
|
32
|
-
* Used by the resource generator to ensure the correct imports are emitted.
|
|
33
26
|
*/
|
|
34
27
|
export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
|
|
35
28
|
const models = new Set<string>();
|
|
@@ -51,7 +44,6 @@ function emitWrapperMethod(
|
|
|
51
44
|
const method = toCamelCase(wrapper.name);
|
|
52
45
|
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
53
46
|
|
|
54
|
-
// Build parameter list: path params, then required exposed, then optional exposed
|
|
55
47
|
const paramParts: string[] = [];
|
|
56
48
|
|
|
57
49
|
for (const p of op.pathParams) {
|
|
@@ -72,7 +64,6 @@ function emitWrapperMethod(
|
|
|
72
64
|
paramParts.push(`${tsName}?: ${tsType}`);
|
|
73
65
|
}
|
|
74
66
|
|
|
75
|
-
// Response type
|
|
76
67
|
const responseTypeName = wrapper.responseModelName ? resolveInterfaceName(wrapper.responseModelName, ctx) : null;
|
|
77
68
|
const wireType = responseTypeName ? wireInterfaceName(responseTypeName) : null;
|
|
78
69
|
const returnType = responseTypeName ?? 'void';
|
|
@@ -110,24 +101,19 @@ function emitWrapperMethod(
|
|
|
110
101
|
lines.push(' */');
|
|
111
102
|
}
|
|
112
103
|
|
|
113
|
-
// Method signature
|
|
114
104
|
lines.push(` async ${method}(${paramParts.join(', ')}): Promise<${returnType}> {`);
|
|
115
105
|
|
|
116
|
-
// Build body with wire-format (snake_case) keys
|
|
117
106
|
lines.push(' const body: Record<string, unknown> = {');
|
|
118
107
|
|
|
119
|
-
// Constant defaults
|
|
120
108
|
for (const [key, value] of Object.entries(wrapper.defaults)) {
|
|
121
109
|
lines.push(` ${key}: ${tsLiteral(value)},`);
|
|
122
110
|
}
|
|
123
111
|
|
|
124
|
-
// Inferred fields from client config
|
|
125
112
|
for (const field of wrapper.inferFromClient) {
|
|
126
113
|
const expr = clientFieldExpression(field);
|
|
127
114
|
lines.push(` ${field}: ${expr},`);
|
|
128
115
|
}
|
|
129
116
|
|
|
130
|
-
// Required exposed params (wire-format key, camelCase value)
|
|
131
117
|
for (const { paramName, isOptional } of wrapperParams) {
|
|
132
118
|
if (isOptional) continue;
|
|
133
119
|
lines.push(` ${paramName}: ${fieldName(paramName)},`);
|
|
@@ -135,17 +121,14 @@ function emitWrapperMethod(
|
|
|
135
121
|
|
|
136
122
|
lines.push(' };');
|
|
137
123
|
|
|
138
|
-
// Optional exposed params — add conditionally
|
|
139
124
|
for (const { paramName, isOptional } of wrapperParams) {
|
|
140
125
|
if (!isOptional) continue;
|
|
141
126
|
const tsName = fieldName(paramName);
|
|
142
127
|
lines.push(` if (${tsName} !== undefined) body.${paramName} = ${tsName};`);
|
|
143
128
|
}
|
|
144
129
|
|
|
145
|
-
// Build path expression
|
|
146
130
|
const pathStr = buildPathStr(op);
|
|
147
131
|
|
|
148
|
-
// Make the request
|
|
149
132
|
if (responseTypeName) {
|
|
150
133
|
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, body);`);
|
|
151
134
|
lines.push(` return deserialize${responseTypeName}(data);`);
|
|
@@ -156,19 +139,16 @@ function emitWrapperMethod(
|
|
|
156
139
|
lines.push(' }');
|
|
157
140
|
}
|
|
158
141
|
|
|
159
|
-
/** Build a path template string from an Operation. */
|
|
160
142
|
function buildPathStr(op: { path: string; pathParams: Array<{ name: string }> }): string {
|
|
161
143
|
return buildNodePathExpression(op.path);
|
|
162
144
|
}
|
|
163
145
|
|
|
164
|
-
/** Convert a JS value to a TypeScript literal. */
|
|
165
146
|
function tsLiteral(value: string | number | boolean): string {
|
|
166
147
|
if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
|
|
167
148
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
168
149
|
return String(value);
|
|
169
150
|
}
|
|
170
151
|
|
|
171
|
-
/** Get the TypeScript expression for reading a client config field. */
|
|
172
152
|
function clientFieldExpression(field: string): string {
|
|
173
153
|
switch (field) {
|
|
174
154
|
case 'client_id':
|
package/src/rust/fixtures.ts
CHANGED
|
@@ -41,7 +41,12 @@ export function generateModelFixture(
|
|
|
41
41
|
|
|
42
42
|
for (const field of model.fields) {
|
|
43
43
|
if (!field.required) continue;
|
|
44
|
-
|
|
44
|
+
// Prefer the spec `example` value when it is shape-compatible with the
|
|
45
|
+
// declared type. Falls back to the placeholder generator when no example
|
|
46
|
+
// is provided or when the example would not deserialize cleanly.
|
|
47
|
+
const fromExample = exampleFromSpec(field.example, field.type, enumMap);
|
|
48
|
+
result[field.name] =
|
|
49
|
+
fromExample !== undefined ? fromExample : exampleFor(field.type, modelMap, enumMap, visiting, field.name);
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
visiting.delete(model.name);
|
|
@@ -108,3 +113,84 @@ export function exampleFor(
|
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a spec-provided `example` against a TypeRef and return the value to
|
|
119
|
+
* embed in the fixture, or `undefined` when the example cannot be used safely.
|
|
120
|
+
*
|
|
121
|
+
* "Safely" means the value would round-trip through serde to the generated
|
|
122
|
+
* Rust type. We deliberately only accept primitives, enum string/number
|
|
123
|
+
* values, and homogenous arrays of those; nested object examples (which the
|
|
124
|
+
* spec sometimes supplies as illustrative metadata blobs) are skipped because
|
|
125
|
+
* they rarely match the strict struct shape Rust expects.
|
|
126
|
+
*/
|
|
127
|
+
export function exampleFromSpec(example: unknown, type: TypeRef, enumMap: Map<string, Enum>): unknown {
|
|
128
|
+
if (example === undefined) return undefined;
|
|
129
|
+
// Spec authors sometimes use `null` as a sentinel; let placeholder gen
|
|
130
|
+
// handle nullable types so we don't emit `null` for required fields.
|
|
131
|
+
if (example === null) return undefined;
|
|
132
|
+
return matchExampleToType(example, type, enumMap);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function matchExampleToType(value: unknown, type: TypeRef, enumMap: Map<string, Enum>): unknown {
|
|
136
|
+
switch (type.kind) {
|
|
137
|
+
case 'primitive':
|
|
138
|
+
return matchPrimitive(value, type.type);
|
|
139
|
+
case 'literal':
|
|
140
|
+
return value === type.value ? value : undefined;
|
|
141
|
+
case 'enum': {
|
|
142
|
+
const e = enumMap.get(type.name);
|
|
143
|
+
if (!e) return undefined;
|
|
144
|
+
const ok = e.values.some((v) => v.value === value);
|
|
145
|
+
return ok ? value : undefined;
|
|
146
|
+
}
|
|
147
|
+
case 'array': {
|
|
148
|
+
if (!Array.isArray(value)) return undefined;
|
|
149
|
+
const out: unknown[] = [];
|
|
150
|
+
for (const item of value) {
|
|
151
|
+
const matched = matchExampleToType(item, type.items, enumMap);
|
|
152
|
+
if (matched === undefined) return undefined;
|
|
153
|
+
out.push(matched);
|
|
154
|
+
}
|
|
155
|
+
// Empty arrays are valid but unhelpful in fixtures — fall back so the
|
|
156
|
+
// placeholder generator can produce a one-element example.
|
|
157
|
+
if (out.length === 0) return undefined;
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
case 'nullable':
|
|
161
|
+
return matchExampleToType(value, type.inner, enumMap);
|
|
162
|
+
case 'map':
|
|
163
|
+
// Map examples are usually free-form metadata blobs that match
|
|
164
|
+
// `HashMap<String, _>`; only accept plain objects with string-keyed values.
|
|
165
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
166
|
+
return value;
|
|
167
|
+
case 'union': {
|
|
168
|
+
for (const variant of type.variants) {
|
|
169
|
+
const matched = matchExampleToType(value, variant, enumMap);
|
|
170
|
+
if (matched !== undefined) return matched;
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
case 'model':
|
|
175
|
+
// Model-shaped examples are too risky to copy verbatim: they rarely
|
|
176
|
+
// supply every required field and may use wire names that don't align
|
|
177
|
+
// with the generated struct. Let the recursive generator handle them.
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function matchPrimitive(value: unknown, primitive: 'string' | 'integer' | 'number' | 'boolean' | 'unknown'): unknown {
|
|
183
|
+
switch (primitive) {
|
|
184
|
+
case 'string':
|
|
185
|
+
return typeof value === 'string' ? value : undefined;
|
|
186
|
+
case 'integer':
|
|
187
|
+
return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
|
|
188
|
+
case 'number':
|
|
189
|
+
return typeof value === 'number' ? value : undefined;
|
|
190
|
+
case 'boolean':
|
|
191
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
192
|
+
case 'unknown':
|
|
193
|
+
// `unknown` deserialises to `serde_json::Value`, so any JSON value works.
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/rust/models.ts
CHANGED
|
@@ -105,8 +105,13 @@ function resolveFieldNames(fields: Field[]): string[] {
|
|
|
105
105
|
|
|
106
106
|
function renderField(field: Field, rustField: string, modelName: string, registry: UnionRegistry): string {
|
|
107
107
|
const lines: string[] = [];
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
const hasDescription = !!field.description;
|
|
109
|
+
if (hasDescription) {
|
|
110
|
+
for (const c of docComment(field.description!)) lines.push(` ${c}`);
|
|
111
|
+
}
|
|
112
|
+
if (field.default != null) {
|
|
113
|
+
if (hasDescription) lines.push(' ///');
|
|
114
|
+
lines.push(` /// Defaults to \`${formatDefault(field.default)}\`.`);
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
const rename = rustField !== field.name ? field.name : null;
|
|
@@ -148,3 +153,13 @@ function docComment(text: string): string[] {
|
|
|
148
153
|
.filter((l) => l.length > 0)
|
|
149
154
|
.map((l) => `/// ${l}`);
|
|
150
155
|
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render a spec-level default value for inclusion in a doc comment. Strings
|
|
159
|
+
* render bare (e.g. `desc`) so they nest naturally inside the surrounding
|
|
160
|
+
* backticks; numbers/booleans use JSON encoding.
|
|
161
|
+
*/
|
|
162
|
+
function formatDefault(value: unknown): string {
|
|
163
|
+
if (typeof value === 'string') return value;
|
|
164
|
+
return JSON.stringify(value);
|
|
165
|
+
}
|