@workos/oagen-emitters 0.15.2 → 0.16.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 (46) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +48 -1
  4. package/dist/index.d.mts +51 -2
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +852 -2
  7. package/dist/index.mjs.map +1 -0
  8. package/dist/{plugin-Xkr83G9A.mjs → plugin-CpO8rePT.mjs} +1219 -493
  9. package/dist/plugin-CpO8rePT.mjs.map +1 -0
  10. package/dist/plugin.mjs +1 -1
  11. package/package.json +7 -7
  12. package/src/dotnet/naming.ts +1 -1
  13. package/src/go/naming.ts +1 -1
  14. package/src/index.ts +15 -0
  15. package/src/node/enums.ts +17 -4
  16. package/src/node/index.ts +264 -4
  17. package/src/node/live-surface.ts +309 -0
  18. package/src/node/models.ts +69 -3
  19. package/src/node/naming.ts +204 -23
  20. package/src/node/resources.ts +39 -3
  21. package/src/node/tests.ts +29 -3
  22. package/src/node/utils.ts +140 -22
  23. package/src/snippets/dotnet.ts +159 -0
  24. package/src/snippets/go.ts +148 -0
  25. package/src/snippets/index.ts +8 -0
  26. package/src/snippets/kotlin.ts +144 -0
  27. package/src/snippets/php.ts +149 -0
  28. package/src/snippets/plugin.ts +36 -0
  29. package/src/snippets/python.ts +135 -0
  30. package/src/snippets/ruby.ts +152 -0
  31. package/src/snippets/rust.ts +189 -0
  32. package/test/node/enums.test.ts +239 -2
  33. package/test/node/live-surface.test.ts +771 -1
  34. package/test/node/models.test.ts +738 -3
  35. package/test/node/naming.test.ts +159 -0
  36. package/test/node/resources.test.ts +464 -0
  37. package/test/node/utils.test.ts +157 -2
  38. package/test/snippets/_helpers.ts +67 -0
  39. package/test/snippets/dotnet.test.ts +49 -0
  40. package/test/snippets/go.test.ts +94 -0
  41. package/test/snippets/kotlin.test.ts +53 -0
  42. package/test/snippets/php.test.ts +48 -0
  43. package/test/snippets/python.test.ts +73 -0
  44. package/test/snippets/ruby.test.ts +339 -0
  45. package/test/snippets/rust.test.ts +76 -0
  46. package/dist/plugin-Xkr83G9A.mjs.map +0 -1
