@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.
Files changed (53) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-C408Wh-o.mjs → plugin-eCuvoL1T.mjs} +3914 -2121
  12. package/dist/plugin-eCuvoL1T.mjs.map +1 -0
  13. package/dist/plugin.d.mts.map +1 -1
  14. package/dist/plugin.mjs +1 -1
  15. package/package.json +10 -10
  16. package/renovate.json +46 -6
  17. package/src/node/client.ts +19 -32
  18. package/src/node/enums.ts +67 -30
  19. package/src/node/errors.ts +2 -8
  20. package/src/node/field-plan.ts +188 -52
  21. package/src/node/fixtures.ts +11 -33
  22. package/src/node/index.ts +345 -20
  23. package/src/node/live-surface.ts +378 -0
  24. package/src/node/models.ts +540 -351
  25. package/src/node/naming.ts +119 -25
  26. package/src/node/node-overrides.ts +77 -0
  27. package/src/node/options.ts +41 -0
  28. package/src/node/resources.ts +455 -46
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +108 -83
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/src/rust/fixtures.ts +87 -1
  35. package/src/rust/models.ts +17 -2
  36. package/src/rust/resources.ts +697 -62
  37. package/src/rust/tests.ts +540 -20
  38. package/test/node/client.test.ts +106 -1201
  39. package/test/node/enums.test.ts +59 -130
  40. package/test/node/errors.test.ts +2 -3
  41. package/test/node/live-surface.test.ts +240 -0
  42. package/test/node/models.test.ts +396 -765
  43. package/test/node/naming.test.ts +69 -234
  44. package/test/node/resources.test.ts +376 -2036
  45. package/test/node/tests.test.ts +119 -0
  46. package/test/node/type-map.test.ts +49 -54
  47. package/test/node/utils.test.ts +29 -80
  48. package/test/rust/fixtures.test.ts +227 -0
  49. package/test/rust/models.test.ts +38 -0
  50. package/test/rust/resources.test.ts +505 -2
  51. package/test/rust/tests.test.ts +504 -0
  52. package/dist/plugin-C408Wh-o.mjs.map +0 -1
  53. package/test/node/serializers.test.ts +0 -444
