@workos/oagen-emitters 0.0.1

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 (73) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.github/workflows/lint-pr-title.yml +16 -0
  3. package/.github/workflows/lint.yml +21 -0
  4. package/.github/workflows/release-please.yml +28 -0
  5. package/.github/workflows/release.yml +32 -0
  6. package/.husky/commit-msg +1 -0
  7. package/.husky/pre-commit +1 -0
  8. package/.husky/pre-push +1 -0
  9. package/.node-version +1 -0
  10. package/.oxfmtrc.json +10 -0
  11. package/.oxlintrc.json +29 -0
  12. package/.vscode/settings.json +11 -0
  13. package/LICENSE.txt +21 -0
  14. package/README.md +123 -0
  15. package/commitlint.config.ts +1 -0
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.js +2158 -0
  18. package/docs/endpoint-coverage.md +275 -0
  19. package/docs/sdk-architecture/node.md +355 -0
  20. package/oagen.config.ts +51 -0
  21. package/package.json +83 -0
  22. package/renovate.json +26 -0
  23. package/smoke/sdk-dotnet.ts +903 -0
  24. package/smoke/sdk-elixir.ts +771 -0
  25. package/smoke/sdk-go.ts +948 -0
  26. package/smoke/sdk-kotlin.ts +799 -0
  27. package/smoke/sdk-node.ts +516 -0
  28. package/smoke/sdk-php.ts +699 -0
  29. package/smoke/sdk-python.ts +738 -0
  30. package/smoke/sdk-ruby.ts +723 -0
  31. package/smoke/sdk-rust.ts +774 -0
  32. package/src/compat/extractors/dotnet.ts +8 -0
  33. package/src/compat/extractors/elixir.ts +8 -0
  34. package/src/compat/extractors/go.ts +8 -0
  35. package/src/compat/extractors/kotlin.ts +8 -0
  36. package/src/compat/extractors/node.ts +8 -0
  37. package/src/compat/extractors/php.ts +8 -0
  38. package/src/compat/extractors/python.ts +8 -0
  39. package/src/compat/extractors/ruby.ts +8 -0
  40. package/src/compat/extractors/rust.ts +8 -0
  41. package/src/index.ts +1 -0
  42. package/src/node/client.ts +356 -0
  43. package/src/node/common.ts +203 -0
  44. package/src/node/config.ts +70 -0
  45. package/src/node/enums.ts +87 -0
  46. package/src/node/errors.ts +205 -0
  47. package/src/node/fixtures.ts +139 -0
  48. package/src/node/index.ts +57 -0
  49. package/src/node/manifest.ts +23 -0
  50. package/src/node/models.ts +323 -0
  51. package/src/node/naming.ts +96 -0
  52. package/src/node/resources.ts +380 -0
  53. package/src/node/serializers.ts +286 -0
  54. package/src/node/tests.ts +336 -0
  55. package/src/node/type-map.ts +56 -0
  56. package/src/node/utils.ts +164 -0
  57. package/test/compat/extractors/node.test.ts +145 -0
  58. package/test/fixtures/sample-sdk-node/package.json +7 -0
  59. package/test/fixtures/sample-sdk-node/src/client.ts +24 -0
  60. package/test/fixtures/sample-sdk-node/src/index.ts +4 -0
  61. package/test/fixtures/sample-sdk-node/src/models.ts +28 -0
  62. package/test/fixtures/sample-sdk-node/tsconfig.json +13 -0
  63. package/test/node/client.test.ts +165 -0
  64. package/test/node/enums.test.ts +128 -0
  65. package/test/node/errors.test.ts +65 -0
  66. package/test/node/models.test.ts +301 -0
  67. package/test/node/naming.test.ts +212 -0
  68. package/test/node/resources.test.ts +260 -0
  69. package/test/node/serializers.test.ts +206 -0
  70. package/test/node/type-map.test.ts +127 -0
  71. package/tsconfig.json +20 -0
  72. package/tsup.config.ts +8 -0
  73. package/vitest.config.ts +4 -0