@@ -0,0 +1,189 @@
1
+ import type { EmitterContext, ExampleBuilder, ResolvedOperation, SnippetArg, SnippetEmitter } from '@workos/oagen';
2
+ import { collectSnippetArgs, collectWrapperArgs } from '@workos/oagen';
3
+ import { fieldName, methodName, moduleName, resourceAccessorName, typeName } from '../rust/naming.js';
4
+
5
+ const INDENT = ' ';
6
+
7
+ export const rustSnippetEmitter: SnippetEmitter = {
8
+ language: 'rust',
9
+ fileExtension: 'rs',
10
+
11
+ renderOperation(resolved, ctx, examples) {
12
+ if (resolved.urlBuilder) return null;
13
+
14
+ const method = methodName(
15
+ resolved.wrappers && resolved.wrappers.length > 0 ? resolved.wrappers[0]!.name : resolved.methodName,
16
+ );
17
+
18
+ return renderCall(resolved, ctx, examples, method);
19
+ },
20
+ };
21
+
22
+ function renderCall(
23
+ resolved: ResolvedOperation,
24
+ ctx: EmitterContext,
25
+ examples: ExampleBuilder,
26
+ method: string,
27
+ ): string {
28
+ const accessor = resourceAccessorName(resolved.mountOn);
29
+ const modulePath = moduleName(resolved.mountOn);
30
+ const paramsStructName = `${typeName(
31
+ resolved.wrappers && resolved.wrappers.length > 0 ? resolved.wrappers[0]!.name : resolved.methodName,
32
+ )}Params`;
33
+
34
+ let args: SnippetArg[];
35
+ let pathArgs: SnippetArg[];
36
+ let structArgs: SnippetArg[];
37
+
38
+ if (resolved.wrappers && resolved.wrappers.length > 0) {
39
+ args = collectWrapperArgs(resolved.wrappers[0]!, ctx, examples);
40
+ pathArgs = [];
41
+ structArgs = args;
42
+ } else {
43
+ const collected = collectSnippetArgs(resolved, ctx, examples);
44
+ args = collected.args;
45
+ pathArgs = args.filter((a) => a.source === 'path');
46
+ structArgs = args.filter((a) => a.source !== 'path');
47
+ }
48
+
49
+ const imports: string[] = ['use workos::Client;'];
50
+ if (structArgs.length > 0) {
51
+ imports.push(`use workos::${modulePath}::${paramsStructName};`);
52
+ }
53
+
54
+ const lines: string[] = [];
55
+ lines.push(...imports);
56
+ lines.push('');
57
+ lines.push('#[tokio::main]');
58
+ lines.push('async fn main() -> Result<(), workos::Error> {');
59
+ lines.push(`${INDENT}let client = Client::builder()`);
60
+ lines.push(`${INDENT}${INDENT}.api_key("sk_example_123456789")`);
61
+ lines.push(`${INDENT}${INDENT}.client_id("client_123456789")`);
62
+ lines.push(`${INDENT}${INDENT}.build();`);
63
+ lines.push('');
64
+
65
+ const callParts: string[] = [];
66
+ for (const p of pathArgs) {
67
+ const v = p.value;
68
+ if (typeof v === 'string') callParts.push(rustString(v));
69
+ else callParts.push(renderValue(v));
70
+ }
71
+ if (structArgs.length > 0) {
72
+ callParts.push(renderStructLiteral(paramsStructName, structArgs));
73
+ }
74
+
75
+ const callLines: string[] = [`${INDENT}let _result = client`, `${INDENT}${INDENT}.${accessor}()`];
76
+ if (callParts.length === 0) {
77
+ callLines.push(`${INDENT}${INDENT}.${method}()`);
78
+ } else if (callParts.length === 1 && !callParts[0]!.includes('\n')) {
79
+ callLines.push(`${INDENT}${INDENT}.${method}(${callParts[0]})`);
80
+ } else {
81
+ callLines.push(`${INDENT}${INDENT}.${method}(`);
82
+ for (let i = 0; i < callParts.length; i++) {
83
+ const trailing = i < callParts.length - 1 ? ',' : '';
84
+ const indented = indentContinuationLines(callParts[i]!, `${INDENT}${INDENT}${INDENT}`);
85
+ callLines.push(`${INDENT}${INDENT}${INDENT}${indented}${trailing}`);
86
+ }
87
+ callLines.push(`${INDENT}${INDENT})`);
88
+ }
89
+ callLines.push(`${INDENT}${INDENT}.await?;`);
90
+ lines.push(...callLines);
91
+
92
+ lines.push('');
93
+ lines.push(`${INDENT}Ok(())`);
94
+ lines.push('}');
95
+
96
+ return lines.join('\n');
97
+ }
98
+
99
+ function renderStructLiteral(structName: string, args: SnippetArg[]): string {
100
+ const lines: string[] = [`${structName} {`];
101
+ for (const a of args) {
102
+ const field = fieldName(a.wireName);
103
+ const value = renderStructValue(a.value);
104
+ lines.push(`${INDENT}${field}: ${indentContinuationLines(value, INDENT)},`);
105
+ }
106
+ lines.push(`${INDENT}..Default::default()`);
107
+ lines.push('}');
108
+ return lines.join('\n');
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Rust literal rendering
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** Top-level value rendering (no `.into()` wrap — used for path params etc.). */
116
+ function renderValue(value: unknown): string {
117
+ if (value === null || value === undefined) return 'None';
118
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
119
+ if (typeof value === 'number') return String(value);
120
+ if (typeof value === 'string') return rustString(value);
121
+ if (Array.isArray(value)) return renderVec(value);
122
+ if (typeof value === 'object') return renderInlineObject(value as Record<string, unknown>);
123
+ return 'None';
124
+ }
125
+
126
+ /** Struct-field rendering: wraps strings with `.into()` so `String` fields accept &str. */
127
+ function renderStructValue(value: unknown): string {
128
+ if (typeof value === 'string') return `${rustString(value)}.into()`;
129
+ if (Array.isArray(value)) return renderVecStructValues(value);
130
+ return renderValue(value);
131
+ }
132
+
133
+ function rustString(s: string): string {
134
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
135
+ }
136
+
137
+ function renderVec(items: unknown[]): string {
138
+ if (items.length === 0) return 'vec![]';
139
+ const rendered = items.map((v) => renderValue(v));
140
+ const oneline = `vec![${rendered.join(', ')}]`;
141
+ if (oneline.length <= 80 && rendered.every((r) => !r.includes('\n'))) return oneline;
142
+ const lines: string[] = ['vec!['];
143
+ for (let i = 0; i < rendered.length; i++) {
144
+ const trailing = i < rendered.length - 1 ? ',' : ',';
145
+ lines.push(`${INDENT}${indentContinuationLines(rendered[i]!, INDENT)}${trailing}`);
146
+ }
147
+ lines.push(']');
148
+ return lines.join('\n');
149
+ }
150
+
151
+ function renderVecStructValues(items: unknown[]): string {
152
+ if (items.length === 0) return 'vec![]';
153
+ const rendered = items.map((v) => renderStructValue(v));
154
+ const oneline = `vec![${rendered.join(', ')}]`;
155
+ if (oneline.length <= 80 && rendered.every((r) => !r.includes('\n'))) return oneline;
156
+ const lines: string[] = ['vec!['];
157
+ for (let i = 0; i < rendered.length; i++) {
158
+ const trailing = i < rendered.length - 1 ? ',' : ',';
159
+ lines.push(`${INDENT}${indentContinuationLines(rendered[i]!, INDENT)}${trailing}`);
160
+ }
161
+ lines.push(']');
162
+ return lines.join('\n');
163
+ }
164
+
165
+ /** Render a plain object as a generic `serde_json::json!({...})` literal. The
166
+ * snippet doesn't know the exact Rust struct corresponding to each nested
167
+ * object, so we fall back to a serde-friendly literal a developer can swap
168
+ * for the concrete struct (e.g. `OrganizationDomainData { ... }`). */
169
+ function renderInlineObject(obj: Record<string, unknown>): string {
170
+ const entries = Object.entries(obj);
171
+ if (entries.length === 0) return 'serde_json::json!({})';
172
+ const rendered = entries.map(([k, v]) => ({ key: k, value: renderValue(v) }));
173
+ const oneline = `serde_json::json!({ ${rendered.map((e) => `"${e.key}": ${e.value}`).join(', ')} })`;
174
+ if (oneline.length <= 80 && rendered.every((e) => !e.value.includes('\n'))) return oneline;
175
+ const lines: string[] = ['serde_json::json!({'];
176
+ for (let i = 0; i < rendered.length; i++) {
177
+ const e = rendered[i]!;
178
+ const trailing = i < rendered.length - 1 ? ',' : ',';
179
+ lines.push(`${INDENT}"${e.key}": ${indentContinuationLines(e.value, INDENT)}${trailing}`);
180
+ }
181
+ lines.push('})');
182
+ return lines.join('\n');
183
+ }
184
+
185
+ function indentContinuationLines(s: string, indent: string): string {
186
+ if (!s.includes('\n')) return s;
187
+ const lines = s.split('\n');
188
+ return lines.map((line, i) => (i === 0 ? line : `${indent}${line}`)).join('\n');
189
+ }
@@ -1,7 +1,13 @@
1
- import { describe, it, expect } from 'vitest';
2
- import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Enum, Model } from '@workos/oagen';
3
3
  import { defaultSdkBehavior } from '@workos/oagen';
