@workos/oagen-emitters 0.2.0 → 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 (110) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.oxfmtrc.json +8 -1
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +129 -0
  6. package/dist/index.d.mts +10 -1
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +11943 -2728
  9. package/dist/index.mjs.map +1 -1
  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 +298 -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 +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +137 -46
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-php.ts +28 -26
  23. package/smoke/sdk-python.ts +5 -2
  24. package/smoke/sdk-ruby.ts +17 -3
  25. package/smoke/sdk-rust.ts +16 -3
  26. package/src/go/client.ts +141 -0
  27. package/src/go/enums.ts +196 -0
  28. package/src/go/fixtures.ts +212 -0
  29. package/src/go/index.ts +81 -0
  30. package/src/go/manifest.ts +36 -0
  31. package/src/go/models.ts +254 -0
  32. package/src/go/naming.ts +191 -0
  33. package/src/go/resources.ts +827 -0
  34. package/src/go/tests.ts +751 -0
  35. package/src/go/type-map.ts +82 -0
  36. package/src/go/wrappers.ts +261 -0
  37. package/src/index.ts +3 -0
  38. package/src/node/client.ts +167 -122
  39. package/src/node/enums.ts +13 -4
  40. package/src/node/errors.ts +42 -233
  41. package/src/node/field-plan.ts +726 -0
  42. package/src/node/fixtures.ts +15 -5
  43. package/src/node/index.ts +65 -16
  44. package/src/node/models.ts +264 -96
  45. package/src/node/naming.ts +52 -25
  46. package/src/node/resources.ts +621 -172
  47. package/src/node/sdk-errors.ts +41 -0
  48. package/src/node/tests.ts +71 -27
  49. package/src/node/type-map.ts +4 -2
  50. package/src/node/utils.ts +56 -64
  51. package/src/node/wrappers.ts +151 -0
  52. package/src/php/client.ts +171 -0
  53. package/src/php/enums.ts +67 -0
  54. package/src/php/errors.ts +9 -0
  55. package/src/php/fixtures.ts +181 -0
  56. package/src/php/index.ts +96 -0
  57. package/src/php/manifest.ts +36 -0
  58. package/src/php/models.ts +310 -0
  59. package/src/php/naming.ts +298 -0
  60. package/src/php/resources.ts +561 -0
  61. package/src/php/tests.ts +533 -0
  62. package/src/php/type-map.ts +90 -0
  63. package/src/php/utils.ts +18 -0
  64. package/src/php/wrappers.ts +151 -0
  65. package/src/python/client.ts +337 -0
  66. package/src/python/enums.ts +313 -0
  67. package/src/python/fixtures.ts +196 -0
  68. package/src/python/index.ts +95 -0
  69. package/src/python/manifest.ts +38 -0
  70. package/src/python/models.ts +688 -0
  71. package/src/python/naming.ts +209 -0
  72. package/src/python/resources.ts +1322 -0
  73. package/src/python/tests.ts +1335 -0
  74. package/src/python/type-map.ts +93 -0
  75. package/src/python/wrappers.ts +191 -0
  76. package/src/shared/model-utils.ts +255 -0
  77. package/src/shared/naming-utils.ts +107 -0
  78. package/src/shared/non-spec-services.ts +54 -0
  79. package/src/shared/resolved-ops.ts +109 -0
  80. package/src/shared/wrapper-utils.ts +59 -0
  81. package/test/go/client.test.ts +92 -0
  82. package/test/go/enums.test.ts +132 -0
  83. package/test/go/errors.test.ts +9 -0
  84. package/test/go/models.test.ts +265 -0
  85. package/test/go/resources.test.ts +408 -0
  86. package/test/go/tests.test.ts +143 -0
  87. package/test/node/client.test.ts +199 -94
  88. package/test/node/enums.test.ts +75 -3
  89. package/test/node/errors.test.ts +2 -41
  90. package/test/node/models.test.ts +109 -20
  91. package/test/node/naming.test.ts +37 -4
  92. package/test/node/resources.test.ts +662 -30
  93. package/test/node/serializers.test.ts +36 -7
  94. package/test/node/type-map.test.ts +11 -0
  95. package/test/php/client.test.ts +94 -0
  96. package/test/php/enums.test.ts +173 -0
  97. package/test/php/errors.test.ts +9 -0
  98. package/test/php/models.test.ts +497 -0
  99. package/test/php/resources.test.ts +644 -0
  100. package/test/php/tests.test.ts +118 -0
  101. package/test/python/client.test.ts +200 -0
  102. package/test/python/enums.test.ts +228 -0
  103. package/test/python/errors.test.ts +16 -0
  104. package/test/python/manifest.test.ts +74 -0
  105. package/test/python/models.test.ts +716 -0
  106. package/test/python/resources.test.ts +617 -0
  107. package/test/python/tests.test.ts +202 -0
  108. package/src/node/common.ts +0 -273
  109. package/src/node/config.ts +0 -71
  110. package/src/node/serializers.ts +0 -744
