@workos/oagen-emitters 0.0.1 → 0.2.0

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.
Files changed (41) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.prettierignore +1 -0
  6. package/.release-please-manifest.json +3 -0
  7. package/.vscode/settings.json +3 -0
  8. package/CHANGELOG.md +54 -0
  9. package/README.md +2 -2
  10. package/dist/index.d.mts +7 -0
  11. package/dist/index.d.mts.map +1 -0
  12. package/dist/index.mjs +3522 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/package.json +14 -18
  15. package/release-please-config.json +11 -0
  16. package/src/node/client.ts +437 -204
  17. package/src/node/common.ts +74 -4
  18. package/src/node/config.ts +1 -0
  19. package/src/node/enums.ts +50 -6
  20. package/src/node/errors.ts +78 -3
  21. package/src/node/fixtures.ts +84 -15
  22. package/src/node/index.ts +2 -2
  23. package/src/node/manifest.ts +4 -2
  24. package/src/node/models.ts +195 -79
  25. package/src/node/naming.ts +16 -1
  26. package/src/node/resources.ts +721 -106
  27. package/src/node/serializers.ts +510 -52
  28. package/src/node/tests.ts +621 -105
  29. package/src/node/type-map.ts +89 -11
  30. package/src/node/utils.ts +377 -114
  31. package/test/node/client.test.ts +979 -15
  32. package/test/node/enums.test.ts +0 -1
  33. package/test/node/errors.test.ts +4 -21
  34. package/test/node/models.test.ts +409 -2
  35. package/test/node/naming.test.ts +0 -3
  36. package/test/node/resources.test.ts +964 -7
  37. package/test/node/serializers.test.ts +212 -3
  38. package/tsconfig.json +2 -3
  39. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  40. package/dist/index.d.ts +0 -5
  41. package/dist/index.js +0 -2158
