@workos/oagen-emitters 0.2.1 → 0.3.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 (103) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +10 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +11893 -3226
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/go.md +338 -0
  10. package/docs/sdk-architecture/php.md +315 -0
  11. package/docs/sdk-architecture/python.md +511 -0
  12. package/oagen.config.ts +298 -2
  13. package/package.json +9 -5
  14. package/scripts/generate-php.js +13 -0
  15. package/scripts/git-push-with-published-oagen.sh +21 -0
  16. package/smoke/sdk-go.ts +116 -42
  17. package/smoke/sdk-php.ts +28 -26
  18. package/smoke/sdk-python.ts +5 -2
  19. package/src/go/client.ts +141 -0
  20. package/src/go/enums.ts +196 -0
  21. package/src/go/fixtures.ts +212 -0
  22. package/src/go/index.ts +81 -0
  23. package/src/go/manifest.ts +36 -0
  24. package/src/go/models.ts +254 -0
  25. package/src/go/naming.ts +191 -0
  26. package/src/go/resources.ts +827 -0
  27. package/src/go/tests.ts +751 -0
  28. package/src/go/type-map.ts +82 -0
  29. package/src/go/wrappers.ts +261 -0
  30. package/src/index.ts +3 -0
  31. package/src/node/client.ts +78 -115
  32. package/src/node/enums.ts +9 -0
  33. package/src/node/errors.ts +37 -232
  34. package/src/node/field-plan.ts +726 -0
  35. package/src/node/fixtures.ts +9 -1
  36. package/src/node/index.ts +2 -9
  37. package/src/node/models.ts +178 -21
  38. package/src/node/naming.ts +49 -111
  39. package/src/node/resources.ts +374 -364
  40. package/src/node/sdk-errors.ts +41 -0
  41. package/src/node/tests.ts +32 -12
  42. package/src/node/type-map.ts +4 -2
  43. package/src/node/utils.ts +13 -71
  44. package/src/node/wrappers.ts +151 -0
  45. package/src/php/client.ts +171 -0
  46. package/src/php/enums.ts +67 -0
  47. package/src/php/errors.ts +9 -0
  48. package/src/php/fixtures.ts +181 -0
  49. package/src/php/index.ts +96 -0
  50. package/src/php/manifest.ts +36 -0
  51. package/src/php/models.ts +310 -0
  52. package/src/php/naming.ts +298 -0
  53. package/src/php/resources.ts +561 -0
  54. package/src/php/tests.ts +533 -0
  55. package/src/php/type-map.ts +90 -0
  56. package/src/php/utils.ts +18 -0
  57. package/src/php/wrappers.ts +151 -0
  58. package/src/python/client.ts +337 -0
  59. package/src/python/enums.ts +313 -0
  60. package/src/python/fixtures.ts +196 -0
  61. package/src/python/index.ts +95 -0
  62. package/src/python/manifest.ts +38 -0
  63. package/src/python/models.ts +688 -0
  64. package/src/python/naming.ts +209 -0
  65. package/src/python/resources.ts +1322 -0
  66. package/src/python/tests.ts +1335 -0
  67. package/src/python/type-map.ts +93 -0
  68. package/src/python/wrappers.ts +191 -0
  69. package/src/shared/model-utils.ts +255 -0
  70. package/src/shared/naming-utils.ts +107 -0
  71. package/src/shared/non-spec-services.ts +54 -0
  72. package/src/shared/resolved-ops.ts +109 -0
  73. package/src/shared/wrapper-utils.ts +59 -0
  74. package/test/go/client.test.ts +92 -0
  75. package/test/go/enums.test.ts +132 -0
  76. package/test/go/errors.test.ts +9 -0
  77. package/test/go/models.test.ts +265 -0
  78. package/test/go/resources.test.ts +408 -0
  79. package/test/go/tests.test.ts +143 -0
  80. package/test/node/client.test.ts +18 -12
  81. package/test/node/enums.test.ts +2 -0
  82. package/test/node/errors.test.ts +2 -41
  83. package/test/node/models.test.ts +2 -0
  84. package/test/node/naming.test.ts +23 -0
  85. package/test/node/resources.test.ts +99 -69
  86. package/test/node/serializers.test.ts +3 -1
  87. package/test/node/type-map.test.ts +11 -0
  88. package/test/php/client.test.ts +94 -0
  89. package/test/php/enums.test.ts +173 -0
  90. package/test/php/errors.test.ts +9 -0
  91. package/test/php/models.test.ts +497 -0
  92. package/test/php/resources.test.ts +644 -0
  93. package/test/php/tests.test.ts +118 -0
  94. package/test/python/client.test.ts +200 -0
  95. package/test/python/enums.test.ts +228 -0
  96. package/test/python/errors.test.ts +16 -0
  97. package/test/python/manifest.test.ts +74 -0
  98. package/test/python/models.test.ts +716 -0
  99. package/test/python/resources.test.ts +617 -0
  100. package/test/python/tests.test.ts +202 -0
  101. package/src/node/common.ts +0 -273
  102. package/src/node/config.ts +0 -71
  103. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,644 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateResources } from '../../src/php/resources.js';