@@ -0,0 +1,323 @@
1
+ import type { Model, Field, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
+ import { walkTypeRef } from '@workos/oagen';
3
+ import { mapTypeRef, mapWireTypeRef } from './type-map.js';
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
+ ]);
35
+
36
+ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
37
+ if (models.length === 0) return [];
38
+
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';
43
+ const files: GeneratedFile[] = [];
44
+
45
+ for (const model of models) {
46
+ const service = modelToService.get(model.name);
47
+ const dirName = resolveDir(service);
48
+ const domainName = resolveInterfaceName(model.name, ctx);
49
+ const responseName = wireInterfaceName(domainName);
50
+ const deps = collectFieldDependencies(model);
51
+ const lines: string[] = [];
52
+
53
+ // Baseline interface data (for compat field type matching)
54
+ const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
55
+ const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
56
+
57
+ // Build set of importable type names for this file:
58
+ // the model itself, its Response variant, all IR-dep model names + Response variants, and all IR-dep enum names
59
+ const importableNames = new Set<string>();
60
+ importableNames.add(domainName);
61
+ importableNames.add(responseName);
62
+ for (const dep of deps.models) {
63
+ const depName = resolveInterfaceName(dep, ctx);
64
+ importableNames.add(depName);
65
+ importableNames.add(wireInterfaceName(depName));
66
+ }
67
+ for (const dep of deps.enums) {
68
+ importableNames.add(dep);
69
+ }
70
+
71
+ // Pre-pass: discover baseline type names that aren't directly importable.
72
+ // For each unresolvable name we either:
73
+ // 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
75
+ const typeDecls = new Map<string, string>(); // aliasName → type expression
76
+ const crossServiceImports = new Map<string, { name: string; relPath: string }>(); // extra imports
77
+ const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
78
+ // Build a lookup: resolved enum name → IR enum name
79
+ const resolvedEnumNames = new Map<string, string>();
80
+ for (const e of ctx.spec.enums) {
81
+ resolvedEnumNames.set(resolveInterfaceName(e.name, ctx), e.name);
82
+ }
83
+
84
+ for (const field of model.fields) {
85
+ const baselineFields = [
86
+ baselineDomain?.fields?.[fieldName(field.name)],
87
+ baselineResponse?.fields?.[wireFieldName(field.name)],
88
+ ].filter(Boolean) as { type: string; optional: boolean }[];
89
+
90
+ for (const bf of baselineFields) {
91
+ const names = bf.type.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
92
+ if (!names) continue;
93
+
94
+ for (const name of names) {
95
+ if (BUILTINS.has(name)) continue;
96
+ if (importableNames.has(name)) continue;
97
+ if (typeDecls.has(name)) continue;
98
+ if (crossServiceImports.has(name)) continue;
99
+
100
+ // Check if this name exists as an enum in another service —
101
+ // import the actual type so the extractor sees the real name
102
+ const irEnumName = resolvedEnumNames.get(name);
103
+ if (irEnumName && !deps.enums.has(irEnumName)) {
104
+ const eService = enumToService.get(irEnumName);
105
+ const eDir = resolveDir(eService);
106
+ const relPath =
107
+ eDir === dirName
108
+ ? `./${fileName(irEnumName)}.interface`
109
+ : `../../${eDir}/interfaces/${fileName(irEnumName)}.interface`;
110
+ crossServiceImports.set(name, { name, relPath });
111
+ importableNames.add(name);
112
+ continue;
113
+ }
114
+
115
+ // Try suffix match: find an importable name ending with this name
116
+ const candidates = [...importableNames].filter((n) => n.endsWith(name) && n !== name);
117
+ if (candidates.length === 1) {
118
+ // Create local type alias (e.g., type RoleResponse = ProfileRoleResponse)
119
+ typeDecls.set(name, candidates[0]);
120
+ importableNames.add(name);
121
+ } 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);
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ // Import referenced models (domain + response) and enums with correct cross-service paths
133
+ for (const dep of deps.models) {
134
+ const depName = resolveInterfaceName(dep, ctx);
135
+ const depService = modelToService.get(dep);
136
+ const depDir = resolveDir(depService);
137
+ const relPath =
138
+ depDir === dirName ? `./${fileName(dep)}.interface` : `../../${depDir}/interfaces/${fileName(dep)}.interface`;
139
+ lines.push(`import type { ${depName}, ${wireInterfaceName(depName)} } from '${relPath}';`);
140
+ }
141
+ for (const dep of deps.enums) {
142
+ const depService = enumToService.get(dep);
143
+ const depDir = resolveDir(depService);
144
+ const relPath =
145
+ depDir === dirName ? `./${fileName(dep)}.interface` : `../../${depDir}/interfaces/${fileName(dep)}.interface`;
146
+ lines.push(`import type { ${dep} } from '${relPath}';`);
147
+ }
148
+ for (const [, imp] of crossServiceImports) {
149
+ lines.push(`import type { ${imp.name} } from '${imp.relPath}';`);
150
+ }
151
+
152
+ if (lines.length > 0) lines.push('');
153
+
154
+ // Add local type declarations for unresolvable baseline type names
155
+ for (const [alias, typeExpr] of typeDecls) {
156
+ lines.push(`type ${alias} = ${typeExpr};`);
157
+ }
158
+ if (typeDecls.size > 0) lines.push('');
159
+
160
+ // Type params (generics)
161
+ const typeParams = renderTypeParams(model);
162
+
163
+ // Domain interface (camelCase fields) — deduplicate by camelCase name
164
+ const seenDomainFields = new Set<string>();
165
+ if (model.description) {
166
+ lines.push(...docComment(model.description));
167
+ }
168
+ lines.push(`export interface ${domainName}${typeParams} {`);
169
+ for (const field of model.fields) {
170
+ const domainFieldName = fieldName(field.name);
171
+ if (seenDomainFields.has(domainFieldName)) continue;
172
+ seenDomainFields.add(domainFieldName);
173
+ if (field.description || field.deprecated) {
174
+ const parts: string[] = [];
175
+ if (field.description) parts.push(field.description);
176
+ if (field.deprecated) parts.push('@deprecated');
177
+ lines.push(...docComment(parts.join('\n'), 2));
178
+ }
179
+ const baselineField = baselineDomain?.fields?.[domainFieldName];
180
+ // For the domain interface, also check that the response baseline's optionality
181
+ // is compatible — the serializer reads from the response type and assigns to the domain type.
182
+ // If the domain baseline says required but the response baseline says optional,
183
+ // the serializer would produce T | undefined for a field expecting T.
184
+ const domainWireField = wireFieldName(field.name);
185
+ const responseBaselineField = baselineResponse?.fields?.[domainWireField];
186
+ const domainResponseOptionalMismatch =
187
+ baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
188
+ if (
189
+ baselineField &&
190
+ !domainResponseOptionalMismatch &&
191
+ baselineTypeResolvable(baselineField.type, importableNames) &&
192
+ baselineFieldCompatible(baselineField, field)
193
+ ) {
194
+ const opt = baselineField.optional ? '?' : '';
195
+ lines.push(` ${domainFieldName}${opt}: ${baselineField.type};`);
196
+ } else {
197
+ const opt = !field.required ? '?' : '';
198
+ lines.push(` ${domainFieldName}${opt}: ${mapTypeRef(field.type)};`);
199
+ }
200
+ }
201
+ lines.push('}');
202
+ lines.push('');
203
+
204
+ // Wire/response interface (snake_case fields) — deduplicate by snake_case name
205
+ const seenWireFields = new Set<string>();
206
+ lines.push(`export interface ${responseName}${typeParams} {`);
207
+ for (const field of model.fields) {
208
+ const wireField = wireFieldName(field.name);
209
+ if (seenWireFields.has(wireField)) continue;
210
+ seenWireFields.add(wireField);
211
+ const baselineField = baselineResponse?.fields?.[wireField];
212
+ if (
213
+ baselineField &&
214
+ baselineTypeResolvable(baselineField.type, importableNames) &&
215
+ baselineFieldCompatible(baselineField, field)
216
+ ) {
217
+ const opt = baselineField.optional ? '?' : '';
218
+ lines.push(` ${wireField}${opt}: ${baselineField.type};`);
219
+ } else {
220
+ const opt = !field.required ? '?' : '';
221
+ lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type)};`);
222
+ }
223
+ }
224
+ lines.push('}');
225
+
226
+ files.push({
227
+ path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
228
+ content: lines.join('\n'),
229
+ skipIfExists: true,
230
+ });
231
+ }
232
+
233
+ return files;
234
+ }
235
+
236
+ /**
237
+ * Check if all PascalCase type references in a baseline type string
238
+ * can be resolved to types that are actually importable in the generated file.
239
+ * A type is importable if it's a builtin, or if it's among the set of names
240
+ * that will be imported (the model's own name/response, or its IR deps).
241
+ * Returns false if any reference is unresolvable (e.g., hand-written types
242
+ * from the live SDK, or spec types from other services not in IR deps).
243
+ */
244
+ function baselineTypeResolvable(typeStr: string, importableNames: Set<string>): boolean {
245
+ const matches = typeStr.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
246
+ if (!matches) return true;
247
+
248
+ for (const name of matches) {
249
+ if (BUILTINS.has(name)) continue;
250
+ if (importableNames.has(name)) continue;
251
+ return false;
252
+ }
253
+ return true;
254
+ }
255
+
256
+ /**
257
+ * Check if a baseline field type is compatible with the IR field for use
258
+ * in the generated interface. The serializer generates expressions based on
259
+ * the IR type, so the interface type must be assignable from the serializer output.
260
+ *
261
+ * Rejects baseline types when:
262
+ * - IR field is nullable but baseline type doesn't include `null`
263
+ * - IR field is optional but baseline says required (and vice versa)
264
+ * - IR field is required but baseline says optional
265
+ */
266
+ function baselineFieldCompatible(baselineField: { type: string; optional: boolean }, irField: Field): boolean {
267
+ const irNullable = irField.type.kind === 'nullable';
268
+ const baselineHasNull = baselineField.type.includes('null');
269
+
270
+ // If the IR field is nullable, the serializer produces `expr ?? null`,
271
+ // so the baseline type must include null to be assignable.
272
+ // Exception: for optional fields, the serializer's null guard converts
273
+ // null to undefined (`wireAccess != null ? expr : undefined`), so the
274
+ // result type is `T | undefined` which is compatible with `field?: T`.
275
+ if (irNullable && !baselineHasNull && irField.required) {
276
+ return false;
277
+ }
278
+
279
+ // If the IR field is optional, the serializer may produce undefined,
280
+ // so the baseline should also be optional (or include undefined)
281
+ if (!irField.required && !baselineField.optional && !baselineField.type.includes('undefined')) {
282
+ return false;
283
+ }
284
+
285
+ // If the IR field is required but the baseline says optional,
286
+ // the serializer produces a definite value but the interface is looser — that's OK
287
+ // (the domain type is wider than the serializer output)
288
+
289
+ return true;
290
+ }
291
+
292
+ function renderTypeParams(model: Model): string {
293
+ if (!model.typeParams?.length) return '';
294
+ const params = model.typeParams.map((tp) => {
295
+ const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
296
+ return `${tp.name}${def}`;
297
+ });
298
+ return `<${params.join(', ')}>`;
299
+ }
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
+ }
@@ -0,0 +1,96 @@
1
+ import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
+ import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase } from '@workos/oagen';
3
+
4
+ /** PascalCase class/interface name. */
5
+ export function className(name: string): string {
6
+ return toPascalCase(name);
7
+ }
8
+
9
+ /** kebab-case file name (without extension). */
10
+ export function fileName(name: string): string {
11
+ return toKebabCase(name);
12
+ }
13
+
14
+ /** camelCase method name. */
15
+ export function methodName(name: string): string {
16
+ return toCamelCase(name);
17
+ }
18
+
19
+ /** camelCase field name for domain interfaces. */
20
+ export function fieldName(name: string): string {
21
+ return toCamelCase(name);
22
+ }
23
+
24
+ /** snake_case field name for wire/response interfaces. */
25
+ export function wireFieldName(name: string): string {
26
+ return toSnakeCase(name);
27
+ }
28
+
29
+ /**
30
+ * Wire/response interface name. Uses "Wire" suffix when the domain name
31
+ * already ends in "Response" to avoid stuttering (e.g., FooResponseResponse).
32
+ */
33
+ export function wireInterfaceName(domainName: string): string {
34
+ return domainName.endsWith('Response') ? `${domainName}Wire` : `${domainName}Response`;
35
+ }
36
+
37
+ /** kebab-case service directory name. */
38
+ export function serviceDirName(name: string): string {
39
+ return toKebabCase(name);
40
+ }
41
+
42
+ /** camelCase property name for service accessors on the client. */
43
+ export function servicePropertyName(name: string): string {
44
+ return toCamelCase(name);
45
+ }
46
+
47
+ /**
48
+ * Resolve the effective service name, using the overlay-resolved class name
49
+ * when available. This ensures directory names, file names, and property names
50
+ * all derive from the same resolved name (e.g., "Mfa" instead of "MultiFactorAuth").
51
+ */
52
+ export function resolveServiceName(service: Service, ctx: EmitterContext): string {
53
+ return resolveClassName(service, ctx);
54
+ }
55
+
56
+ /**
57
+ * Build a map from IR service name → resolved service name.
58
+ * Used to translate modelToService/enumToService map values to overlay-resolved
59
+ * directory names when the code only has the IR service name string.
60
+ */
61
+ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): Map<string, string> {
62
+ const map = new Map<string, string>();
63
+ for (const service of services) {
64
+ map.set(service.name, resolveServiceName(service, ctx));
65
+ }
66
+ return map;
67
+ }
68
+
69
+ /** Resolve the SDK method name for an operation, checking overlay first. */
70
+ export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
71
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
72
+ const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
73
+ if (existing) return existing.methodName;
74
+ return toCamelCase(op.name);
75
+ }
76
+
77
+ /** Resolve the SDK class name for a service, checking overlay for existing names. */
78
+ export function resolveClassName(service: Service, ctx: EmitterContext): string {
79
+ // Check overlay's methodByOperation for any operation in this service
80
+ // to find the existing class name
81
+ if (ctx.overlayLookup?.methodByOperation) {
82
+ for (const op of service.operations) {
83
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
84
+ const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
85
+ if (existing) return existing.className;
86
+ }
87
+ }
88
+ return toPascalCase(service.name);
89
+ }
90
+
91
+ /** Resolve the interface name for a model, checking overlay first. */
92
+ export function resolveInterfaceName(name: string, ctx: EmitterContext): string {
93
+ const existing = ctx.overlayLookup?.interfaceByName?.get(name);
94
+ if (existing) return existing;
95
+ return toPascalCase(name);
96
+ }