4
4
  import { generateEnums } from '../../src/node/enums.js';
5
+ import { nodeEmitter } from '../../src/node/index.js';
6
+ import { emptyLiveSurface, setActiveLiveSurface } from '../../src/node/live-surface.js';
7
+ import * as fs from 'node:fs';
8
+ import * as os from 'node:os';
9
+ import * as path from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
5
11
 
6
12
  const emptySpec: ApiSpec = {
7
13
  name: 'Test',
@@ -126,3 +132,234 @@ describe('generateEnums', () => {
126
132
  expect(result[0].content).toContain('/** @deprecated */');
127
133
  });
128
134
  });
135
+
136
+ describe('assignEnumsToServices owned-service dependency reassignment', () => {
137
+ it('follows a reassigned dependency model into the owned service', () => {
138
+ // The enum is referenced only through `AuditLogsRetention`, whose
139
+ // first-reference assignment is Organizations (unemittable this run).
140
+ // When the owned AuditLogs service pulls the model into `audit-logs/`,
141
+ // the enum must follow — otherwise the model file imports an enum file
142
+ // that is emitted nowhere.
143
+ const surface = emptyLiveSurface();
144
+ surface.files.add('src/workos.ts'); // existing SDK
145
+ setActiveLiveSurface(surface);
146
+ try {
147
+ const enums: Enum[] = [
148
+ {
149
+ name: 'RetentionPeriod',
150
+ values: [
151
+ { name: 'THIRTY_DAYS', value: '30d' },
152
+ { name: 'NINETY_DAYS', value: '90d' },
153
+ ],
154
+ },
155
+ ];
156
+ const models = [
157
+ {
158
+ name: 'AuditLogsRetention',
159
+ fields: [
160
+ {
161
+ name: 'period',
162
+ type: { kind: 'enum' as const, name: 'RetentionPeriod', values: ['30d', '90d'] },
163
+ required: true,
164
+ },
165
+ ],
166
+ },
167
+ ];
168
+ const retentionOp = (name: string, path: string) => ({
169
+ name,
170
+ httpMethod: 'get' as const,
171
+ path,
172
+ pathParams: [],
173
+ queryParams: [],
174
+ headerParams: [],
175
+ response: { kind: 'model' as const, name: 'AuditLogsRetention' },
176
+ errors: [],
177
+ injectIdempotencyKey: false,
178
+ });
179
+ const services = [
180
+ { name: 'Organizations', operations: [retentionOp('getRetention', '/organizations/{id}/retention')] },
181
+ { name: 'AuditLogs', operations: [retentionOp('getAuditLogsRetention', '/audit_logs/retention')] },
182
+ ];
183
+ const ctxOwned: EmitterContext = {
184
+ ...ctx,
185
+ spec: { ...emptySpec, enums, models, services },
186
+ emitterOptions: { ownedServices: ['AuditLogs'] },
187
+ } as EmitterContext;
188
+
189
+ const result = generateEnums(enums, ctxOwned);
190
+ expect(result).toHaveLength(1);
191
+ expect(result[0].path).toBe('src/audit-logs/interfaces/retention-period.interface.ts');
192
+ } finally {
193
+ setActiveLiveSurface(emptyLiveSurface());
194
+ }
195
+ });
196
+ });
197
+
198
+ describe('owned-service enum emission under the live-surface skip', () => {
199
+ function ownedDomainSpec(enums: Enum[], models: Model[]): ApiSpec {
200
+ return {
201
+ ...emptySpec,
202
+ enums,
203
+ models,
204
+ services: [
205
+ {
206
+ name: 'OrganizationDomains',
207
+ operations: [
208
+ {
209
+ name: 'getOrganizationDomain',
210
+ httpMethod: 'get',
211
+ path: '/organization_domains/{id}',
212
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
213
+ queryParams: [],
214
+ headerParams: [],
215
+ response: { kind: 'model', name: 'OrganizationDomain' },
216
+ errors: [],
217
+ injectIdempotencyKey: false,
218
+ },
219
+ ],
220
+ },
221
+ ],
222
+ };
223
+ }
224
+
225
+ const stateEnum: Enum = {
226
+ name: 'OrganizationDomainState',
227
+ values: [
228
+ { name: 'VERIFIED', value: 'verified' },
229
+ { name: 'PENDING', value: 'pending' },
230
+ ],
231
+ };
232
+ const domainModel: Model = {
233
+ name: 'OrganizationDomain',
234
+ fields: [
235
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
236
+ {
237
+ name: 'state',
238
+ type: { kind: 'enum', name: 'OrganizationDomainState', values: ['verified', 'pending'] },
239
+ required: true,
240
+ },
241
+ ],
242
+ };
243
+
244
+ it('still emits the union module when the name is declared in a file the owned regeneration overwrites', () => {
245
+ // Real instance (OrganizationDomains rebuild, service OWNED): the
246
+ // on-disk organization-domain.interface.ts declared the enum names, so
247
+ // the live-surface skip suppressed emitting the canonical modules — but
248
+ // that very file was simultaneously being OVERWRITTEN by the owned
249
+ // regeneration, leaving the names declared nowhere.
250
+ const surface = emptyLiveSurface();
251
+ surface.files.add('src/workos.ts'); // existing SDK
252
+ surface.files.add('src/organization-domains/interfaces/organization-domain.interface.ts');
253
+ surface.interfaces.set('OrganizationDomainState', {
254
+ filePath: 'src/organization-domains/interfaces/organization-domain.interface.ts',
255
+ fields: new Set(),
256
+ });
257
+ setActiveLiveSurface(surface);
258
+ try {
259
+ const ctxOwned: EmitterContext = {
260
+ ...ctx,
261
+ spec: ownedDomainSpec([stateEnum], [domainModel]),
262
+ emitterOptions: { ownedServices: ['OrganizationDomains'] },
263
+ } as EmitterContext;
264
+
265
+ const result = generateEnums([stateEnum], ctxOwned);
266
+ const enumFile = result.find(
267
+ (f) => f.path === 'src/organization-domains/interfaces/organization-domain-state.interface.ts',
268
+ );
269
+ expect(enumFile).toBeDefined();
270
+ expect(enumFile!.content).toContain('export const OrganizationDomainState = {');
271
+ expect(enumFile!.content).toContain('export type OrganizationDomainState =');
272
+ } finally {
273
+ setActiveLiveSurface(emptyLiveSurface());
274
+ }
275
+ });
276
+
277
+ it('keeps the skip for non-owned services whose enum genuinely lives elsewhere', () => {
278
+ const surface = emptyLiveSurface();
279
+ surface.files.add('src/workos.ts');
280
+ surface.interfaces.set('OrganizationDomainState', {
281
+ filePath: 'src/common/interfaces/organization-domain-state.interface.ts',
282
+ fields: new Set(),
283
+ });
284
+ setActiveLiveSurface(surface);
285
+ try {
286
+ const ctxNotOwned: EmitterContext = {
287
+ ...ctx,
288
+ spec: ownedDomainSpec([stateEnum], [domainModel]),
289
+ } as EmitterContext;
290
+
291
+ const result = generateEnums([stateEnum], ctxNotOwned);
292
+ expect(result).toHaveLength(0);
293
+ } finally {
294
+ setActiveLiveSurface(emptyLiveSurface());
295
+ }
296
+ });
297
+
298
+ it('emits the module, resolves the barrel export, and imports the name in the model file', () => {
299
+ // End-to-end shape of the OrganizationDomains failure: generated
300
+ // organization-domain.interface.ts used `OrganizationDomainState` with
301
+ // NO import, and interfaces/index.ts exported
302
+ // ./organization-domain-state.interface — a module no hook ever emitted.
303
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-owned-enum-'));
304
+ try {
305
+ const ifaceDir = path.join(tmpRoot, 'src', 'organization-domains', 'interfaces');
306
+ fs.mkdirSync(ifaceDir, { recursive: true });
307
+ fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), 'export class WorkOS {}\n');
308
+ fs.writeFileSync(
309
+ path.join(ifaceDir, 'organization-domain.interface.ts'),
310
+ [
311
+ "export type OrganizationDomainState = 'verified' | 'pending';",
312
+ '',
313
+ 'export interface OrganizationDomain {',
314
+ ' id: string;',
315
+ ' state: OrganizationDomainState;',
316
+ '}',
317
+ ].join('\n'),
318
+ );
319
+ execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
320
+ execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
321
+
322
+ const spec = ownedDomainSpec([stateEnum], [domainModel]);
323
+ const runCtx = {
324
+ ...ctx,
325
+ spec,
326
+ outputDir: tmpRoot,
327
+ emitterOptions: { ownedServices: ['OrganizationDomains'] },
328
+ } as EmitterContext;
329
+
330
+ const modelFiles = nodeEmitter.generateModels([domainModel], runCtx);
331
+ const enumFiles = nodeEmitter.generateEnums([stateEnum], runCtx);
332
+ const clientFiles = nodeEmitter.generateClient(spec, runCtx);
333
+
334
+ // The canonical union module IS emitted…
335
+ const enumPath = 'src/organization-domains/interfaces/organization-domain-state.interface.ts';
336
+ expect(enumFiles.some((f) => f.path === enumPath)).toBe(true);
337
+
338
+ // …the model file imports the name from it…
339
+ const modelFile = modelFiles.find(
340
+ (f) => f.path === 'src/organization-domains/interfaces/organization-domain.interface.ts',
341
+ );
342
+ expect(modelFile).toBeDefined();
343
+ expect(modelFile!.content).toContain(
344
+ "import type { OrganizationDomainState } from './organization-domain-state.interface';",
345
+ );
346
+
347
+ // …and the barrel export resolves to an emitted module instead of a
348
+ // phantom the import-invariant pass has to drop.
349
+ const barrel = clientFiles.find((f) => f.path === 'src/organization-domains/interfaces/index.ts');
350
+ expect(barrel).toBeDefined();
351
+ expect(barrel!.content).toContain("export * from './organization-domain-state.interface';");
352
+
353
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
354
+ try {
355
+ nodeEmitter.generateTests(spec, runCtx);
356
+ const dropped = warnSpy.mock.calls.filter((call) => String(call[0]).includes('dropped unresolvable'));
357
+ expect(dropped).toEqual([]);
358
+ } finally {
359
+ warnSpy.mockRestore();
360
+ }
361
+ } finally {
362
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
363
+ }
364
+ });
365
+ });