@workos/oagen-emitters 0.2.1 → 0.4.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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,682 @@
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
+ // Redirect endpoint: should return string and build URL, not make HTTP request
475
+ expect(content).toContain('): string {');
476
+ expect(content).toContain('$this->client->buildUrl(');
477
+ expect(content).not.toContain('$this->client->request(');
478
+ expect(content).toContain('@return string');
479
+ // Should pass $options to buildUrl for base URL overrides
480
+ expect(content).toContain('$options);');
481
+ });
482
+
483
+ it('generates redirect endpoint that builds URL for GET with primitive unknown response', () => {
484
+ const logoutServices: Service[] = [
485
+ {
486
+ name: 'SSO',
487
+ operations: [
488
+ {
489
+ name: 'getLogoutUrl',
490
+ httpMethod: 'get',
491
+ path: '/sso/logout',
492
+ pathParams: [],
493
+ queryParams: [{ name: 'token', type: { kind: 'primitive', type: 'string' }, required: true }],
494
+ headerParams: [],
495
+ response: { kind: 'primitive', type: 'unknown' },
496
+ errors: [],
497
+ injectIdempotencyKey: false,
498
+ },
499
+ ],
500
+ },
501
+ ];
502
+
503
+ const spec = { ...emptySpec, services: logoutServices };
504
+ const result = generateResources(logoutServices, { ...ctx, spec });
505
+ const content = result[0].content;
506
+
507
+ expect(content).toContain('): string {');
508
+ expect(content).toContain("return $this->client->buildUrl('sso/logout', $query, $options);");
509
+ expect(content).not.toContain('$this->client->request(');
510
+ expect(content).toContain('@return string');
511
+ });
512
+
513
+ it('skips base method when wrappers exist', () => {
514
+ const authServices: Service[] = [
515
+ {
516
+ name: 'UserManagement',
517
+ operations: [
518
+ {
519
+ name: 'authenticate',
520
+ httpMethod: 'post',
521
+ path: '/user_management/authenticate',
522
+ pathParams: [],
523
+ queryParams: [],
524
+ headerParams: [],
525
+ requestBody: { kind: 'model', name: 'CreateOrganizationRequest' },
526
+ response: { kind: 'model', name: 'Organization' },
527
+ errors: [],
528
+ injectIdempotencyKey: false,
529
+ },
530
+ ],
531
+ },
532
+ ];
533
+
534
+ const authSpec = { ...emptySpec, services: authServices };
535
+ const authCtx: EmitterContext = {
536
+ ...ctx,
537
+ spec: authSpec,
538
+ resolvedOperations: [
539
+ {
540
+ operation: authServices[0].operations[0],
541
+ service: authServices[0],
542
+ methodName: 'authenticate',
543
+ mountOn: 'UserManagement',
544
+ wrappers: [
545
+ {
546
+ name: 'authenticate_with_password',
547
+ targetVariant: 'PasswordSessionAuthenticateRequest',
548
+ defaults: { grant_type: 'password' },
549
+ inferFromClient: ['client_id', 'client_secret'],
550
+ exposedParams: ['email'],
551
+ },
552
+ ],
553
+ } as any,
554
+ ],
555
+ };
556
+
557
+ const result = generateResources(authServices, authCtx);
558
+ const content = result[0].content;
559
+
560
+ // Wrapper method should be emitted
561
+ expect(content).toContain('authenticateWithPassword');
562
+ // Base method should NOT be emitted
563
+ expect(content).not.toContain('public function authenticate(');
564
+ });
565
+
566
+ it('does not produce |null|null in PHPDoc for nullable optional body fields', () => {
567
+ const nullableModels: Model[] = [
568
+ ...models,
569
+ {
570
+ name: 'UpdateOrgRequest',
571
+ fields: [
572
+ {
573
+ name: 'domain',
574
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
575
+ required: false,
576
+ },
577
+ ],
578
+ },
579
+ ];
580
+
581
+ const nullableServices: Service[] = [
582
+ {
583
+ name: 'Organizations',
584
+ operations: [
585
+ {
586
+ name: 'updateOrganization',
587
+ httpMethod: 'put',
588
+ path: '/organizations/{id}',
589
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
590
+ queryParams: [],
591
+ headerParams: [],
592
+ requestBody: { kind: 'model', name: 'UpdateOrgRequest' },
593
+ response: { kind: 'model', name: 'Organization' },
594
+ errors: [],
595
+ injectIdempotencyKey: false,
596
+ },
597
+ ],
598
+ },
599
+ ];
600
+
601
+ const spec = { ...emptySpec, services: nullableServices, models: nullableModels };
602
+ const result = generateResources(nullableServices, { ...ctx, spec });
603
+
604
+ expect(result[0].content).toContain('string|null $domain');
605
+ expect(result[0].content).not.toContain('|null|null');
606
+ });
607
+
608
+ it('hides body params from PHPDoc when in hiddenParams', () => {
609
+ const tokenModels: Model[] = [
610
+ ...models,
611
+ {
612
+ name: 'TokenRequest',
613
+ fields: [
614
+ { name: 'code', type: { kind: 'primitive', type: 'string' }, required: true },
615
+ { name: 'client_id', type: { kind: 'primitive', type: 'string' }, required: true },
616
+ { name: 'client_secret', type: { kind: 'primitive', type: 'string' }, required: true },
617
+ { name: 'grant_type', type: { kind: 'primitive', type: 'string' }, required: true },
618
+ ],
619
+ },
620
+ ];
621
+
622
+ const tokenServices: Service[] = [
623
+ {
624
+ name: 'SSO',
625
+ operations: [
626
+ {
627
+ name: 'getProfileAndToken',
628
+ httpMethod: 'post',
629
+ path: '/sso/token',
630
+ pathParams: [],
631
+ queryParams: [],
632
+ headerParams: [],
633
+ requestBody: { kind: 'model', name: 'TokenRequest' },
634
+ response: { kind: 'model', name: 'Organization' },
635
+ errors: [],
636
+ injectIdempotencyKey: false,
637
+ },
638
+ ],
639
+ },
640
+ ];
641
+
642
+ const spec = { ...emptySpec, services: tokenServices, models: tokenModels };
643
+ const tokenCtx: EmitterContext = {
644
+ ...ctx,
645
+ spec,
646
+ resolvedOperations: [
647
+ {
648
+ operation: tokenServices[0].operations[0],
649
+ service: tokenServices[0],
650
+ methodName: 'get_profile_and_token',
651
+ mountOn: 'SSO',
652
+ defaults: { grant_type: 'authorization_code' },
653
+ inferFromClient: ['client_id', 'client_secret'],
654
+ } as any,
655
+ ],
656
+ };
657
+
658
+ const result = generateResources(tokenServices, tokenCtx);
659
+ const content = result[0].content;
660
+
661
+ // Hidden params should not appear in PHPDoc
662
+ expect(content).not.toContain('@param string $clientId');
663
+ expect(content).not.toContain('@param string $clientSecret');
664
+ expect(content).not.toContain('@param string $grantType');
665
+ // Visible params should appear
666
+ expect(content).toContain('@param string $code');
667
+
668
+ // Body should NOT reference hidden fields as variables
669
+ expect(content).not.toContain("'client_id' => $clientId");
670
+ expect(content).not.toContain("'client_secret' => $clientSecret");
671
+ expect(content).not.toContain("'grant_type' => $grantType");
672
+ // Body should inject defaults and inferred fields
673
+ expect(content).toContain("'grant_type' => 'authorization_code'");
674
+ expect(content).toContain("$body['client_id'] = $this->client->requireClientId()");
675
+ expect(content).toContain("$body['client_secret'] = $this->client->requireApiKey()");
676
+ // Visible field should still be in the body array
677
+ expect(content).toContain("'code' => $code");
678
+ // Developer should only need to pass code
679
+ expect(content).toContain('public function getProfileAndToken(');
680
+ expect(content).toMatch(/function getProfileAndToken\(\s*string \$code/);
681
+ });
682
+ });