5
+ import { generateWrapperMethods } from '../../src/php/wrappers.js';
6
+
7
+ const models: Model[] = [
8
+ {
9
+ name: 'Organization',
10
+ fields: [
11
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
12
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
13
+ ],
14
+ },
15
+ {
16
+ name: 'CreateOrganizationRequest',
17
+ fields: [
18
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
19
+ { name: 'slug', type: { kind: 'primitive', type: 'string' }, required: false },
20
+ ],
21
+ },
22
+ ];
23
+
24
+ const services: Service[] = [
25
+ {
26
+ name: 'Organizations',
27
+ operations: [
28
+ {
29
+ name: 'getOrganization',
30
+ httpMethod: 'get',
31
+ path: '/organizations/{id}',
32
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
33
+ queryParams: [],
34
+ headerParams: [],
35
+ response: { kind: 'model', name: 'Organization' },
36
+ errors: [],
37
+ injectIdempotencyKey: false,
38
+ },
39
+ {
40
+ name: 'listOrganizations',
41
+ httpMethod: 'get',
42
+ path: '/organizations',
43
+ pathParams: [],
44
+ queryParams: [
45
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
46
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
47
+ ],
48
+ headerParams: [],
49
+ response: { kind: 'model', name: 'Organization' },
50
+ errors: [],
51
+ pagination: {
52
+ strategy: 'cursor',
53
+ param: 'after',
54
+ dataPath: 'data',
55
+ itemType: { kind: 'model', name: 'Organization' },
56
+ },
57
+ injectIdempotencyKey: false,
58
+ },
59
+ {
60
+ name: 'createOrganization',
61
+ httpMethod: 'post',
62
+ path: '/organizations',
63
+ pathParams: [],
64
+ queryParams: [],
65
+ headerParams: [],
66
+ requestBody: { kind: 'model', name: 'CreateOrganizationRequest' },
67
+ response: { kind: 'model', name: 'Organization' },
68
+ errors: [],
69
+ injectIdempotencyKey: false,
70
+ },
71
+ {
72
+ name: 'deleteOrganization',
73
+ httpMethod: 'delete',
74
+ path: '/organizations/{id}',
75
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
76
+ queryParams: [],
77
+ headerParams: [],
78
+ response: { kind: 'primitive', type: 'unknown' },
79
+ errors: [],
80
+ injectIdempotencyKey: false,
81
+ },
82
+ ],
83
+ },
84
+ ];
85
+
86
+ const emptySpec: ApiSpec = {
87
+ name: 'Test',
88
+ version: '1.0.0',
89
+ baseUrl: '',
90
+ services,
91
+ models,
92
+ enums: [],
93
+ sdk: defaultSdkBehavior(),
94
+ };
95
+
96
+ const ctx: EmitterContext = {
97
+ namespace: 'workos',
98
+ namespacePascal: 'WorkOS',
99
+ spec: emptySpec,
100
+ };
101
+
102
+ describe('generateResources', () => {
103
+ it('returns empty array for no services', () => {
104
+ expect(generateResources([], ctx)).toEqual([]);
105
+ });
106
+
107
+ it('generates a resource class with methods', () => {
108
+ const result = generateResources(services, ctx);
109
+
110
+ expect(result).toHaveLength(1);
111
+ expect(result[0].path).toBe('lib/Service/Organizations.php');
112
+ expect(result[0].content).toContain('class Organizations');
113
+ expect(result[0].content).toContain('private readonly \\WorkOS\\HttpClient $client');
114
+ });
115
+
116
+ it('generates GET by ID method with path interpolation', () => {
117
+ const result = generateResources(services, ctx);
118
+
119
+ expect(result[0].content).toContain('public function getOrganization(');
120
+ expect(result[0].content).toContain('string $id');
121
+ expect(result[0].content).toContain('"organizations/{$id}"');
122
+ expect(result[0].content).toContain('Organization::fromArray($response)');
123
+ });
124
+
125
+ it('generates paginated list method', () => {
126
+ const result = generateResources(services, ctx);
127
+
128
+ expect(result[0].content).toContain('public function listOrganizations(');
129
+ expect(result[0].content).toContain('?int $limit = null');
130
+ expect(result[0].content).toContain('PaginatedResponse');
131
+ expect(result[0].content).toContain('$this->client->requestPage(');
132
+ expect(result[0].content).toContain('modelClass: Organization::class');
133
+ expect(result[0].content).not.toContain('PaginatedResponse::fromArray($response, Organization::class)');
134
+ });
135
+
136
+ it('generates create method with body params', () => {
137
+ const result = generateResources(services, ctx);
138
+
139
+ expect(result[0].content).toContain('public function createOrganization(');
140
+ expect(result[0].content).toContain('string $name');
141
+ expect(result[0].content).toContain('?string $slug = null');
142
+ });
143
+
144
+ it('generates delete method returning void', () => {
145
+ const result = generateResources(services, ctx);
146
+
147
+ expect(result[0].content).toContain('): void');
148
+ });
149
+
150
+ it('generates correct namespace', () => {
151
+ const result = generateResources(services, ctx);
152
+
153
+ expect(result[0].content).toContain('namespace WorkOS\\Service;');
154
+ });
155
+
156
+ it('generates DELETE method with body params', () => {
157
+ const deleteBodyModels: Model[] = [
158
+ {
159
+ name: 'DeleteRoleAssignmentsRequest',
160
+ fields: [
161
+ {
162
+ name: 'permissions',
163
+ type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
164
+ required: true,
165
+ },
166
+ ],
167
+ },
168
+ ];
169
+
170
+ const deleteBodyServices: Service[] = [
171
+ {
172
+ name: 'Authorization',
173
+ operations: [
174
+ {
175
+ name: 'deleteRoleAssignments',
176
+ httpMethod: 'delete',
177
+ path: '/roles/{slug}/assignments',
178
+ pathParams: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
179
+ queryParams: [],
180
+ headerParams: [],
181
+ requestBody: { kind: 'model', name: 'DeleteRoleAssignmentsRequest' },
182
+ response: { kind: 'primitive', type: 'unknown' },
183
+ errors: [],
184
+ injectIdempotencyKey: false,
185
+ },
186
+ ],
187
+ },
188
+ ];
189
+
190
+ const spec = { ...emptySpec, services: deleteBodyServices, models: deleteBodyModels };
191
+ const result = generateResources(deleteBodyServices, { ...ctx, spec });
192
+
193
+ expect(result[0].content).toContain('body: $body,');
194
+ expect(result[0].content).toContain("'permissions' => $permissions,");
195
+ });
196
+
197
+ it('generates DELETE method with query params', () => {
198
+ const deleteQueryServices: Service[] = [
199
+ {
200
+ name: 'Authorization',
201
+ operations: [
202
+ {
203
+ name: 'deleteResource',
204
+ httpMethod: 'delete',
205
+ path: '/resources/{id}',
206
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
207
+ queryParams: [{ name: 'cascade_delete', type: { kind: 'primitive', type: 'boolean' }, required: false }],
208
+ headerParams: [],
209
+ response: { kind: 'primitive', type: 'unknown' },
210
+ errors: [],
211
+ injectIdempotencyKey: false,
212
+ },
213
+ ],
214
+ },
215
+ ];
216
+
217
+ const spec = { ...emptySpec, services: deleteQueryServices };
218
+ const result = generateResources(deleteQueryServices, { ...ctx, spec });
219
+
220
+ expect(result[0].content).toContain('query: $query,');
221
+ expect(result[0].content).toContain("'cascade_delete' => $cascadeDelete,");
222
+ });
223
+
224
+ it('generates array response with array_map', () => {
225
+ const arrayModels: Model[] = [
226
+ {
227
+ name: 'ClientSecret',
228
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
229
+ },
230
+ ];
231
+
232
+ const arrayServices: Service[] = [
233
+ {
234
+ name: 'Applications',
235
+ operations: [
236
+ {
237
+ name: 'listClientSecrets',
238
+ httpMethod: 'get',
239
+ path: '/applications/{id}/secrets',
240
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
241
+ queryParams: [],
242
+ headerParams: [],
243
+ response: { kind: 'array', items: { kind: 'model', name: 'ClientSecret' } },
244
+ errors: [],
245
+ injectIdempotencyKey: false,
246
+ },
247
+ ],
248
+ },
249
+ ];
250
+
251
+ const spec = { ...emptySpec, services: arrayServices, models: arrayModels };
252
+ const result = generateResources(arrayServices, { ...ctx, spec });
253
+
254
+ expect(result[0].content).toContain('array_map(fn ($item) => ClientSecret::fromArray($item), $response)');
255
+ });
256
+
257
+ it('disambiguates body field from path param with same name', () => {
258
+ const collisionModels: Model[] = [
259
+ {
260
+ name: 'CreateRolePermissionRequest',
261
+ fields: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
262
+ },
263
+ ];
264
+
265
+ const collisionServices: Service[] = [
266
+ {
267
+ name: 'Authorization',
268
+ operations: [
269
+ {
270
+ name: 'createRolePermissions',
271
+ httpMethod: 'post',
272
+ path: '/roles/{slug}/permissions',
273
+ pathParams: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
274
+ queryParams: [],
275
+ headerParams: [],
276
+ requestBody: { kind: 'model', name: 'CreateRolePermissionRequest' },
277
+ response: { kind: 'primitive', type: 'unknown' },
278
+ errors: [],
279
+ injectIdempotencyKey: false,
280
+ },
281
+ ],
282
+ },
283
+ ];
284
+
285
+ const spec = { ...emptySpec, services: collisionServices, models: collisionModels };
286
+ const result = generateResources(collisionServices, { ...ctx, spec });
287
+
288
+ // Should have both $slug (path) and $bodySlug (body) params
289
+ expect(result[0].content).toContain('string $slug');
290
+ expect(result[0].content).toContain('string $bodySlug');
291
+ expect(result[0].content).toContain("'slug' => $bodySlug,");
292
+ });
293
+
294
+ it('adds @deprecated PHPDoc for deprecated operations', () => {
295
+ const deprecatedServices: Service[] = [
296
+ {
297
+ name: 'Organizations',
298
+ operations: [
299
+ {
300
+ name: 'getOrganizationOld',
301
+ httpMethod: 'get',
302
+ path: '/organizations/{id}',
303
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
304
+ queryParams: [],
305
+ headerParams: [],
306
+ response: { kind: 'model', name: 'Organization' },
307
+ errors: [],
308
+ injectIdempotencyKey: false,
309
+ deprecated: true,
310
+ },
311
+ ],
312
+ },
313
+ ];
314
+
315
+ const spec = { ...emptySpec, services: deprecatedServices };
316
+ const result = generateResources(deprecatedServices, { ...ctx, spec });
317
+
318
+ expect(result[0].content).toContain('@deprecated');
319
+ });
320
+
321
+ it('adds description in PHPDoc for operations with description', () => {
322
+ const describedServices: Service[] = [
323
+ {
324
+ name: 'Organizations',
325
+ operations: [
326
+ {
327
+ name: 'getOrganization',
328
+ httpMethod: 'get',
329
+ path: '/organizations/{id}',
330
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
331
+ queryParams: [],
332
+ headerParams: [],
333
+ response: { kind: 'model', name: 'Organization' },
334
+ errors: [],
335
+ injectIdempotencyKey: false,
336
+ description: 'Fetch a single organization by ID',
337
+ },
338
+ ],
339
+ },
340
+ ];
341
+
342
+ const spec = { ...emptySpec, services: describedServices };
343
+ const result = generateResources(describedServices, { ...ctx, spec });
344
+
345
+ expect(result[0].content).toContain('Fetch a single organization by ID');
346
+ });
347
+
348
+ it('adds (deprecated) prefix in @param for deprecated path params', () => {
349
+ const deprecatedParamServices: Service[] = [
350
+ {
351
+ name: 'Organizations',
352
+ operations: [
353
+ {
354
+ name: 'getOrganization',
355
+ httpMethod: 'get',
356
+ path: '/organizations/{id}',
357
+ pathParams: [
358
+ {
359
+ name: 'id',
360
+ type: { kind: 'primitive', type: 'string' },
361
+ required: true,
362
+ deprecated: true,
363
+ description: 'The organization ID',
364
+ },
365
+ ],
366
+ queryParams: [],
367
+ headerParams: [],
368
+ response: { kind: 'model', name: 'Organization' },
369
+ errors: [],
370
+ injectIdempotencyKey: false,
371
+ },
372
+ ],
373
+ },
374
+ ];
375
+
376
+ const spec = { ...emptySpec, services: deprecatedParamServices };
377
+ const result = generateResources(deprecatedParamServices, { ...ctx, spec });
378
+
379
+ expect(result[0].content).toContain('(deprecated) The organization ID');
380
+ });
381
+
382
+ it('requires inferred client credentials in wrapper methods', () => {
383
+ const lines = generateWrapperMethods(
384
+ {
385
+ operation: {
386
+ name: 'authenticate',
387
+ httpMethod: 'post',
388
+ path: '/user_management/authenticate',
389
+ pathParams: [],
390
+ queryParams: [],
391
+ headerParams: [],
392
+ response: { kind: 'model', name: 'Organization' },
393
+ errors: [],
394
+ injectIdempotencyKey: false,
395
+ },
396
+ service: services[0],
397
+ methodName: 'authenticate',
398
+ mountOn: 'Organizations',
399
+ wrappers: [
400
+ {
401
+ name: 'authenticate_with_password',
402
+ targetVariant: 'PasswordSessionAuthenticateRequest',
403
+ defaults: { grant_type: 'password' },
404
+ inferFromClient: ['client_id', 'client_secret'],
405
+ exposedParams: ['email'],
406
+ },
407
+ ],
408
+ } as never,
409
+ ctx,
410
+ ).join('\n');
411
+
412
+ expect(lines).toContain("$body['client_id'] = $this->client->requireClientId();");
413
+ expect(lines).toContain("$body['client_secret'] = $this->client->requireApiKey();");
414
+ expect(lines).not.toContain('\\WorkOS\\WorkOS::getClientId()');
415
+ expect(lines).not.toContain('\\WorkOS\\WorkOS::getApiKey()');
416
+ });
417
+
418
+ it('hides defaults and inferFromClient params from method signature', () => {
419
+ const ssoServices: Service[] = [
420
+ {
421
+ name: 'SSO',
422
+ operations: [
423
+ {
424
+ name: 'getAuthorizationUrl',
425
+ httpMethod: 'get',
426
+ path: '/sso/authorize',
427
+ pathParams: [],
428
+ queryParams: [
429
+ { name: 'client_id', type: { kind: 'primitive', type: 'string' }, required: true },
430
+ { name: 'response_type', type: { kind: 'primitive', type: 'string' }, required: true },
431
+ { name: 'redirect_uri', type: { kind: 'primitive', type: 'string' }, required: true },
432
+ { name: 'state', type: { kind: 'primitive', type: 'string' }, required: false },
433
+ ],
434
+ headerParams: [],
435
+ response: { kind: 'primitive', type: 'unknown' },
436
+ errors: [],
437
+ injectIdempotencyKey: false,
438
+ },
439
+ ],
440
+ },
441
+ ];
442
+
443
+ const ssoSpec = { ...emptySpec, services: ssoServices };
444
+ const ssoCtx: EmitterContext = {
445
+ ...ctx,
446
+ spec: ssoSpec,
447
+ resolvedOperations: [
448
+ {
449
+ operation: ssoServices[0].operations[0],
450
+ service: ssoServices[0],
451
+ methodName: 'get_authorization_url',
452
+ mountOn: 'SSO',
453
+ defaults: { response_type: 'code' },
454
+ inferFromClient: ['client_id'],
455
+ } as any,
456
+ ],
457
+ };
458
+
459
+ const result = generateResources(ssoServices, ssoCtx);
460
+ const content = result[0].content;
461
+
462
+ // Should NOT include hidden params in method signature
463
+ expect(content).not.toContain('$clientId');
464
+ expect(content).not.toContain('$responseType');
465
+
466
+ // Should include the remaining params
467
+ expect(content).toContain('string $redirectUri');
468
+ expect(content).toContain('?string $state');
469
+
470
+ // Should inject default and inferred values into query
471
+ expect(content).toContain("'response_type' => 'code'");
472
+ expect(content).toContain("$query['client_id'] = $this->client->requireClientId()");
473
+ });
474
+
475
+ it('skips base method when wrappers exist', () => {
476
+ const authServices: Service[] = [
477
+ {
478
+ name: 'UserManagement',
479
+ operations: [
480
+ {
481
+ name: 'authenticate',
482
+ httpMethod: 'post',
483
+ path: '/user_management/authenticate',
484
+ pathParams: [],
485
+ queryParams: [],
486
+ headerParams: [],
487
+ requestBody: { kind: 'model', name: 'CreateOrganizationRequest' },
488
+ response: { kind: 'model', name: 'Organization' },
489
+ errors: [],
490
+ injectIdempotencyKey: false,
491
+ },
492
+ ],
493
+ },
494
+ ];
495
+
496
+ const authSpec = { ...emptySpec, services: authServices };
497
+ const authCtx: EmitterContext = {
498
+ ...ctx,
499
+ spec: authSpec,
500
+ resolvedOperations: [
501
+ {
502
+ operation: authServices[0].operations[0],
503
+ service: authServices[0],
504
+ methodName: 'authenticate',
505
+ mountOn: 'UserManagement',
506
+ wrappers: [
507
+ {
508
+ name: 'authenticate_with_password',
509
+ targetVariant: 'PasswordSessionAuthenticateRequest',
510
+ defaults: { grant_type: 'password' },
511
+ inferFromClient: ['client_id', 'client_secret'],
512
+ exposedParams: ['email'],
513
+ },
514
+ ],
515
+ } as any,
516
+ ],
517
+ };
518
+
519
+ const result = generateResources(authServices, authCtx);
520
+ const content = result[0].content;
521
+
522
+ // Wrapper method should be emitted
523
+ expect(content).toContain('authenticateWithPassword');
524
+ // Base method should NOT be emitted
525
+ expect(content).not.toContain('public function authenticate(');
526
+ });
527
+
528
+ it('does not produce |null|null in PHPDoc for nullable optional body fields', () => {
529
+ const nullableModels: Model[] = [
530
+ ...models,
531
+ {
532
+ name: 'UpdateOrgRequest',
533
+ fields: [
534
+ {
535
+ name: 'domain',
536
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
537
+ required: false,
538
+ },
539
+ ],
540
+ },
541
+ ];
542
+
543
+ const nullableServices: Service[] = [
544
+ {
545
+ name: 'Organizations',
546
+ operations: [
547
+ {
548
+ name: 'updateOrganization',
549
+ httpMethod: 'put',
550
+ path: '/organizations/{id}',
551
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
552
+ queryParams: [],
553
+ headerParams: [],
554
+ requestBody: { kind: 'model', name: 'UpdateOrgRequest' },
555
+ response: { kind: 'model', name: 'Organization' },
556
+ errors: [],
557
+ injectIdempotencyKey: false,
558
+ },
559
+ ],
560
+ },
561
+ ];
562
+
563
+ const spec = { ...emptySpec, services: nullableServices, models: nullableModels };
564
+ const result = generateResources(nullableServices, { ...ctx, spec });
565
+
566
+ expect(result[0].content).toContain('string|null $domain');
567
+ expect(result[0].content).not.toContain('|null|null');
568
+ });
569
+
570
+ it('hides body params from PHPDoc when in hiddenParams', () => {
571
+ const tokenModels: Model[] = [
572
+ ...models,
573
+ {
574
+ name: 'TokenRequest',
575
+ fields: [
576
+ { name: 'code', type: { kind: 'primitive', type: 'string' }, required: true },
577
+ { name: 'client_id', type: { kind: 'primitive', type: 'string' }, required: true },
578
+ { name: 'client_secret', type: { kind: 'primitive', type: 'string' }, required: true },
579
+ { name: 'grant_type', type: { kind: 'primitive', type: 'string' }, required: true },
580
+ ],
581
+ },
582
+ ];
583
+
584
+ const tokenServices: Service[] = [
585
+ {
586
+ name: 'SSO',
587
+ operations: [
588
+ {
589
+ name: 'getProfileAndToken',
590
+ httpMethod: 'post',
591
+ path: '/sso/token',
592
+ pathParams: [],
593
+ queryParams: [],
594
+ headerParams: [],
595
+ requestBody: { kind: 'model', name: 'TokenRequest' },
596
+ response: { kind: 'model', name: 'Organization' },
597
+ errors: [],
598
+ injectIdempotencyKey: false,
599
+ },
600
+ ],
601
+ },
602
+ ];
603
+
604
+ const spec = { ...emptySpec, services: tokenServices, models: tokenModels };
605
+ const tokenCtx: EmitterContext = {
606
+ ...ctx,
607
+ spec,
608
+ resolvedOperations: [
609
+ {
610
+ operation: tokenServices[0].operations[0],
611
+ service: tokenServices[0],
612
+ methodName: 'get_profile_and_token',
613
+ mountOn: 'SSO',
614
+ defaults: { grant_type: 'authorization_code' },
615
+ inferFromClient: ['client_id', 'client_secret'],
616
+ } as any,
617
+ ],
618
+ };
619
+
620
+ const result = generateResources(tokenServices, tokenCtx);
621
+ const content = result[0].content;
622
+
623
+ // Hidden params should not appear in PHPDoc
624
+ expect(content).not.toContain('@param string $clientId');
625
+ expect(content).not.toContain('@param string $clientSecret');
626
+ expect(content).not.toContain('@param string $grantType');
627
+ // Visible params should appear
628
+ expect(content).toContain('@param string $code');
629
+
630
+ // Body should NOT reference hidden fields as variables
631
+ expect(content).not.toContain("'client_id' => $clientId");
632
+ expect(content).not.toContain("'client_secret' => $clientSecret");
633
+ expect(content).not.toContain("'grant_type' => $grantType");
634
+ // Body should inject defaults and inferred fields
635
+ expect(content).toContain("'grant_type' => 'authorization_code'");
636
+ expect(content).toContain("$body['client_id'] = $this->client->requireClientId()");
637
+ expect(content).toContain("$body['client_secret'] = $this->client->requireApiKey()");
638
+ // Visible field should still be in the body array
639
+ expect(content).toContain("'code' => $code");
640
+ // Developer should only need to pass code
641
+ expect(content).toContain('public function getProfileAndToken(');
642
+ expect(content).toMatch(/function getProfileAndToken\(\s*string \$code/);
643
+ });
644
+ });