@@ -1,48 +1,121 @@
1
- import type { Model, Field, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
- import { walkTypeRef } from '@workos/oagen';
1
+ import type { Model, Field, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
3
2
  import { mapTypeRef, mapWireTypeRef } from './type-map.js';
3
+ import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
4
4
  import {
5
- fieldName,
6
- wireFieldName,
7
- fileName,
8
- serviceDirName,
9
- resolveInterfaceName,
10
- buildServiceNameMap,
11
- wireInterfaceName,
12
- } from './naming.js';
13
- import { assignModelsToServices, collectFieldDependencies, docComment } from './utils.js';
14
-
15
- /** Built-in TypeScript types that are always available (no import needed). */
16
- const BUILTINS = new Set([
17
- 'Record',
18
- 'Promise',
19
- 'Array',
20
- 'Map',
21
- 'Set',
22
- 'Date',
23
- 'string',
24
- 'number',
25
- 'boolean',
26
- 'void',
27
- 'null',
28
- 'undefined',
29
- 'any',
30
- 'never',
31
- 'unknown',
32
- 'true',
33
- 'false',
34
- ]);
5
+ collectFieldDependencies,
6
+ docComment,
7
+ buildGenericModelDefaults,
8
+ pruneUnusedImports,
9
+ TS_BUILTINS,
10
+ detectStringDateConvention,
11
+ buildKnownTypeNames,
12
+ isBaselineGeneric,
13
+ createServiceDirResolver,
14
+ isListMetadataModel,
15
+ isListWrapperModel,
16
+ buildDeduplicationMap,
17
+ } from './utils.js';
18
+ import { assignEnumsToServices } from './enums.js';
19
+
20
+ /**
21
+ * Detect baseline interfaces that are generic (have type parameters) even though
22
+ * the IR model has no typeParams (OpenAPI doesn't support generics).
23
+ *
24
+ * Heuristic: if any field type in the baseline interface contains a PascalCase
25
+ * name that isn't a known model, enum, or builtin, it's likely a type parameter
26
+ * (e.g., `CustomAttributesType`), indicating the interface is generic.
27
+ *
28
+ * When detected, adds a default generic type arg so references like `Profile`
29
+ * become `Profile<Record<string, unknown>>`.
30
+ */
31
+ function enrichGenericDefaultsFromBaseline(
32
+ genericDefaults: Map<string, string>,
33
+ models: Model[],
34
+ ctx: EmitterContext,
35
+ resolveDir: (irService: string | undefined) => string,
36
+ modelToService: Map<string, string>,
37
+ ): void {
38
+ if (!ctx.apiSurface?.interfaces) return;
39
+ const knownNames = buildKnownTypeNames(models, ctx);
40
+
41
+ for (const model of models) {
42
+ if (genericDefaults.has(model.name)) continue; // IR already handles it
43
+ const domainName = resolveInterfaceName(model.name, ctx);
44
+ const baseline = ctx.apiSurface.interfaces[domainName];
45
+ if (!baseline?.fields) continue;
46
+
47
+ // Only enrich generic defaults for models whose baseline file will be
48
+ // preserved via skipIfExists (paths match). If the file is generated
49
+ // fresh in a new directory, it won't have generics, so references
50
+ // to it don't need type args.
51
+ const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
52
+ const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
53
+ if (baselineSourceFile && baselineSourceFile !== generatedPath) continue;
54
+
55
+ if (isBaselineGeneric(baseline.fields, knownNames)) {
56
+ genericDefaults.set(model.name, '<Record<string, unknown>>');
57
+ }
58
+ }
59
+ }
35
60
 
36
61
  export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
37
62
  if (models.length === 0) return [];
38
63
 
39
- const modelToService = assignModelsToServices(models, ctx.spec.services);
40
- const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
41
- const resolveDir = (irService: string | undefined) =>
42
- irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
64
+ const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
65
+ // Detect whether the existing SDK uses string dates (ISO 8601) rather than Date objects.
66
+ // When detected, newly generated models also use string to maintain consistency.
67
+ const useStringDates = detectStringDateConvention(models, ctx);
68
+ const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
69
+ // Enrich genericDefaults from baseline interfaces that appear to be generic.
70
+ // The IR doesn't carry typeParams for models parsed from OpenAPI (which has no
71
+ // generics), but the existing SDK may have hand-written generic interfaces
72
+ // (e.g., Profile<CustomAttributesType>). Detect these by checking if any
73
+ // field type contains a PascalCase name that isn't a known model, enum, or builtin.
74
+ enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
75
+ const typeRefOpts = useStringDates ? { stringDates: true, genericDefaults } : { genericDefaults };
76
+ const wireTypeRefOpts = { genericDefaults };
43
77
  const files: GeneratedFile[] = [];
44
78
 
79
+ // Detect structurally identical or same-name models — emit type aliases for duplicates
80
+ const dedup = buildDeduplicationMap(models, ctx);
81
+
45
82
  for (const model of models) {
83
+ // Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
84
+ if (isListMetadataModel(model)) continue;
85
+
86
+ // Fix #6: Skip per-domain list wrapper interfaces — the shared List<T>/ListResponse<T> covers these
87
+ if (isListWrapperModel(model)) continue;
88
+
89
+ // Deduplication: if this model is structurally identical to a canonical model,
90
+ // emit a type alias instead of a full interface.
91
+ const canonicalName = dedup.get(model.name);
92
+ if (canonicalName) {
93
+ const domainName = resolveInterfaceName(model.name, ctx);
94
+ const responseName = wireInterfaceName(domainName);
95
+ const canonDomainName = resolveInterfaceName(canonicalName, ctx);
96
+ const canonResponseName = wireInterfaceName(canonDomainName);
97
+ const service = modelToService.get(model.name);
98
+ const dirName = resolveDir(service);
99
+ const canonService = modelToService.get(canonicalName);
100
+ const canonDir = resolveDir(canonService);
101
+ const canonRelPath =
102
+ canonDir === dirName
103
+ ? `./${fileName(canonicalName)}.interface`
104
+ : `../../${canonDir}/interfaces/${fileName(canonicalName)}.interface`;
105
+ const aliasLines = [
106
+ `import type { ${canonDomainName}, ${canonResponseName} } from '${canonRelPath}';`,
107
+ '',
108
+ `export type ${domainName} = ${canonDomainName};`,
109
+ `export type ${responseName} = ${canonResponseName};`,
110
+ ];
111
+ files.push({
112
+ path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
113
+ content: aliasLines.join('\n'),
114
+ skipIfExists: true,
115
+ });
116
+ continue;
117
+ }
118
+
46
119
  const service = modelToService.get(model.name);
47
120
  const dirName = resolveDir(service);
48
121
  const domainName = resolveInterfaceName(model.name, ctx);
@@ -50,6 +123,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
50
123
  const deps = collectFieldDependencies(model);
51
124
  const lines: string[] = [];
52
125
 
126
+ // Exclude the current model from generic defaults to avoid self-referencing
127
+ // (e.g., Profile's own fields should use TCustom, not Profile<Record<...>>)
128
+ let modelTypeRefOpts = typeRefOpts;
129
+ let modelWireTypeRefOpts = wireTypeRefOpts;
130
+ if (genericDefaults.has(model.name)) {
131
+ const filteredDefaults = new Map(genericDefaults);
132
+ filteredDefaults.delete(model.name);
133
+ modelTypeRefOpts = { ...typeRefOpts, genericDefaults: filteredDefaults };
134
+ modelWireTypeRefOpts = { genericDefaults: filteredDefaults };
135
+ }
136
+
53
137
  // Baseline interface data (for compat field type matching)
54
138
  const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
55
139
  const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
@@ -71,9 +155,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
71
155
  // Pre-pass: discover baseline type names that aren't directly importable.
72
156
  // For each unresolvable name we either:
73
157
  // 1. Import the real type from another service (if it exists as an enum/model there)
74
- // 2. Create a local type declaration as a fallback
158
+ // 2. Create a local type alias from a suffix match
159
+ // 3. Mark as unresolvable — the field will fall back to the IR-generated type
75
160
  const typeDecls = new Map<string, string>(); // aliasName → type expression
76
161
  const crossServiceImports = new Map<string, { name: string; relPath: string }>(); // extra imports
162
+ const unresolvableNames = new Set<string>(); // names that can't be resolved — forces IR fallback
77
163
  const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
78
164
  // Build a lookup: resolved enum name → IR enum name
79
165
  const resolvedEnumNames = new Map<string, string>();
@@ -92,10 +178,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
92
178
  if (!names) continue;
93
179
 
94
180
  for (const name of names) {
95
- if (BUILTINS.has(name)) continue;
181
+ if (TS_BUILTINS.has(name)) continue;
96
182
  if (importableNames.has(name)) continue;
97
183
  if (typeDecls.has(name)) continue;
98
184
  if (crossServiceImports.has(name)) continue;
185
+ if (unresolvableNames.has(name)) continue;
99
186
 
100
187
  // Check if this name exists as an enum in another service —
101
188
  // import the actual type so the extractor sees the real name
@@ -119,11 +206,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
119
206
  typeDecls.set(name, candidates[0]);
120
207
  importableNames.add(name);
121
208
  } else {
122
- // No suffix match create a type alias using the IR-generated type
123
- const innerType = field.type.kind === 'nullable' ? field.type.inner : field.type;
124
- const typeExpr = mapTypeRef(innerType);
125
- typeDecls.set(name, typeExpr);
126
- importableNames.add(name);
209
+ // Cannot resolve this baseline type name mark it so the field
210
+ // falls back to the IR-generated type instead of the baseline.
211
+ // This avoids creating type aliases that reference undefined types.
212
+ unresolvableNames.add(name);
127
213
  }
128
214
  }
129
215
  }