@@ -0,0 +1,827 @@
1
+ import type {
2
+ Service,
3
+ Operation,
4
+ OperationPlan,
5
+ EmitterContext,
6
+ GeneratedFile,
7
+ ResolvedOperation,
8
+ } from '@workos/oagen';
9
+ import { planOperation, toSnakeCase } from '@workos/oagen';
10
+ import { isListWrapperModel } from './models.js';
11
+ import { mapTypeRef, mapTypeRefValue } from './type-map.js';
12
+ import { className, fieldName, methodName, resolveClassName, resolveMethodName, unexportedName } from './naming.js';
13
+ import {
14
+ buildResolvedLookup,
15
+ lookupResolved,
16
+ groupByMount,
17
+ getOpDefaults,
18
+ getOpInferFromClient,
19
+ buildHiddenParams,
20
+ hasHiddenParams,
21
+ } from '../shared/resolved-ops.js';
22
+ import { lowerFirstForDoc, fieldDocComment } from '../shared/naming-utils.js';
23
+ import { generateWrapperMethods } from './wrappers.js';
24
+
25
+ /**
26
+ * Return path params sorted by their first occurrence in the URL template.
27
+ * This ensures fmt.Sprintf args and function signatures match template order.
28
+ */
29
+ export function sortPathParamsByTemplateOrder(op: Operation): typeof op.pathParams {
30
+ return [...op.pathParams].sort((a, b) => {
31
+ const posA = op.path.indexOf(`{${a.name}}`);
32
+ const posB = op.path.indexOf(`{${b.name}}`);
33
+ return posA - posB;
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Resolve the resource class name for a service.
39
+ */
40
+ export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
41
+ return resolveClassName(service, ctx);
42
+ }
43
+
44
+ /**
45
+ * Generate Go resource/service files from IR Service definitions.
46
+ * Each mount group becomes a single .go file with an unexported service struct
47
+ * and exported methods.
48
+ */
49
+ export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
50
+ if (services.length === 0) return [];
51
+
52
+ const files: GeneratedFile[] = [];
53
+ const mountGroups = groupByMount(ctx);
54
+
55
+ // If no resolved operations, fall back to raw services
56
+ const entries: Array<{ name: string; operations: Operation[] }> =
57
+ mountGroups.size > 0
58
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
59
+ : services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
60
+
61
+ for (const { name: mountName, operations } of entries) {
62
+ if (operations.length === 0) continue;
63
+ const file = generateServiceFile(mountName, operations, ctx);
64
+ if (file) files.push(file);
65
+ }
66
+
67
+ return files;
68
+ }
69
+
70
+ function generateServiceFile(mountName: string, operations: Operation[], ctx: EmitterContext): GeneratedFile | null {
71
+ const lines: string[] = [];
72
+ const serviceType = serviceTypeName(mountName);
73
+ const goFile = `${toSnakeCase(mountName)}.go`;
74
+
75
+ // Build resolved lookup once for the whole file
76
+ const resolvedLookup = buildResolvedLookup(ctx);
77
+
78
+ // Determine which imports are needed
79
+ const needsFmt = operations.some((op) => op.pathParams.length > 0);
80
+ const needsNetUrl = operations.some((op) => {
81
+ const resolved = lookupResolved(op, resolvedLookup);
82
+ return resolved && hasHiddenParams(resolved) && op.httpMethod.toLowerCase() === 'get';
83
+ });
84
+ const needsStrings = needsStringsImport(operations, resolvedLookup);
85
+
86
+ lines.push(`package ${ctx.namespace}`);
87
+ lines.push('');
88
+ lines.push('import (');
89
+ lines.push('\t"context"');
90
+ if (needsFmt) {
91
+ lines.push('\t"fmt"');
92
+ }
93
+ if (needsNetUrl) {
94
+ lines.push('\t"net/url"');
95
+ }
96
+ if (needsStrings) {
97
+ lines.push('\t"strings"');
98
+ }
99
+ lines.push(')');
100
+ lines.push('');
101
+
102
+ // Service struct
103
+ lines.push(`// ${serviceType} handles ${mountName} operations.`);
104
+ lines.push(`type ${serviceType} struct {`);
105
+ lines.push('\tclient *Client');
106
+ lines.push('}');
107
+ lines.push('');
108
+
109
+ // Generate params structs and methods for each operation.
110
+ // Deduplicate by method name -- multiple IR operations can resolve to the same
111
+ // Go method name when mounted from different IR services.
112
+ const emittedMethods = new Set<string>();
113
+ for (const op of operations) {
114
+ const plan = planOperation(op);
115
+ const method = resolveGoMethodName(op, mountName, ctx);
116
+
117
+ if (emittedMethods.has(method)) continue;
118
+ emittedMethods.add(method);
119
+
120
+ const resolvedOp = lookupResolved(op, resolvedLookup);
121
+
122
+ // Generate params struct if needed
123
+ const paramsStruct = generateParamsStruct(mountName, method, op, plan, ctx, resolvedOp);
124
+ if (paramsStruct) {
125
+ lines.push(paramsStruct);
126
+ lines.push('');
127
+ }
128
+
129
+ // Generate method
130
+ const methodCode = generateMethod(serviceType, mountName, method, op, plan, ctx, resolvedOp);
131
+ lines.push(methodCode);
132
+ lines.push('');
133
+
134
+ // Generate union split wrapper methods (e.g., AuthenticateWithPassword)
135
+ if (resolvedOp?.wrappers && resolvedOp.wrappers.length > 0) {
136
+ const wrapperLines = generateWrapperMethods(serviceType, resolvedOp, ctx);
137
+ lines.push(...wrapperLines);
138
+ for (const w of resolvedOp.wrappers) {
139
+ emittedMethods.add(methodName(w.name));
140
+ }
141
+ }
142
+ }
143
+
144
+ return {
145
+ path: goFile,
146
+ content: lines.join('\n'),
147
+ overwriteExisting: true,
148
+ };
149
+ }
150
+
151
+ function resolveGoMethodName(op: Operation, mountName: string, ctx: EmitterContext): string {
152
+ return resolveMethodName(op, { name: mountName, operations: [op] }, ctx);
153
+ }
154
+
155
+ export function paramsStructName(mountName: string, method: string): string {
156
+ // Prefix with mount name to avoid cross-file collisions in flat package
157
+ const prefix = className(mountName);
158
+ // If method already starts with the mount name, don't double-prefix
159
+ if (method.startsWith(prefix)) return `${method}Params`;
160
+ return `${prefix}${method}Params`;
161
+ }
162
+
163
+ function generateParamsStruct(
164
+ mountName: string,
165
+ method: string,
166
+ op: Operation,
167
+ plan: OperationPlan,
168
+ ctx: EmitterContext,
169
+ resolvedOp?: ResolvedOperation,
170
+ ): string | null {
171
+ // Build set of hidden param names (defaults + inferFromClient)
172
+ const hidden = buildHiddenParams(resolvedOp);
173
+
174
+ const hasQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
175
+ const hasBody = plan.hasBody && op.requestBody;
176
+
177
+ // Check if body has any visible fields after filtering
178
+ let hasVisibleBodyFields = false;
179
+ if (hasBody && op.requestBody?.kind === 'model') {
180
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
181
+ if (bodyModel) {
182
+ hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
183
+ }
184
+ } else if (hasBody) {
185
+ hasVisibleBodyFields = true; // non-model body always visible
186
+ }
187
+
188
+ if (!hasQueryParams && !hasVisibleBodyFields) return null;
189
+
190
+ const lines: string[] = [];
191
+ const structName = paramsStructName(mountName, method);
192
+
193
+ lines.push(`// ${structName} contains the parameters for ${method}.`);
194
+ lines.push(`type ${structName} struct {`);
195
+
196
+ // Track emitted field names to avoid duplicates
197
+ const emittedFields = new Set<string>();
198
+
199
+ // Body fields (if body is a model)
200
+ if (hasBody && op.requestBody?.kind === 'model') {
201
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
202
+ if (bodyModel) {
203
+ for (const field of bodyModel.fields) {
204
+ if (hidden.has(field.name)) continue;
205
+ const goField = fieldName(field.name);
206
+ if (emittedFields.has(goField)) continue;
207
+ emittedFields.add(goField);
208
+ const isOptional = !field.required;
209
+ const goType = isOptional ? makeOptional(mapTypeRef(field.type)) : mapTypeRef(field.type);
210
+ const jsonTag = field.required ? `json:"${field.name}"` : `json:"${field.name},omitempty"`;
211
+ // If this field also appears in query params, emit a url tag too
212
+ const isAlsoQueryParam = op.queryParams.some((qp) => !hidden.has(qp.name) && fieldName(qp.name) === goField);
213
+ const urlTag = isAlsoQueryParam ? ` url:"${field.name}${field.required ? '' : ',omitempty'}"` : '';
214
+ if (field.description) {
215
+ const fdLines = field.description.split('\n').filter((l) => l.trim());
216
+ lines.push(`\t// ${fieldDocComment(goField, fdLines[0])}`);
217
+ for (let i = 1; i < fdLines.length; i++) {
218
+ lines.push(`\t// ${fdLines[i].trim()}`);
219
+ }
220
+ }
221
+ if (field.deprecated) {
222
+ if (field.description) lines.push(`\t//`);
223
+ lines.push(`\t// Deprecated: this field is deprecated.`);
224
+ }
225
+ lines.push(`\t${goField} ${goType} \`${jsonTag}${urlTag}\``);
226
+ }
227
+ }
228
+ } else if (hasBody) {
229
+ // Non-model body (generic)
230
+ lines.push('\tBody interface{} `json:"-"`');
231
+ }
232
+
233
+ // Check if this is a list operation with standard pagination fields.
234
+ // If so, embed PaginationParams and skip those fields individually.
235
+ const PAGINATION_FIELDS = new Set(['before', 'after', 'limit', 'order']);
236
+ const visibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name));
237
+ const hasPaginationFields = ['before', 'after', 'limit'].every((name) =>
238
+ visibleQueryParams.some((qp) => qp.name === name),
239
+ );
240
+ if (hasPaginationFields) {
241
+ lines.push('\tPaginationParams');
242
+ }
243
+
244
+ // Query params (skip any already emitted from body fields, hidden params, and pagination fields)
245
+ for (const param of op.queryParams) {
246
+ if (hidden.has(param.name)) continue;
247
+ if (hasPaginationFields && PAGINATION_FIELDS.has(param.name)) continue;
248
+ const goField = fieldName(param.name);
249
+ if (emittedFields.has(goField)) continue;
250
+ emittedFields.add(goField);
251
+ const isOptional = !param.required;
252
+ const paramType = mapQueryParamType(param.name, param.type);
253
+ const goType = isOptional ? makeOptional(paramType) : paramType;
254
+ const urlTag = param.required ? `url:"${param.name}"` : `url:"${param.name},omitempty"`;
255
+ const jsonTag = 'json:"-"';
256
+ if (param.description) {
257
+ const pdLines = param.description.split('\n').filter((l) => l.trim());
258
+ lines.push(`\t// ${fieldDocComment(goField, pdLines[0])}`);
259
+ for (let i = 1; i < pdLines.length; i++) {
260
+ lines.push(`\t// ${pdLines[i].trim()}`);
261
+ }
262
+ }
263
+ if (param.default != null) {
264
+ const defaultLine = `\t// Defaults to ${JSON.stringify(param.default)}.`;
265
+ if (!param.description) lines.push(defaultLine);
266
+ else lines.push(defaultLine);
267
+ }
268
+ if (param.deprecated) {
269
+ if (param.description || param.default != null) lines.push(`\t//`);
270
+ lines.push(`\t// Deprecated: this parameter is deprecated.`);
271
+ }
272
+ lines.push(`\t${goField} ${goType} \`${urlTag} ${jsonTag}\``);
273
+ }
274
+
275
+ lines.push('}');
276
+ return lines.join('\n');
277
+ }
278
+
279
+ function generateMethod(
280
+ serviceType: string,
281
+ mountName: string,
282
+ method: string,
283
+ op: Operation,
284
+ plan: OperationPlan,
285
+ _ctx: EmitterContext,
286
+ resolvedOp?: ResolvedOperation,
287
+ ): string {
288
+ const lines: string[] = [];
289
+ const isPaginated = plan.isPaginated;
290
+ const isDelete = plan.isDelete;
291
+ const hasBody = plan.hasBody && op.requestBody;
292
+ const hidden = buildHiddenParams(resolvedOp);
293
+ const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
294
+
295
+ // Check if body has visible fields after filtering hidden params
296
+ let hasVisibleBodyFields = false;
297
+ if (hasBody && op.requestBody?.kind === 'model') {
298
+ const bodyModel = _ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
299
+ if (bodyModel) {
300
+ hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
301
+ }
302
+ } else if (hasBody) {
303
+ hasVisibleBodyFields = true;
304
+ }
305
+
306
+ const hasParams = hasVisibleBodyFields || hasVisibleQueryParams;
307
+ const paramsType = hasParams ? `*${paramsStructName(mountName, method)}` : null;
308
+ const bodyArg = hasBody && hasParams ? bodyArgument(op) : 'nil';
309
+ const hasHidden = hasHiddenParams(resolvedOp);
310
+ const isGet = op.httpMethod.toLowerCase() === 'get';
311
+
312
+ // Detect if response is a raw array (not paginated)
313
+ const isArrayResponse = !isPaginated && op.response?.kind === 'array';
314
+
315
+ // Return type
316
+ let returnType: string;
317
+ if (isPaginated && op.pagination) {
318
+ const itemType = resolveIteratorItemType(op.pagination.itemType, _ctx);
319
+ returnType = `*Iterator[${itemType}]`;
320
+ } else if (isDelete) {
321
+ returnType = 'error';
322
+ } else if (plan.responseModelName) {
323
+ const respType = className(plan.responseModelName);
324
+ if (isArrayResponse) {
325
+ returnType = `([]${respType}, error)`;
326
+ } else {
327
+ returnType = `(*${respType}, error)`;
328
+ }
329
+ } else {
330
+ returnType = 'error';
331
+ }
332
+
333
+ // Build godoc -- wrap multi-line descriptions in // comments
334
+ if (op.description) {
335
+ const descLines = op.description.split('\n').filter((l) => l.trim());
336
+ lines.push(`// ${method} ${lowerFirstDesc(descLines[0])}`);
337
+ for (let i = 1; i < descLines.length; i++) {
338
+ lines.push(`// ${descLines[i].trim()}`);
339
+ }
340
+ }
341
+ for (const p of op.pathParams) {
342
+ if (p.deprecated) {
343
+ lines.push(`//`);
344
+ lines.push(`// Deprecated parameter ${fieldName(p.name)}${p.description ? ': ' + p.description : '.'}`);
345
+ }
346
+ }
347
+ if (op.deprecated) {
348
+ lines.push(`//`);
349
+ lines.push(`// Deprecated: this operation is deprecated.`);
350
+ }
351
+
352
+ // Method signature
353
+ const params: string[] = ['ctx context.Context'];
354
+ // Path params as positional args (sorted by template order)
355
+ for (const p of sortPathParamsByTemplateOrder(op)) {
356
+ params.push(`${lowerFirst(fieldName(p.name))} ${mapTypeRefValue(p.type)}`);
357
+ }
358
+ if (paramsType) {
359
+ params.push(`params ${paramsType}`);
360
+ }
361
+ params.push('opts ...RequestOption');
362
+
363
+ if (isPaginated) {
364
+ lines.push(`func (s *${serviceType}) ${method}(${params.join(', ')}) ${returnType} {`);
365
+ } else if (isDelete || !plan.responseModelName) {
366
+ lines.push(`func (s *${serviceType}) ${method}(${params.join(', ')}) ${returnType} {`);
367
+ } else {
368
+ lines.push(`func (s *${serviceType}) ${method}(${params.join(', ')}) ${returnType} {`);
369
+ }
370
+
371
+ // Build path
372
+ const pathExpr = buildPathExpr(op);
373
+
374
+ // For GET operations with hidden params, build query via url.Values
375
+ // so we can inject defaults + inferred values alongside user-provided params.
376
+ if (hasHidden && isGet) {
377
+ emitGetWithHiddenParams(
378
+ lines,
379
+ op,
380
+ pathExpr,
381
+ plan,
382
+ _ctx,
383
+ resolvedOp!,
384
+ paramsType,
385
+ isPaginated,
386
+ isDelete,
387
+ isArrayResponse,
388
+ );
389
+ } else if (hasHidden && !isGet && hasBody) {
390
+ // For non-GET operations with hidden params, build a body map
391
+ emitBodyWithHiddenParams(
392
+ lines,
393
+ op,
394
+ pathExpr,
395
+ plan,
396
+ _ctx,
397
+ resolvedOp!,
398
+ paramsType,
399
+ isPaginated,
400
+ isDelete,
401
+ isArrayResponse,
402
+ );
403
+ } else if (isPaginated && op.pagination) {
404
+ const itemType = resolveIteratorItemType(op.pagination.itemType, _ctx);
405
+ const dataPath = op.pagination.dataPath ? `"${op.pagination.dataPath}"` : `"data"`;
406
+ const cursorParam = '"after"';
407
+ lines.push(
408
+ `\treturn newIterator[${itemType}](ctx, s.client, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${hasVisibleQueryParams ? 'params' : 'nil'}, ${cursorParam}, ${dataPath}, opts)`,
409
+ );
410
+ } else if (isDelete) {
411
+ lines.push(
412
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${hasVisibleQueryParams ? 'params' : 'nil'}, ${bodyArg}, nil, opts)`,
413
+ );
414
+ lines.push('\treturn err');
415
+ } else if (plan.responseModelName) {
416
+ const respType = className(plan.responseModelName);
417
+ if (isArrayResponse) {
418
+ lines.push(`\tvar result []${respType}`);
419
+ lines.push(
420
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${hasVisibleQueryParams ? 'params' : 'nil'}, ${bodyArg}, &result, opts)`,
421
+ );
422
+ lines.push('\tif err != nil {');
423
+ lines.push('\t\treturn nil, err');
424
+ lines.push('\t}');
425
+ lines.push('\treturn result, nil');
426
+ } else {
427
+ lines.push(`\tvar result ${respType}`);
428
+ lines.push(
429
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${hasVisibleQueryParams ? 'params' : 'nil'}, ${bodyArg}, &result, opts)`,
430
+ );
431
+ lines.push('\tif err != nil {');
432
+ lines.push('\t\treturn nil, err');
433
+ lines.push('\t}');
434
+ lines.push('\treturn &result, nil');
435
+ }
436
+ } else {
437
+ lines.push(
438
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${hasVisibleQueryParams ? 'params' : 'nil'}, ${bodyArg}, nil, opts)`,
439
+ );
440
+ lines.push('\treturn err');
441
+ }
442
+
443
+ lines.push('}');
444
+ return lines.join('\n');
445
+ }
446
+
447
+ // buildHiddenParams and hasHiddenParams are imported from ../shared/resolved-ops.js
448
+
449
+ /** Convert a JS value to a Go literal. */
450
+ function goLiteral(value: string | number | boolean): string {
451
+ if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
452
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
453
+ return String(value);
454
+ }
455
+
456
+ /** Get the Go expression for reading a client config field. */
457
+ function clientFieldExpression(field: string): string {
458
+ switch (field) {
459
+ case 'client_id':
460
+ return 's.client.clientID';
461
+ case 'client_secret':
462
+ return 's.client.apiKey';
463
+ default:
464
+ return `s.client.${lowerFirst(fieldName(field))}`;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Emit method body for GET operations that have hidden params (defaults/inferFromClient).
470
+ * Builds a url.Values manually so we can inject hidden values alongside user-provided query params.
471
+ */
472
+ function emitGetWithHiddenParams(
473
+ lines: string[],
474
+ op: Operation,
475
+ pathExpr: string,
476
+ plan: OperationPlan,
477
+ ctx: EmitterContext,
478
+ resolvedOp: ResolvedOperation,
479
+ paramsType: string | null,
480
+ isPaginated: boolean,
481
+ isDelete: boolean,
482
+ isArrayResponse: boolean,
483
+ ): void {
484
+ const hidden = buildHiddenParams(resolvedOp);
485
+
486
+ // Build url.Values with hidden + user-provided params
487
+ lines.push('\tquery := url.Values{}');
488
+
489
+ // Inject constant defaults
490
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
491
+ lines.push(`\tquery.Set("${key}", ${goLiteralForQuery(value as string | number | boolean)})`);
492
+ }
493
+
494
+ // Inject inferred fields from client config
495
+ for (const field of getOpInferFromClient(resolvedOp)) {
496
+ const expr = clientFieldExpression(field);
497
+ lines.push(`\tif ${expr} != "" {`);
498
+ lines.push(`\t\tquery.Set("${field}", ${expr})`);
499
+ lines.push('\t}');
500
+ }
501
+
502
+ // Add user-provided query params from the struct
503
+ if (paramsType) {
504
+ const visibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name));
505
+ for (const param of visibleQueryParams) {
506
+ const goField = fieldName(param.name);
507
+ const isMap = param.type.kind === 'map';
508
+ if (isMap) {
509
+ // Maps use bracket encoding: param[key]=value
510
+ if (param.required) {
511
+ lines.push(`\tfor k, v := range params.${goField} {`);
512
+ lines.push(`\t\tquery.Set(fmt.Sprintf("${param.name}[%s]", k), fmt.Sprintf("%v", v))`);
513
+ lines.push('\t}');
514
+ } else {
515
+ lines.push(`\tif params.${goField} != nil {`);
516
+ lines.push(`\t\tfor k, v := range params.${goField} {`);
517
+ lines.push(`\t\t\tquery.Set(fmt.Sprintf("${param.name}[%s]", k), fmt.Sprintf("%v", v))`);
518
+ lines.push('\t\t}');
519
+ lines.push('\t}');
520
+ }
521
+ } else if (param.required) {
522
+ lines.push(`\tquery.Set("${param.name}", ${formatQueryValue(`params.${goField}`, param.type)})`);
523
+ } else {
524
+ // Slices are reference types in Go -- nil-able without pointer wrapping
525
+ const isRefType = param.type.kind === 'array';
526
+ const valueExpr = isRefType ? `params.${goField}` : `*params.${goField}`;
527
+ lines.push(`\tif params.${goField} != nil {`);
528
+ lines.push(`\t\tquery.Set("${param.name}", ${formatQueryValue(valueExpr, param.type)})`);
529
+ lines.push('\t}');
530
+ }
531
+ }
532
+ }
533
+
534
+ // Make the request with query as the 4th arg
535
+ if (isPaginated && op.pagination) {
536
+ const itemType = resolveIteratorItemType(op.pagination.itemType, ctx);
537
+ const dataPath = op.pagination.dataPath ? `"${op.pagination.dataPath}"` : `"data"`;
538
+ const cursorParam = '"after"';
539
+ lines.push(
540
+ `\treturn newIterator[${itemType}](ctx, s.client, "GET", ${pathExpr}, query, ${cursorParam}, ${dataPath}, opts)`,
541
+ );
542
+ } else if (isDelete) {
543
+ lines.push(`\t_, err := s.client.request(ctx, "GET", ${pathExpr}, query, nil, nil, opts)`);
544
+ lines.push('\treturn err');
545
+ } else if (plan.responseModelName) {
546
+ const respType = className(plan.responseModelName);
547
+ if (isArrayResponse) {
548
+ lines.push(`\tvar result []${respType}`);
549
+ lines.push(`\t_, err := s.client.request(ctx, "GET", ${pathExpr}, query, nil, &result, opts)`);
550
+ lines.push('\tif err != nil {');
551
+ lines.push('\t\treturn nil, err');
552
+ lines.push('\t}');
553
+ lines.push('\treturn result, nil');
554
+ } else {
555
+ lines.push(`\tvar result ${respType}`);
556
+ lines.push(`\t_, err := s.client.request(ctx, "GET", ${pathExpr}, query, nil, &result, opts)`);
557
+ lines.push('\tif err != nil {');
558
+ lines.push('\t\treturn nil, err');
559
+ lines.push('\t}');
560
+ lines.push('\treturn &result, nil');
561
+ }
562
+ } else {
563
+ lines.push(`\t_, err := s.client.request(ctx, "GET", ${pathExpr}, query, nil, nil, opts)`);
564
+ lines.push('\treturn err');
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Emit method body for non-GET operations that have hidden params (defaults/inferFromClient).
570
+ * Builds a body map so we can inject hidden values alongside user-provided fields.
571
+ */
572
+ function emitBodyWithHiddenParams(
573
+ lines: string[],
574
+ op: Operation,
575
+ pathExpr: string,
576
+ plan: OperationPlan,
577
+ ctx: EmitterContext,
578
+ resolvedOp: ResolvedOperation,
579
+ paramsType: string | null,
580
+ _isPaginated: boolean,
581
+ isDelete: boolean,
582
+ isArrayResponse: boolean,
583
+ ): void {
584
+ const hidden = buildHiddenParams(resolvedOp);
585
+
586
+ // Build body map
587
+ lines.push('\tbody := map[string]interface{}{');
588
+
589
+ // Inject constant defaults
590
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
591
+ lines.push(`\t\t"${key}": ${goLiteral(value as string | number | boolean)},`);
592
+ }
593
+
594
+ lines.push('\t}');
595
+
596
+ // Inject inferred fields from client config
597
+ for (const field of getOpInferFromClient(resolvedOp)) {
598
+ const expr = clientFieldExpression(field);
599
+ lines.push(`\tif ${expr} != "" {`);
600
+ lines.push(`\t\tbody["${field}"] = ${expr}`);
601
+ lines.push('\t}');
602
+ }
603
+
604
+ // Add user-provided body fields from the struct
605
+ if (paramsType && op.requestBody?.kind === 'model') {
606
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
607
+ if (bodyModel) {
608
+ for (const field of bodyModel.fields) {
609
+ if (hidden.has(field.name)) continue;
610
+ const goField = fieldName(field.name);
611
+ if (field.required) {
612
+ lines.push(`\tbody["${field.name}"] = params.${goField}`);
613
+ } else {
614
+ // Slices and maps are reference types in Go — nil-able without pointer wrapping
615
+ const isRefType = field.type.kind === 'array' || field.type.kind === 'map';
616
+ const valueExpr = isRefType ? `params.${goField}` : `*params.${goField}`;
617
+ lines.push(`\tif params.${goField} != nil {`);
618
+ lines.push(`\t\tbody["${field.name}"] = ${valueExpr}`);
619
+ lines.push('\t}');
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ // Determine query arg (visible query params from struct)
626
+ const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
627
+ const queryArg = hasVisibleQueryParams ? 'params' : 'nil';
628
+
629
+ // Make the request
630
+ if (isDelete) {
631
+ lines.push(
632
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${queryArg}, body, nil, opts)`,
633
+ );
634
+ lines.push('\treturn err');
635
+ } else if (plan.responseModelName) {
636
+ const respType = className(plan.responseModelName);
637
+ if (isArrayResponse) {
638
+ lines.push(`\tvar result []${respType}`);
639
+ lines.push(
640
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${queryArg}, body, &result, opts)`,
641
+ );
642
+ lines.push('\tif err != nil {');
643
+ lines.push('\t\treturn nil, err');
644
+ lines.push('\t}');
645
+ lines.push('\treturn result, nil');
646
+ } else {
647
+ lines.push(`\tvar result ${respType}`);
648
+ lines.push(
649
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${queryArg}, body, &result, opts)`,
650
+ );
651
+ lines.push('\tif err != nil {');
652
+ lines.push('\t\treturn nil, err');
653
+ lines.push('\t}');
654
+ lines.push('\treturn &result, nil');
655
+ }
656
+ } else {
657
+ lines.push(
658
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, ${queryArg}, body, nil, opts)`,
659
+ );
660
+ lines.push('\treturn err');
661
+ }
662
+ }
663
+
664
+ /** Format a Go value as a string for url.Values.Set(). */
665
+ function goLiteralForQuery(value: string | number | boolean): string {
666
+ if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
667
+ if (typeof value === 'boolean') return value ? `"true"` : `"false"`;
668
+ return `fmt.Sprintf("%v", ${String(value)})`;
669
+ }
670
+
671
+ /** Format a Go expression to a string suitable for url.Values.Set(). */
672
+ function formatQueryValue(expr: string, type: import('@workos/oagen').TypeRef): string {
673
+ if (type.kind === 'primitive') {
674
+ switch (type.type) {
675
+ case 'string':
676
+ return expr;
677
+ case 'integer':
678
+ case 'number':
679
+ return `fmt.Sprintf("%v", ${expr})`;
680
+ case 'boolean':
681
+ return `fmt.Sprintf("%v", ${expr})`;
682
+ default:
683
+ return `fmt.Sprintf("%v", ${expr})`;
684
+ }
685
+ }
686
+ if (type.kind === 'array') {
687
+ return `strings.Join(${expr}, ",")`;
688
+ }
689
+ return `fmt.Sprintf("%v", ${expr})`;
690
+ }
691
+
692
+ /**
693
+ * Check if any operations with hidden params also have visible array query params.
694
+ * strings.Join is only generated inside emitGetWithHiddenParams, so the import is
695
+ * only needed when that code path is active AND uses array params.
696
+ */
697
+ function needsStringsImport(operations: Operation[], resolvedLookup: Map<string, any>): boolean {
698
+ for (const op of operations) {
699
+ const resolved = lookupResolved(op, resolvedLookup);
700
+ if (!resolved || !hasHiddenParams(resolved)) continue;
701
+ if (op.httpMethod.toLowerCase() !== 'get') continue;
702
+ const hidden = buildHiddenParams(resolved);
703
+ for (const qp of op.queryParams) {
704
+ if (hidden.has(qp.name)) continue;
705
+ if (qp.type.kind === 'array') return true;
706
+ }
707
+ }
708
+ return false;
709
+ }
710
+
711
+ /**
712
+ * Check if any visible query params in operations use map types,
713
+ * requiring bracket-encoded loop generation.
714
+ */
715
+ function _hasMapQueryParams(op: Operation, hidden: Set<string>): boolean {
716
+ return op.queryParams.some((qp) => !hidden.has(qp.name) && qp.type.kind === 'map');
717
+ }
718
+
719
+ function buildPathExpr(op: Operation): string {
720
+ if (op.pathParams.length === 0) {
721
+ return `"${op.path}"`;
722
+ }
723
+ // Build fmt.Sprintf expression (sorted by template order)
724
+ let fmtStr = op.path;
725
+ const args: string[] = [];
726
+ for (const p of sortPathParamsByTemplateOrder(op)) {
727
+ fmtStr = fmtStr.replace(`{${p.name}}`, '%s');
728
+ args.push(lowerFirst(fieldName(p.name)));
729
+ }
730
+ return `fmt.Sprintf("${fmtStr}", ${args.join(', ')})`;
731
+ }
732
+
733
+ function bodyArgument(op: Operation): string {
734
+ if (op.requestBody?.kind === 'model') {
735
+ return 'params';
736
+ }
737
+ return 'params.Body';
738
+ }
739
+
740
+ function mapQueryParamType(name: string, type: import('@workos/oagen').TypeRef): string {
741
+ if (name === 'limit' && type.kind === 'primitive' && (type.type === 'integer' || type.type === 'number')) {
742
+ return 'int';
743
+ }
744
+ return mapTypeRef(type);
745
+ }
746
+
747
+ function makeOptional(goType: string): string {
748
+ if (goType.startsWith('*') || goType.startsWith('[]') || goType.startsWith('map[')) {
749
+ return goType;
750
+ }
751
+ return `*${goType}`;
752
+ }
753
+
754
+ /**
755
+ * Resolve the iterator item type for pagination. If the item type is a list
756
+ * wrapper model (which we skip in models.ts), unwrap it to the actual data item.
757
+ */
758
+ function resolveIteratorItemType(itemType: import('@workos/oagen').TypeRef, ctx: EmitterContext): string {
759
+ if (itemType.kind === 'model') {
760
+ // Check if this is a list wrapper model -- if so, unwrap to its data array's item type
761
+ const model = ctx.spec.models.find((m) => m.name === itemType.name);
762
+ if (model && isListWrapperModel(model)) {
763
+ const dataField = model.fields.find((f) => f.name === 'data');
764
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
765
+ return className(dataField.type.items.name);
766
+ }
767
+ }
768
+ return className(itemType.name);
769
+ }
770
+ return mapTypeRefValue(itemType);
771
+ }
772
+
773
+ /** Go reserved words that cannot be used as identifiers. */
774
+ const GO_RESERVED = new Set([
775
+ 'break',
776
+ 'case',
777
+ 'chan',
778
+ 'const',
779
+ 'continue',
780
+ 'default',
781
+ 'defer',
782
+ 'else',
783
+ 'fallthrough',
784
+ 'for',
785
+ 'func',
786
+ 'go',
787
+ 'goto',
788
+ 'if',
789
+ 'import',
790
+ 'interface',
791
+ 'map',
792
+ 'package',
793
+ 'range',
794
+ 'return',
795
+ 'select',
796
+ 'struct',
797
+ 'switch',
798
+ 'type',
799
+ 'var',
800
+ ]);
801
+
802
+ function lowerFirst(s: string): string {
803
+ if (!s) return s;
804
+ const result = unexportedName(s);
805
+ // Escape Go reserved words by appending an underscore
806
+ if (GO_RESERVED.has(result)) return `${result}Param`;
807
+ return result;
808
+ }
809
+
810
+ /** Simple lowercase-first for human-readable descriptions (not identifiers). */
811
+ function lowerFirstDesc(s: string): string {
812
+ return lowerFirstForDoc(s);
813
+ }
814
+
815
+ function singularizePascal(name: string): string {
816
+ if (name.endsWith('ies')) {
817
+ return `${name.slice(0, -3)}y`;
818
+ }
819
+ if (name.endsWith('s') && !name.endsWith('ss')) {
820
+ return name.slice(0, -1);
821
+ }
822
+ return name;
823
+ }
824
+
825
+ function serviceTypeName(name: string): string {
826
+ return `${unexportedName(singularizePascal(name))}Service`;
827
+ }