@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,516 @@
1
+ /**
2
+ * Node SDK smoke test — captures wire-level HTTP exchanges from the generated
3
+ * WorkOS Node SDK and outputs SmokeResults JSON for diff comparison.
4
+ *
5
+ * Usage:
6
+ * npx tsx smoke/sdk-node.ts --spec ../openapi-spec/spec/open-api-spec.yaml --sdk-path ./sdk
7
+ *
8
+ * Requires API_KEY or WORKOS_API_KEY env var.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
12
+ import { resolve } from 'node:path';
13
+ import {
14
+ parseSpec,
15
+ planOperations,
16
+ generateCamelPayload,
17
+ generateCamelQueryParams,
18
+ IdRegistry,
19
+ delay,
20
+ parseCliArgs,
21
+ loadSmokeConfig,
22
+ getExpectedStatusCodes,
23
+ isUnexpectedStatus,
24
+ toCamelCase,
25
+ SERVICE_PROPERTY_MAP,
26
+ } from '@workos/oagen/smoke';
27
+ import type { CapturedExchange, SmokeResults, ExchangeProvenance } from '@workos/oagen/smoke';
28
+ import type { Operation } from '@workos/oagen';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ interface ManifestEntry {
35
+ sdkMethod: string;
36
+ service: string;
37
+ }
38
+
39
+ interface CapturedRequest {
40
+ method: string;
41
+ path: string;
42
+ queryParams: Record<string, string>;
43
+ body: unknown | null;
44
+ }
45
+
46
+ interface CapturedResponse {
47
+ status: number;
48
+ body: unknown | null;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // HTTP Interception
53
+ // ---------------------------------------------------------------------------
54
+
55
+ let currentCapture: { request: CapturedRequest; response: CapturedResponse } | null = null;
56
+ const originalFetch = globalThis.fetch;
57
+
58
+ function interceptFetch(): void {
59
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
60
+ const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
61
+ const method = (init?.method ?? 'GET').toUpperCase();
62
+ const path = url.pathname;
63
+ const queryParams: Record<string, string> = {};
64
+ url.searchParams.forEach((v, k) => {
65
+ queryParams[k] = v;
66
+ });
67
+
68
+ let body: unknown = null;
69
+ if (init?.body) {
70
+ try {
71
+ body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
72
+ } catch {
73
+ body = init.body;
74
+ }
75
+ }
76
+
77
+ const capturedReq: CapturedRequest = { method, path, queryParams, body };
78
+
79
+ const response = await originalFetch(input, init);
80
+ const cloned = response.clone();
81
+
82
+ let responseBody: unknown = null;
83
+ try {
84
+ responseBody = await cloned.json();
85
+ } catch {
86
+ // Not JSON — that's fine
87
+ }
88
+
89
+ currentCapture = {
90
+ request: capturedReq,
91
+ response: { status: response.status, body: responseBody },
92
+ };
93
+
94
+ return response;
95
+ };
96
+ }
97
+
98
+ function restoreFetch(): void {
99
+ globalThis.fetch = originalFetch;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Manifest loading
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
107
+ const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
108
+ if (!existsSync(manifestPath)) {
109
+ console.warn(`⚠ No smoke-manifest.json found at ${manifestPath}`);
110
+ console.warn(' Method resolution will rely on heuristic tiers — most operations may be skipped.');
111
+ return null;
112
+ }
113
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
114
+ const manifest = new Map<string, ManifestEntry>();
115
+ for (const [httpKey, entry] of Object.entries(raw)) {
116
+ manifest.set(httpKey, entry as ManifestEntry);
117
+ }
118
+ return manifest;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Method resolution
123
+ // ---------------------------------------------------------------------------
124
+
125
+ interface MethodResolution {
126
+ service: string;
127
+ method: string;
128
+ tier: ExchangeProvenance['resolutionTier'];
129
+ confidence: number;
130
+ }
131
+
132
+ function resolveMethod(
133
+ op: Operation,
134
+ irService: string,
135
+ manifest: Map<string, ManifestEntry> | null,
136
+ ): MethodResolution | null {
137
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
138
+
139
+ // Tier 0: Manifest match (primary for generated SDKs)
140
+ if (manifest) {
141
+ const entry = manifest.get(httpKey);
142
+ if (entry) {
143
+ return {
144
+ service: entry.service,
145
+ method: entry.sdkMethod,
146
+ tier: 'manifest',
147
+ confidence: 1.0,
148
+ };
149
+ }
150
+ }
151
+
152
+ // Tier 1: Exact match — IR operation name in camelCase
153
+ const sdkProp = SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService);
154
+ const exactName = toCamelCase(op.name);
155
+ return {
156
+ service: sdkProp,
157
+ method: exactName,
158
+ tier: 'exact',
159
+ confidence: 0.8,
160
+ };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Argument construction
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function buildArgs(op: Operation, pathParams: Record<string, string>, spec: any): unknown[] {
168
+ const args: unknown[] = [];
169
+
170
+ // Positional path params
171
+ if (op.pathParams.length > 0) {
172
+ if (op.pathParams.length === 1 && !op.requestBody && op.queryParams.filter((p) => p.required).length === 0) {
173
+ // Simple case: single path param, no body/query → positional arg
174
+ args.push(pathParams[op.pathParams[0].name]);
175
+ } else {
176
+ // Multiple path params → individual positional args
177
+ for (const p of op.pathParams) {
178
+ args.push(pathParams[p.name]);
179
+ }
180
+ }
181
+ }
182
+
183
+ // Request body
184
+ if (op.requestBody) {
185
+ const payload = generateCamelPayload(op, spec);
186
+ if (payload) args.push(payload);
187
+ }
188
+
189
+ // Query params (for non-paginated GETs with required query params)
190
+ if (!op.requestBody && op.queryParams.some((p) => p.required)) {
191
+ const queryOpts = generateCamelQueryParams(op, spec);
192
+ if (Object.keys(queryOpts).length > 0) args.push(queryOpts);
193
+ }
194
+
195
+ // Paginated operations may pass options
196
+ if (op.pagination && args.length === 0) {
197
+ args.push({ limit: 1 });
198
+ } else if (op.pagination && !op.requestBody) {
199
+ // If we already have path param args but it's paginated, merge limit
200
+ const lastArg = args[args.length - 1];
201
+ if (typeof lastArg === 'object' && lastArg !== null) {
202
+ (lastArg as Record<string, unknown>)['limit'] = 1;
203
+ } else {
204
+ args.push({ limit: 1 });
205
+ }
206
+ }
207
+
208
+ // Idempotent POST: append empty options for idempotency key slot
209
+ if (op.injectIdempotencyKey && op.httpMethod === 'post') {
210
+ args.push({});
211
+ }
212
+
213
+ return args;
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Main
218
+ // ---------------------------------------------------------------------------
219
+
220
+ async function main(): Promise<void> {
221
+ const { spec: specPath, sdkPath, smokeConfig } = parseCliArgs();
222
+
223
+ if (!sdkPath) {
224
+ console.error('--sdk-path is required');
225
+ process.exit(1);
226
+ }
227
+
228
+ const apiKey = process.env.WORKOS_API_KEY || process.env.API_KEY;
229
+ if (!apiKey) {
230
+ console.error('API key required. Set WORKOS_API_KEY or API_KEY env var.');
231
+ process.exit(1);
232
+ }
233
+
234
+ // Load config
235
+ loadSmokeConfig(smokeConfig);
236
+
237
+ // Parse spec
238
+ console.log('Parsing spec...');
239
+ const spec = await parseSpec(specPath);
240
+ console.log(`Spec: ${spec.name} v${spec.version}`);
241
+
242
+ // Load manifest
243
+ const manifest = loadManifest(sdkPath);
244
+
245
+ // Import SDK dynamically from the sdk-path
246
+ const sdkEntryPoint = resolve(sdkPath, 'src/index.ts');
247
+ const sdkModule = await import(sdkEntryPoint);
248
+ const WorkOS = sdkModule.WorkOS || sdkModule.default?.WorkOS;
249
+
250
+ if (!WorkOS) {
251
+ console.error(`Could not find WorkOS class in ${sdkEntryPoint}`);
252
+ process.exit(1);
253
+ }
254
+
255
+ const baseUrl = process.env.WORKOS_BASE_URL || spec.baseUrl;
256
+ const client = new WorkOS({ apiKey, apiHostname: new URL(baseUrl).hostname });
257
+
258
+ // Plan operations
259
+ const groups = planOperations(spec);
260
+ const ids = new IdRegistry();
261
+ const exchanges: CapturedExchange[] = [];
262
+ const createdEntities: Array<{ service: string; id: string; deleteFn?: () => Promise<void> }> = [];
263
+ const delayMs = Number(process.env.SMOKE_DELAY_MS) || 200;
264
+
265
+ let successCount = 0;
266
+ let errorCount = 0;
267
+ let skipCount = 0;
268
+ let unexpectedCount = 0;
269
+
270
+ interceptFetch();
271
+
272
+ try {
273
+ for (const group of groups) {
274
+ console.log(`\n--- ${group.service} (${group.operations.length} operations) ---`);
275
+
276
+ for (const planned of group.operations) {
277
+ const { operation: op, service: irService } = planned;
278
+ const isTopLevel = op.pathParams.length === 0;
279
+
280
+ // Resolve SDK method
281
+ const resolution = resolveMethod(op, irService, manifest);
282
+ if (!resolution) {
283
+ console.log(` SKIP ${op.name} — no matching SDK method`);
284
+ exchanges.push(makeSkippedExchange(op, irService, 'No matching SDK method'));
285
+ skipCount++;
286
+ continue;
287
+ }
288
+
289
+ // Get the resource accessor and method
290
+ const resource = (client as any)[resolution.service];
291
+ if (!resource) {
292
+ console.log(` SKIP ${op.name} — service "${resolution.service}" not found on client`);
293
+ exchanges.push(makeSkippedExchange(op, irService, `Service "${resolution.service}" not found`));
294
+ skipCount++;
295
+ continue;
296
+ }
297
+
298
+ const methodFn = resource[resolution.method];
299
+ if (typeof methodFn !== 'function') {
300
+ console.log(` SKIP ${op.name} — method "${resolution.method}" not found on ${resolution.service}`);
301
+ exchanges.push(
302
+ makeSkippedExchange(op, irService, `Method "${resolution.method}" not found on ${resolution.service}`),
303
+ );
304
+ skipCount++;
305
+ continue;
306
+ }
307
+
308
+ // Resolve path params
309
+ let pathParams: Record<string, string> = {};
310
+ if (op.pathParams.length > 0) {
311
+ const resolved = ids.resolvePathParams(op, irService);
312
+ if (!resolved) {
313
+ console.log(` SKIP ${op.name} — missing path param IDs`);
314
+ exchanges.push(makeSkippedExchange(op, irService, 'Missing path param IDs'));
315
+ skipCount++;
316
+ continue;
317
+ }
318
+ pathParams = resolved;
319
+ }
320
+
321
+ // Build arguments
322
+ const args = buildArgs(op, pathParams, spec);
323
+
324
+ // Execute
325
+ currentCapture = null;
326
+ const start = Date.now();
327
+
328
+ try {
329
+ await methodFn.call(resource, ...args);
330
+ const elapsed = Date.now() - start;
331
+
332
+ if (!currentCapture) {
333
+ console.log(` SKIP ${op.name} — no HTTP capture (method may not make HTTP calls)`);
334
+ exchanges.push(makeSkippedExchange(op, irService, 'No HTTP capture'));
335
+ skipCount++;
336
+ continue;
337
+ }
338
+
339
+ const exchange = buildExchange(op, irService, currentCapture, elapsed, resolution);
340
+
341
+ // Extract IDs from response
342
+ const responseBody = currentCapture.response.body;
343
+ ids.extractAndStore(irService, responseBody, isTopLevel);
344
+
345
+ // Track created entities for cleanup
346
+ if (op.httpMethod === 'post' && currentCapture.response.status < 300) {
347
+ const body = responseBody as Record<string, unknown> | null;
348
+ if (body?.id && typeof body.id === 'string') {
349
+ // Find the delete method for this service
350
+ const deleteResolution = findDeleteMethod(irService, manifest);
351
+ if (deleteResolution) {
352
+ const deleteResource = (client as any)[deleteResolution.service];
353
+ const deleteFn = deleteResource?.[deleteResolution.method];
354
+ if (typeof deleteFn === 'function') {
355
+ createdEntities.push({
356
+ service: irService,
357
+ id: body.id as string,
358
+ deleteFn: () => deleteFn.call(deleteResource, body.id),
359
+ });
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ if (exchange.unexpectedStatus) {
366
+ unexpectedCount++;
367
+ console.log(` ⚠ ${op.name} → ${currentCapture.response.status} (unexpected)`);
368
+ } else if (exchange.outcome === 'api-error') {
369
+ errorCount++;
370
+ console.log(` ✗ ${op.name} → ${currentCapture.response.status}`);
371
+ } else {
372
+ successCount++;
373
+ console.log(` ✓ ${op.name} → ${currentCapture.response.status} (${elapsed}ms)`);
374
+ }
375
+
376
+ exchanges.push(exchange);
377
+ } catch (err) {
378
+ const elapsed = Date.now() - start;
379
+ const message = err instanceof Error ? err.message : String(err);
380
+
381
+ if (currentCapture) {
382
+ const exchange = buildExchange(op, irService, currentCapture, elapsed, resolution);
383
+ exchange.error = message;
384
+ exchanges.push(exchange);
385
+ errorCount++;
386
+ console.log(` ✗ ${op.name} → ${currentCapture.response.status} (${message})`);
387
+ } else {
388
+ exchanges.push({
389
+ ...makeSkippedExchange(op, irService, message),
390
+ outcome: 'api-error',
391
+ durationMs: elapsed,
392
+ });
393
+ errorCount++;
394
+ console.log(` ✗ ${op.name} — ${message}`);
395
+ }
396
+ }
397
+
398
+ await delay(delayMs);
399
+ }
400
+ }
401
+
402
+ // Cleanup created entities (reverse order)
403
+ if (createdEntities.length > 0) {
404
+ console.log(`\n--- Cleanup (${createdEntities.length} entities) ---`);
405
+ for (const entity of createdEntities.reverse()) {
406
+ try {
407
+ if (entity.deleteFn) {
408
+ await entity.deleteFn();
409
+ console.log(` ✓ Deleted ${entity.service} ${entity.id}`);
410
+ }
411
+ } catch (err) {
412
+ const message = err instanceof Error ? err.message : String(err);
413
+ console.log(` ✗ Failed to delete ${entity.service} ${entity.id}: ${message}`);
414
+ }
415
+ await delay(delayMs);
416
+ }
417
+ }
418
+ } finally {
419
+ restoreFetch();
420
+ }
421
+
422
+ // Write results
423
+ const results: SmokeResults = {
424
+ source: 'sdk-node',
425
+ timestamp: new Date().toISOString(),
426
+ specVersion: spec.version,
427
+ exchanges,
428
+ };
429
+
430
+ const outputPath = `smoke-results-sdk-node.json`;
431
+ writeFileSync(outputPath, JSON.stringify(results, null, 2));
432
+ console.log(`\nResults written to ${outputPath}`);
433
+
434
+ // Summary
435
+ const total = exchanges.length;
436
+ console.log(`\n=== Summary ===`);
437
+ console.log(` Total: ${total}`);
438
+ console.log(` Success: ${successCount}`);
439
+ console.log(` API errors: ${errorCount}`);
440
+ console.log(` Skipped: ${skipCount}`);
441
+ if (unexpectedCount > 0) {
442
+ console.log(` Unexpected: ${unexpectedCount}`);
443
+ }
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Helpers
448
+ // ---------------------------------------------------------------------------
449
+
450
+ function makeSkippedExchange(op: Operation, service: string, reason: string): CapturedExchange {
451
+ return {
452
+ operationId: op.name,
453
+ service,
454
+ operationName: op.name,
455
+ request: { method: op.httpMethod.toUpperCase(), path: op.path, queryParams: {}, body: null },
456
+ response: { status: 0, body: null },
457
+ outcome: 'skipped',
458
+ error: reason,
459
+ durationMs: 0,
460
+ };
461
+ }
462
+
463
+ function buildExchange(
464
+ op: Operation,
465
+ service: string,
466
+ capture: NonNullable<typeof currentCapture>,
467
+ durationMs: number,
468
+ resolution: MethodResolution,
469
+ ): CapturedExchange {
470
+ const status = capture.response.status;
471
+ const expectedCodes = getExpectedStatusCodes(op);
472
+ const unexpected = isUnexpectedStatus(status, op);
473
+
474
+ return {
475
+ operationId: op.name,
476
+ service,
477
+ operationName: op.name,
478
+ request: capture.request,
479
+ response: capture.response,
480
+ outcome: status >= 200 && status < 300 ? 'success' : 'api-error',
481
+ unexpectedStatus: unexpected || undefined,
482
+ expectedStatusCodes: expectedCodes,
483
+ durationMs,
484
+ provenance: {
485
+ resolutionTier: resolution.tier,
486
+ resolutionConfidence: resolution.confidence,
487
+ sdkMethodName: `${resolution.service}.${resolution.method}`,
488
+ captureIndex: 0,
489
+ totalCaptures: 1,
490
+ },
491
+ };
492
+ }
493
+
494
+ function findDeleteMethod(irService: string, manifest: Map<string, ManifestEntry> | null): MethodResolution | null {
495
+ if (!manifest) return null;
496
+ // Look for a DELETE method in the manifest that belongs to this service
497
+ for (const [httpKey, entry] of manifest.entries()) {
498
+ if (
499
+ httpKey.startsWith('DELETE ') &&
500
+ entry.service === (SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService))
501
+ ) {
502
+ return {
503
+ service: entry.service,
504
+ method: entry.sdkMethod,
505
+ tier: 'manifest',
506
+ confidence: 1.0,
507
+ };
508
+ }
509
+ }
510
+ return null;
511
+ }
512
+
513
+ main().catch((err) => {
514
+ console.error('Fatal error:', err);
515
+ process.exit(1);
516
+ });