@@ -157,8 +243,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
157
243
  }
158
244
  if (typeDecls.size > 0) lines.push('');
159
245
 
160
- // Type params (generics)
161
- const typeParams = renderTypeParams(model);
246
+ // Type params (generics) — pass genericDefaults so baseline-detected generics
247
+ // also get type parameter declarations on the interface itself.
248
+ const typeParams = renderTypeParams(model, genericDefaults);
162
249
 
163
250
  // Domain interface (camelCase fields) — deduplicate by camelCase name
164
251
  const seenDomainFields = new Set<string>();
@@ -170,9 +257,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
170
257
  const domainFieldName = fieldName(field.name);
171
258
  if (seenDomainFields.has(domainFieldName)) continue;
172
259
  seenDomainFields.add(domainFieldName);
173
- if (field.description || field.deprecated) {
260
+ if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
174
261
  const parts: string[] = [];
175
262
  if (field.description) parts.push(field.description);
263
+ if (field.readOnly) parts.push('@readonly');
264
+ if (field.writeOnly) parts.push('@writeonly');
265
+ if (field.default !== undefined) parts.push(`@default ${JSON.stringify(field.default)}`);
176
266
  if (field.deprecated) parts.push('@deprecated');
177
267
  lines.push(...docComment(parts.join('\n'), 2));
178
268
  }