@@ -0,0 +1,504 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { ApiSpec, EmitterContext, Service } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateTests } from '../../src/rust/tests.js';
5
+
6
+ const baseSpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [],
11
+ models: [],
12
+ enums: [],
13
+ sdk: defaultSdkBehavior(),
14
+ };
15
+
16
+ const baseCtx: EmitterContext = {
17
+ namespace: 'workos',
18
+ namespacePascal: 'WorkOS',
19
+ spec: baseSpec,
20
+ };
21
+
22
+ /**
23
+ * Build an emitter context with the given services and inject a minimal
24
+ * `resolvedOperations` table so the tests generator can group by mount.
25
+ */
26
+ function ctxWithResolved(
27
+ services: Service[],
28
+ models: ApiSpec['models'] = [],
29
+ enums: ApiSpec['enums'] = [],
30
+ ): EmitterContext {
31
+ const spec: ApiSpec = { ...baseSpec, services, models, enums };
32
+ return {
33
+ ...baseCtx,
34
+ spec,
35
+ resolvedOperations: services.flatMap((service) =>
36
+ service.operations.map((operation) => ({
37
+ service,
38
+ operation,
39
+ methodName: operation.name,
40
+ mountOn: service.name,
41
+ defaults: {},
42
+ inferFromClient: [],
43
+ urlBuilder: false,
44
+ })),
45
+ ),
46
+ };
47
+ }
48
+
49
+ /** Convenience: locate the `tests/<accessor>_test.rs` file produced for one mount. */
50
+ function getMountTestFile(files: ReturnType<typeof generateTests>, accessor: string): string {
51
+ const f = files.find((x) => x.path === `tests/${accessor}_test.rs`);
52
+ if (!f) throw new Error(`expected tests/${accessor}_test.rs in generated files`);
53
+ return f.content;
54
+ }
55
+
56
+ describe('rust/tests', () => {
57
+ it('emits the common test client with max_retries(0)', () => {
58
+ const services: Service[] = [];
59
+ const files = generateTests(baseSpec, ctxWithResolved(services));
60
+ const common = files.find((f) => f.path === 'tests/common/mod.rs');
61
+ expect(common).toBeDefined();
62
+ expect(common!.content).toContain('pub async fn test_client(server: &MockServer) -> Client {');
63
+ expect(common!.content).toContain('.max_retries(0)');
64
+ });
65
+
66
+ it('emits a round-trip plus four standard error tests for a GET operation', () => {
67
+ const services: Service[] = [
68
+ {
69
+ name: 'Organizations',
70
+ operations: [
71
+ {
72
+ name: 'getOrganization',
73
+ httpMethod: 'get',
74
+ path: '/organizations/{id}',
75
+ pathParams: [
76
+ {
77
+ name: 'id',
78
+ type: { kind: 'primitive', type: 'string' },
79
+ required: true,
80
+ },
81
+ ],
82
+ queryParams: [],
83
+ headerParams: [],
84
+ response: { kind: 'model', name: 'Organization' },
85
+ errors: [],
86
+ injectIdempotencyKey: false,
87
+ },
88
+ ],
89
+ },
90
+ ];
91
+ const files = generateTests(
92
+ { ...baseSpec, services },
93
+ ctxWithResolved(services, [
94
+ {
95
+ name: 'Organization',
96
+ fields: [
97
+ {
98
+ name: 'id',
99
+ type: { kind: 'primitive', type: 'string' },
100
+ required: true,
101
+ },
102
+ ],
103
+ },
104
+ ]),
105
+ );
106
+ const content = getMountTestFile(files, 'organizations');
107
+ // Round-trip is preserved.
108
+ expect(content).toContain('async fn organizations_get_organization_round_trip()');
109
+ // Standard error tests are emitted.
110
+ expect(content).toContain('async fn organizations_get_organization_unauthorized()');
111
+ expect(content).toContain('async fn organizations_get_organization_not_found()');
112
+ expect(content).toContain('async fn organizations_get_organization_rate_limited()');
113
+ expect(content).toContain('async fn organizations_get_organization_server_error()');
114
+ // GET ops don't get write-only error tests.
115
+ expect(content).not.toContain('async fn organizations_get_organization_bad_request()');
116
+ expect(content).not.toContain('async fn organizations_get_organization_unprocessable()');
117
+ // Error asserts go through Error::Api.
118
+ expect(content).toContain('Error::Api(api) => api.as_ref()');
119
+ expect(content).toContain('assert_eq!(api.status, 401);');
120
+ });
121
+
122
+ it('adds bad_request and unprocessable tests for write methods', () => {
123
+ const services: Service[] = [
124
+ {
125
+ name: 'Organizations',
126
+ operations: [
127
+ {
128
+ name: 'createOrganization',
129
+ httpMethod: 'post',
130
+ path: '/organizations',
131
+ pathParams: [],
132
+ queryParams: [],
133
+ headerParams: [],
134
+ requestBody: { kind: 'model', name: 'OrganizationInput' },
135
+ response: { kind: 'model', name: 'Organization' },
136
+ errors: [],
137
+ injectIdempotencyKey: false,
138
+ },
139
+ ],
140
+ },
141
+ ];
142
+ const files = generateTests(
143
+ { ...baseSpec, services },
144
+ ctxWithResolved(services, [
145
+ {
146
+ name: 'Organization',
147
+ fields: [
148
+ {
149
+ name: 'id',
150
+ type: { kind: 'primitive', type: 'string' },
151
+ required: true,
152
+ },
153
+ ],
154
+ },
155
+ {
156
+ name: 'OrganizationInput',
157
+ fields: [
158
+ {
159
+ name: 'name',
160
+ type: { kind: 'primitive', type: 'string' },
161
+ required: true,
162
+ },
163
+ ],
164
+ },
165
+ ]),
166
+ );
167
+ const content = getMountTestFile(files, 'organizations');
168
+ expect(content).toContain('async fn organizations_create_organization_bad_request()');
169
+ expect(content).toContain('async fn organizations_create_organization_unprocessable()');
170
+ // The bad_request body sets `code = validation_error` and the test asserts it.
171
+ expect(content).toContain('"code\\":\\"validation_error\\"');
172
+ expect(content).toContain('assert_eq!(api.code.as_deref(), Some("validation_error"));');
173
+ });
174
+
175
+ it('emits Retry-After assertion on the rate_limited test', () => {
176
+ const services: Service[] = [
177
+ {
178
+ name: 'Events',
179
+ operations: [
180
+ {
181
+ name: 'listEvents',
182
+ httpMethod: 'get',
183
+ path: '/events',
184
+ pathParams: [],
185
+ queryParams: [],
186
+ headerParams: [],
187
+ response: { kind: 'model', name: 'EventList' },
188
+ errors: [],
189
+ injectIdempotencyKey: false,
190
+ },
191
+ ],
192
+ },
193
+ ];
194
+ const files = generateTests(
195
+ { ...baseSpec, services },
196
+ ctxWithResolved(services, [
197
+ {
198
+ name: 'EventList',
199
+ fields: [
200
+ {
201
+ name: 'data',
202
+ type: {
203
+ kind: 'array',
204
+ items: { kind: 'primitive', type: 'string' },
205
+ },
206
+ required: true,
207
+ },
208
+ ],
209
+ },
210
+ ]),
211
+ );
212
+ const content = getMountTestFile(files, 'events');
213
+ expect(content).toContain('async fn events_list_events_rate_limited()');
214
+ expect(content).toContain('"retry-after"');
215
+ expect(content).toContain('assert_eq!(api.retry_after, Some(std::time::Duration::from_secs(1)));');
216
+ });
217
+
218
+ it('emits an empty_page test for cursor-paginated list ops returning a wrapper model', () => {
219
+ const services: Service[] = [
220
+ {
221
+ name: 'Organizations',
222
+ operations: [
223
+ {
224
+ name: 'listOrganizations',
225
+ httpMethod: 'get',
226
+ path: '/organizations',
227
+ pathParams: [],
228
+ queryParams: [],
229
+ headerParams: [],
230
+ response: { kind: 'model', name: 'OrganizationList' },
231
+ errors: [],
232
+ injectIdempotencyKey: false,
233
+ pagination: {
234
+ strategy: 'cursor',
235
+ param: 'after',
236
+ dataPath: 'data',
237
+ itemType: { kind: 'model', name: 'Organization' },
238
+ },
239
+ },
240
+ ],
241
+ },
242
+ ];
243
+ const files = generateTests(
244
+ { ...baseSpec, services },
245
+ ctxWithResolved(services, [
246
+ {
247
+ name: 'OrganizationList',
248
+ fields: [
249
+ {
250
+ name: 'data',
251
+ type: {
252
+ kind: 'array',
253
+ items: { kind: 'model', name: 'Organization' },
254
+ },
255
+ required: true,
256
+ },
257
+ ],
258
+ },
259
+ ]),
260
+ );
261
+ const content = getMountTestFile(files, 'organizations');
262
+ expect(content).toContain('async fn organizations_list_organizations_empty_page()');
263
+ // The body literal is JSON-escaped in the generated Rust source.
264
+ expect(content).toContain('\\"object\\":\\"list\\"');
265
+ expect(content).toContain('resp.data.is_empty()');
266
+ });
267
+
268
+ it('emits an empty_page test using a bare [] body for Vec<T> paginated responses', () => {
269
+ const services: Service[] = [
270
+ {
271
+ name: 'AuditLogs',
272
+ operations: [
273
+ {
274
+ name: 'listActions',
275
+ httpMethod: 'get',
276
+ path: '/audit_logs/actions',
277
+ pathParams: [],
278
+ queryParams: [],
279
+ headerParams: [],
280
+ response: {
281
+ kind: 'array',
282
+ items: { kind: 'model', name: 'AuditLogAction' },
283
+ },
284
+ errors: [],
285
+ injectIdempotencyKey: false,
286
+ pagination: {
287
+ strategy: 'cursor',
288
+ param: 'after',
289
+ itemType: { kind: 'model', name: 'AuditLogAction' },
290
+ },
291
+ },
292
+ ],
293
+ },
294
+ ];
295
+ const files = generateTests({ ...baseSpec, services }, ctxWithResolved(services, []));
296
+ const content = getMountTestFile(files, 'audit_logs');
297
+ expect(content).toContain('async fn audit_logs_list_actions_empty_page()');
298
+ // Bare array shape, asserted via `resp.is_empty()`.
299
+ expect(content).toContain('"[]"');
300
+ expect(content).toContain('resp.is_empty()');
301
+ });
302
+
303
+ it('skips error tests for URL-builder ops (no HTTP call)', () => {
304
+ const services: Service[] = [
305
+ {
306
+ name: 'Sso',
307
+ operations: [
308
+ {
309
+ name: 'getAuthorizationUrl',
310
+ httpMethod: 'get',
311
+ path: '/sso/authorize',
312
+ pathParams: [],
313
+ queryParams: [
314
+ {
315
+ name: 'redirect_uri',
316
+ type: { kind: 'primitive', type: 'string' },
317
+ required: true,
318
+ },
319
+ ],
320
+ headerParams: [],
321
+ response: { kind: 'primitive', type: 'unknown' },
322
+ errors: [],
323
+ injectIdempotencyKey: false,
324
+ },
325
+ ],
326
+ },
327
+ ];
328
+ const ctx: EmitterContext = {
329
+ ...baseCtx,
330
+ spec: { ...baseSpec, services },
331
+ resolvedOperations: services.flatMap((service) =>
332
+ service.operations.map((operation) => ({
333
+ service,
334
+ operation,
335
+ methodName: operation.name,
336
+ mountOn: service.name,
337
+ defaults: {},
338
+ inferFromClient: [],
339
+ urlBuilder: true,
340
+ })),
341
+ ),
342
+ };
343
+ const files = generateTests({ ...baseSpec, services }, ctx);
344
+ const content = getMountTestFile(files, 'sso');
345
+ expect(content).toContain('async fn sso_get_authorization_url_round_trip()');
346
+ // URL-builder ops do not emit error tests — there is no HTTP call to mock.
347
+ expect(content).not.toContain('async fn sso_get_authorization_url_unauthorized()');
348
+ expect(content).not.toContain('async fn sso_get_authorization_url_not_found()');
349
+ });
350
+
351
+ it('emits an encodes_query_params test for ops with Vec<String> query params', () => {
352
+ const services: Service[] = [
353
+ {
354
+ name: 'Events',
355
+ operations: [
356
+ {
357
+ name: 'listEvents',
358
+ httpMethod: 'get',
359
+ path: '/events',
360
+ pathParams: [],
361
+ queryParams: [
362
+ {
363
+ name: 'events',
364
+ type: {
365
+ kind: 'array',
366
+ items: { kind: 'primitive', type: 'string' },
367
+ },
368
+ required: false,
369
+ explode: false,
370
+ },
371
+ ],
372
+ headerParams: [],
373
+ response: { kind: 'model', name: 'EventList' },
374
+ errors: [],
375
+ injectIdempotencyKey: false,
376
+ pagination: {
377
+ strategy: 'cursor',
378
+ param: 'after',
379
+ dataPath: 'data',
380
+ itemType: { kind: 'model', name: 'EventSchema' },
381
+ },
382
+ },
383
+ ],
384
+ },
385
+ ];
386
+ const files = generateTests(
387
+ { ...baseSpec, services },
388
+ ctxWithResolved(services, [
389
+ {
390
+ name: 'EventList',
391
+ fields: [
392
+ {
393
+ name: 'data',
394
+ type: {
395
+ kind: 'array',
396
+ items: { kind: 'model', name: 'EventSchema' },
397
+ },
398
+ required: true,
399
+ },
400
+ ],
401
+ },
402
+ ]),
403
+ );
404
+ const content = getMountTestFile(files, 'events');
405
+ expect(content).toContain('async fn events_list_events_encodes_query_params()');
406
+ // explode=false → comma-joined (URL-encoded as %2C).
407
+ expect(content).toContain('events=foo%2Cbar');
408
+ // The test inspects the request via wiremock's received_requests().
409
+ expect(content).toContain('server.received_requests().await');
410
+ });
411
+
412
+ it('emits explode=true repeated-key encoding when explode is unset', () => {
413
+ const services: Service[] = [
414
+ {
415
+ name: 'Things',
416
+ operations: [
417
+ {
418
+ name: 'listThings',
419
+ httpMethod: 'get',
420
+ path: '/things',
421
+ pathParams: [],
422
+ queryParams: [
423
+ {
424
+ name: 'tags',
425
+ type: {
426
+ kind: 'array',
427
+ items: { kind: 'primitive', type: 'string' },
428
+ },
429
+ required: false,
430
+ // explode left undefined → defaults to true for form-style arrays.
431
+ },
432
+ ],
433
+ headerParams: [],
434
+ response: { kind: 'model', name: 'Thing' },
435
+ errors: [],
436
+ injectIdempotencyKey: false,
437
+ },
438
+ ],
439
+ },
440
+ ];
441
+ const files = generateTests(
442
+ { ...baseSpec, services },
443
+ ctxWithResolved(services, [
444
+ {
445
+ name: 'Thing',
446
+ fields: [
447
+ {
448
+ name: 'id',
449
+ type: { kind: 'primitive', type: 'string' },
450
+ required: true,
451
+ },
452
+ ],
453
+ },
454
+ ]),
455
+ );
456
+ const content = getMountTestFile(files, 'things');
457
+ expect(content).toContain('async fn things_list_things_encodes_query_params()');
458
+ expect(content).toContain('tags=foo&tags=bar');
459
+ });
460
+
461
+ it('skips encodes_query_params for ops with no Vec<String> query params', () => {
462
+ const services: Service[] = [
463
+ {
464
+ name: 'Things',
465
+ operations: [
466
+ {
467
+ name: 'listThings',
468
+ httpMethod: 'get',
469
+ path: '/things',
470
+ pathParams: [],
471
+ queryParams: [
472
+ {
473
+ name: 'limit',
474
+ type: { kind: 'primitive', type: 'integer' },
475
+ required: false,
476
+ },
477
+ ],
478
+ headerParams: [],
479
+ response: { kind: 'model', name: 'Thing' },
480
+ errors: [],
481
+ injectIdempotencyKey: false,
482
+ },
483
+ ],
484
+ },
485
+ ];
486
+ const files = generateTests(
487
+ { ...baseSpec, services },
488
+ ctxWithResolved(services, [
489
+ {
490
+ name: 'Thing',
491
+ fields: [
492
+ {
493
+ name: 'id',
494
+ type: { kind: 'primitive', type: 'string' },
495
+ required: true,
496
+ },
497
+ ],
498
+ },
499
+ ]),
500
+ );
501
+ const content = getMountTestFile(files, 'things');
502
+ expect(content).not.toContain('encodes_query_params');
503
+ });
504
+ });