@@ -185,6 +275,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
185
275
  const responseBaselineField = baselineResponse?.fields?.[domainWireField];
186
276
  const domainResponseOptionalMismatch =
187
277
  baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
278
+ const readonlyPrefix = field.readOnly ? 'readonly ' : '';
188
279
  if (
189
280
  baselineField &&
190
281
  !domainResponseOptionalMismatch &&
@@ -192,10 +283,29 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
192
283
  baselineFieldCompatible(baselineField, field)
193
284
  ) {
194
285
  const opt = baselineField.optional ? '?' : '';
195
- lines.push(` ${domainFieldName}${opt}: ${baselineField.type};`);
286
+ lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
196
287
  } else {
197
- const opt = !field.required ? '?' : '';
198
- lines.push(` ${domainFieldName}${opt}: ${mapTypeRef(field.type)};`);
288
+ // When a baseline exists for this model, new fields (not present in the
289
+ // baseline) are generated as optional. The merger can deep-merge new
290
+ // fields into existing interfaces, but it cannot update existing
291
+ // deserializer function bodies. Making the field optional prevents a
292
+ // type error where the interface requires a field that the preserved
293
+ // deserializer never populates.
294
+ const isNewFieldOnExistingModel = baselineDomain && !baselineField;
295
+ // Also make the field optional when the response baseline has it as optional
296
+ // but the domain baseline has it as required — the deserializer reads from
297
+ // the response type, so if the response field is optional, the domain value
298
+ // may be undefined.
299
+ // Additionally, when a baseline exists for the RESPONSE interface but NOT the
300
+ // domain interface, fields that are new on the response baseline become optional
301
+ // in the wire type. The domain type must also be optional to match, otherwise
302
+ // the deserializer produces T | undefined for a field typed as T.
303
+ const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
304
+ const opt =
305
+ !field.required || isNewFieldOnExistingModel || domainResponseOptionalMismatch || isNewFieldOnExistingResponse
306
+ ? '?'
307
+ : '';
308
+ lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef(field.type, modelTypeRefOpts)};`);
199
309
  }
200
310
  }
201
311
  lines.push('}');
@@ -217,15 +327,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
217
327
  const opt = baselineField.optional ? '?' : '';
218
328
  lines.push(` ${wireField}${opt}: ${baselineField.type};`);
219
329
  } else {
220
- const opt = !field.required ? '?' : '';
221
- lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type)};`);
330
+ const isNewFieldOnExistingModel = baselineResponse && !baselineField;
331
+ const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
332
+ lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
222
333
  }
223
334
  }
224
335
  lines.push('}');
225
336
 
226
337
  files.push({
227
338
  path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
228
- content: lines.join('\n'),
339
+ content: pruneUnusedImports(lines).join('\n'),
229
340
  skipIfExists: true,
230
341
  });
231
342
  }
@@ -246,7 +357,7 @@ function baselineTypeResolvable(typeStr: string, importableNames: Set<string>):
246
357
  if (!matches) return true;
247
358
 
248
359
  for (const name of matches) {
249
- if (BUILTINS.has(name)) continue;
360
+ if (TS_BUILTINS.has(name)) continue;
250
361
  if (importableNames.has(name)) continue;
251
362
  return false;
252
363
  }
@@ -286,38 +397,43 @@ function baselineFieldCompatible(baselineField: { type: string; optional: boolea
286
397
  // the serializer produces a definite value but the interface is looser — that's OK
287
398
  // (the domain type is wider than the serializer output)
288
399
 
400
+ // If the baseline type is Record<string, unknown> but the IR field has a more specific
401
+ // type (model, enum, or union with named variants), prefer the IR type for better type safety
402
+ if (baselineField.type === 'Record<string, unknown>' && hasSpecificIRType(irField.type)) {
403
+ return false;
404
+ }
405
+
289
406
  return true;
290
407
  }
291
408
 
292
- function renderTypeParams(model: Model): string {
293
- if (!model.typeParams?.length) return '';
409
+ /** Check if an IR type is more specific than Record<string, unknown>. */
410
+ function hasSpecificIRType(ref: TypeRef): boolean {
411
+ switch (ref.kind) {
412
+ case 'model':
413
+ case 'enum':
414
+ return true;
415
+ case 'union':
416
+ // A union with named model/enum variants is more specific
417
+ return ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum');
418
+ case 'nullable':
419
+ return hasSpecificIRType(ref.inner);
420
+ default:
421
+ return false;
422
+ }
423
+ }
424
+
425
+ function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
426
+ if (!model.typeParams?.length) {
427
+ // Fallback: if genericDefaults indicates this model is generic (detected
428
+ // from the baseline), generate a default generic type parameter declaration.
429
+ if (genericDefaults?.has(model.name)) {
430
+ return '<GenericType extends Record<string, unknown> = Record<string, unknown>>';
431
+ }
432
+ return '';
433
+ }
294
434
  const params = model.typeParams.map((tp) => {
295
435
  const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
296
436
  return `${tp.name}${def}`;
297
437
  });
298
438
  return `<${params.join(', ')}>`;
299
439
  }
300
-
301
- function assignEnumsToServices(enums: { name: string }[], services: Service[]): Map<string, string> {
302
- const enumToService = new Map<string, string>();
303
- const enumNames = new Set(enums.map((e) => e.name));
304
- for (const service of services) {
305
- for (const op of service.operations) {
306
- const refs = new Set<string>();
307
- const collect = (ref: any) => {
308
- walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
309
- };
310
- if (op.requestBody) collect(op.requestBody);
311
- collect(op.response);
312
- for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
313
- collect(p.type);
314
- }
315
- for (const name of refs) {
316
- if (enumNames.has(name) && !enumToService.has(name)) {
317
- enumToService.set(name, service.name);
318
- }
319
- }
320
- }
321
- }
322
- return enumToService;
323
- }
@@ -70,7 +70,22 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
70
70
  export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
71
71
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
72
72
  const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
73
- if (existing) return existing.methodName;
73
+ if (existing) {
74
+ // Fix: when the path ends with a path parameter (single-resource operation)
75
+ // and the overlay method name is plural, prefer the singular form.
76
+ // E.g., getUsers → getUser when path is /user_management/users/{id}
77
+ const isSingleResource = /\/\{[^}]+\}$/.test(op.path);
78
+ if (isSingleResource && existing.methodName.endsWith('s') && !existing.methodName.endsWith('ss')) {
79
+ const singular = existing.methodName.slice(0, -1);
80
+ // Only singularize if it looks like a typical pluralization (ends in 's')
81
+ // and the spec-derived name agrees it should be singular
82
+ const specDerived = toCamelCase(op.name);
83
+ if (specDerived === singular || specDerived.endsWith(singular.slice(singular.length - 4))) {
84
+ return singular;
85
+ }
86
+ }
87
+ return existing.methodName;
88
+ }
74
89
  return toCamelCase(op.name);
75
90
  }
76
91