@workos/oagen-emitters 0.7.5 → 0.8.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.
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BoTAX4nl.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-bCMdV7KX.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.16.0"
57
+ "@workos/oagen": "^0.17.0"
58
58
  }
59
59
  }
@@ -20,7 +20,7 @@ import { generateTests } from './tests.js';
20
20
  import { buildOperationsMap } from './manifest.js';
21
21
  import { generateWrapperOptionsClasses } from './wrappers.js';
22
22
  import { groupByMount } from '../shared/resolved-ops.js';
23
- import { discriminatedUnions } from './type-map.js';
23
+ import { discriminatedUnions, resolveModelName } from './type-map.js';
24
24
  import { modelClassName } from './naming.js';
25
25
 
26
26
  /**
@@ -142,7 +142,7 @@ export const dotnetEmitter: Emitter = {
142
142
  lines.push(' switch (discriminatorValue)');
143
143
  lines.push(' {');
144
144
  for (const [value, modelName] of Object.entries(disc.mapping)) {
145
- const csName = modelClassName(modelName);
145
+ const csName = modelClassName(resolveModelName(modelName));
146
146
  lines.push(` case "${value}": return jObject.ToObject<${csName}>(serializer);`);
147
147
  }
148
148
  lines.push(' default: return jObject.ToObject<object>(serializer);');
@@ -1,4 +1,5 @@
1
- import type { Model, EmitterContext, GeneratedFile, TypeRef } from '@workos/oagen';
1
+ import type { Model, EmitterContext, GeneratedFile, TypeRef, Service } from '@workos/oagen';
2
+ import { walkTypeRef } from '@workos/oagen';
2
3
  import {
3
4
  mapTypeRef,
4
5
  isValueTypeRef,
@@ -53,6 +54,15 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
53
54
  }
54
55
  }
55
56
 
57
+ // Models that are referenced ONLY as an operation request body (not by any
58
+ // response, field, or other operation type) are dead surface in .NET because
59
+ // the wrapper generator emits a per-operation `*Options` class containing
60
+ // the same fields. The method signature consumes the Options class — the
61
+ // named entity is never instantiated by callers and just clutters the SDK
62
+ // (see workos-dotnet#248 with `CreateUserApiKey` /
63
+ // `UserManagementCreateApiKeyOptions`). Skip emission for those.
64
+ const requestBodyOnlyNames = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
65
+
56
66
  const files: GeneratedFile[] = [];
57
67
 
58
68
  // Compute and publish model aliases so mapTypeRef rewrites references.
@@ -75,6 +85,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
75
85
 
76
86
  for (const model of models) {
77
87
  if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
88
+ if (requestBodyOnlyNames.has(model.name)) continue;
78
89
 
79
90
  const csClassName = modelClassName(model.name);
80
91
 
@@ -217,6 +228,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
217
228
 
218
229
  const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
219
230
  lines.push(...emitJsonPropertyAttributes(field.name, { isRequiredEnum }));
231
+ // Discriminated-union-typed field: attach the variant-dispatching converter
232
+ // so Newtonsoft picks the right subtype on deserialization. The converter
233
+ // name is keyed off the first IR variant model name (matches how
234
+ // `joinUnionVariants` registered it in `discriminatedUnions`).
235
+ const discriminatedUnionConverter = discriminatedUnionConverterName(field.type);
236
+ if (discriminatedUnionConverter) {
237
+ lines.push(` [Newtonsoft.Json.JsonConverter(typeof(${discriminatedUnionConverter}))]`);
238
+ }
220
239
  const newMod = useNewModifier ? 'new ' : '';
221
240
  lines.push(` public ${newMod}${csType} ${csFieldName} { get; ${setterModifier}set; }${initializer}`);
222
241
 
@@ -289,6 +308,24 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
289
308
  return files;
290
309
  }
291
310
 
311
+ /**
312
+ * Compute the name of the discriminator converter class for a field whose
313
+ * type is a discriminated union, mirroring the keying used in
314
+ * `joinUnionVariants` (first IR model variant name + "DiscriminatorConverter").
315
+ * Returns null when the type isn't a discriminated union with a populated
316
+ * mapping. Also walks through `nullable` so an optional discriminated field
317
+ * still gets the converter applied.
318
+ */
319
+ function discriminatedUnionConverterName(ref: TypeRef): string | null {
320
+ const inner = ref.kind === 'nullable' ? ref.inner : ref;
321
+ if (inner.kind !== 'union') return null;
322
+ if (!inner.discriminator || !inner.discriminator.mapping) return null;
323
+ if (Object.keys(inner.discriminator.mapping).length === 0) return null;
324
+ const firstModel = inner.variants.find((v) => v.kind === 'model');
325
+ if (!firstModel || firstModel.kind !== 'model') return null;
326
+ return `${modelClassName(firstModel.name)}DiscriminatorConverter`;
327
+ }
328
+
292
329
  /**
293
330
  * Whether the emitted C# type is `Dictionary<string, object>` or its
294
331
  * nullable variant — the usual shape of metadata / additional-properties
@@ -403,3 +440,54 @@ function structuralHash(model: Model, aliasOf: Map<string, string> = new Map()):
403
440
  .sort()
404
441
  .join('|');
405
442
  }
443
+
444
+ /**
445
+ * Names of models referenced **only** as a named operation request body —
446
+ * i.e. never appearing in a response, an error, a paginated item type, or as
447
+ * a field type on another model. The .NET wrapper generator emits a
448
+ * per-operation `*Options` class containing the same fields, so the named
449
+ * entity is never instantiated by callers and just clutters the SDK
450
+ * (workos-dotnet#248: `CreateUserApiKey` vs `UserManagementCreateApiKeyOptions`).
451
+ */
452
+ function collectRequestBodyOnlyModelNames(services: Service[], models: Model[]): Set<string> {
453
+ const requestBodyNames = new Set<string>();
454
+ const otherReferences = new Set<string>();
455
+
456
+ const collect = (ref: TypeRef | undefined, into: Set<string>): void => {
457
+ if (!ref) return;
458
+ walkTypeRef(ref, {
459
+ model: (r) => into.add(r.name),
460
+ });
461
+ };
462
+
463
+ for (const service of services) {
464
+ for (const op of service.operations) {
465
+ if (op.requestBody?.kind === 'model') {
466
+ requestBodyNames.add(op.requestBody.name);
467
+ }
468
+ collect(op.response, otherReferences);
469
+ if (op.pagination) collect(op.pagination.itemType, otherReferences);
470
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
471
+ collect(p.type, otherReferences);
472
+ }
473
+ if (op.successResponses) {
474
+ for (const sr of op.successResponses) collect(sr.type, otherReferences);
475
+ }
476
+ for (const err of op.errors) {
477
+ if (err.type) collect(err.type, otherReferences);
478
+ }
479
+ }
480
+ }
481
+
482
+ for (const model of models) {
483
+ for (const field of model.fields) {
484
+ collect(field.type, otherReferences);
485
+ }
486
+ }
487
+
488
+ const result = new Set<string>();
489
+ for (const name of requestBodyNames) {
490
+ if (!otherReferences.has(name)) result.add(name);
491
+ }
492
+ return result;
493
+ }
@@ -200,11 +200,18 @@ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
200
200
  return variants[0] ?? 'object';
201
201
  }
202
202
  const unique = [...new Set(variants)];
203
- if (unique.length === 1) return unique[0];
204
203
 
205
- // Discriminated union: register for converter generation and return first variant as base
204
+ // Discriminated union: register for converter generation BEFORE collapsing
205
+ // single-unique to avoid losing the discriminator when all variants alias
206
+ // to the same canonical model (structural dedup). The base type used for
207
+ // the converter name comes from the discriminator's IR variant names so
208
+ // it stays stable even when the C# class names get rewritten by aliases.
206
209
  if (_ref.discriminator && _ref.discriminator.mapping) {
207
- const baseName = unique[0];
210
+ const irVariantNames = _ref.variants
211
+ .filter((v) => v.kind === 'model')
212
+ .map((v) => (v.kind === 'model' ? v.name : ''))
213
+ .filter(Boolean);
214
+ const baseName = irVariantNames[0] ?? unique[0];
208
215
  discriminatedUnions.set(baseName, {
209
216
  property: _ref.discriminator.property,
210
217
  mapping: _ref.discriminator.mapping,
@@ -215,6 +222,8 @@ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
215
222
  return 'object';
216
223
  }
217
224
 
225
+ if (unique.length === 1) return unique[0];
226
+
218
227
  if (unique.length >= 2 && unique.length <= 9) return `OneOf.OneOf<${unique.join(', ')}>`;
219
228
  // OneOf supports arity 2-9. Higher-arity unions collapse to `object`,
220
229
  // losing type information. Warn so the author knows the spec outgrew the
package/src/go/models.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Model, EmitterContext, GeneratedFile } from '@workos/oagen';
1
+ import type { Model, EmitterContext, GeneratedFile, TypeRef, Service } from '@workos/oagen';
2
+ import { walkTypeRef } from '@workos/oagen';
2
3
  import { mapTypeRef } from './type-map.js';
3
4
  import { className, fieldName } from './naming.js';
4
5
  import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-utils.js';
@@ -7,6 +8,67 @@ import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-
7
8
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
8
9
  export { isListWrapperModel, isListMetadataModel };
9
10
 
11
+ /**
12
+ * Collect names of models that are referenced **only** as a named request body
13
+ * model on an operation, with no other consumers (response types, pagination
14
+ * item types, error types, or fields on other models).
15
+ *
16
+ * The Go emitter synthesizes a `{Service}{Method}Params` struct from those
17
+ * request bodies (see `resources.ts:392`), and the method signature uses the
18
+ * synthesized struct — never the named model. So emitting the named model in
19
+ * `models.go` would leave callers with a duplicate, unused struct (the bug
20
+ * surfaced in workos-go#544 with `CreateUserAPIKey` /
21
+ * `UserManagementCreateAPIKeyParams`).
22
+ *
23
+ * Models in this set are skipped during model emission. Callers parameterize
24
+ * the API surface through `*Params` exclusively, which is the sole consumer
25
+ * of the spec's named request body schema.
26
+ */
27
+ function collectRequestBodyOnlyModelNames(services: Service[], models: Model[]): Set<string> {
28
+ const requestBodyNames = new Set<string>();
29
+ const otherReferences = new Set<string>();
30
+
31
+ const collect = (ref: TypeRef | undefined, into: Set<string>): void => {
32
+ if (!ref) return;
33
+ walkTypeRef(ref, {
34
+ model: (r) => into.add(r.name),
35
+ });
36
+ };
37
+
38
+ for (const service of services) {
39
+ for (const op of service.operations) {
40
+ if (op.requestBody?.kind === 'model') {
41
+ requestBodyNames.add(op.requestBody.name);
42
+ }
43
+ collect(op.response, otherReferences);
44
+ if (op.pagination) collect(op.pagination.itemType, otherReferences);
45
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
46
+ collect(p.type, otherReferences);
47
+ }
48
+ if (op.successResponses) {
49
+ for (const sr of op.successResponses) collect(sr.type, otherReferences);
50
+ }
51
+ for (const err of op.errors) {
52
+ if (err.type) collect(err.type, otherReferences);
53
+ }
54
+ }
55
+ }
56
+
57
+ // Field references — a request body model that's also a field type elsewhere
58
+ // is a reusable schema (e.g. consumed by webhook event data), so we keep it.
59
+ for (const model of models) {
60
+ for (const field of model.fields) {
61
+ collect(field.type, otherReferences);
62
+ }
63
+ }
64
+
65
+ const result = new Set<string>();
66
+ for (const name of requestBodyNames) {
67
+ if (!otherReferences.has(name)) result.add(name);
68
+ }
69
+ return result;
70
+ }
71
+
10
72
  /**
11
73
  * Generate Go struct definitions from IR Models.
12
74
  * All models go into a single models.go file for the flat package.
@@ -20,11 +82,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
20
82
  lines.push(`package ${ctx.namespace}`);
21
83
  lines.push('');
22
84
 
85
+ const requestBodyOnly = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
86
+
23
87
  // Build structural hash for deduplication
24
88
  const modelHashMap = new Map<string, string>();
25
89
  const hashGroups = new Map<string, string[]>();
26
90
  for (const model of models) {
27
91
  if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
92
+ if (requestBodyOnly.has(model.name)) continue;
28
93
  const hash = structuralHash(model);
29
94
  modelHashMap.set(model.name, hash);
30
95
  if (!hashGroups.has(hash)) hashGroups.set(hash, []);
@@ -48,6 +113,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
48
113
  const batchedAliases = new Set<string>();
49
114
  for (const model of models) {
50
115
  if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
116
+ if (requestBodyOnly.has(model.name)) continue;
51
117
 
52
118
  const structName = className(model.name);
53
119
 
@@ -410,9 +410,11 @@ function generateParamsStruct(
410
410
  const isOptional = !field.required;
411
411
  const goType = isOptional ? makeOptional(mapTypeRef(field.type)) : mapTypeRef(field.type);
412
412
  const jsonTag = field.required ? `json:"${field.name}"` : `json:"${field.name},omitempty"`;
413
- // If this field also appears in query params, emit a url tag too
414
- const isAlsoQueryParam = op.queryParams.some((qp) => !hidden.has(qp.name) && fieldName(qp.name) === goField);
415
- const urlTag = isAlsoQueryParam ? ` url:"${field.name}${field.required ? '' : ',omitempty'}"` : '';
413
+ // Body fields are JSON-marshaled into the request body. Emit `url:"-"`
414
+ // so go-querystring's default field-name fallback doesn't also place
415
+ // them in the URL important when the spec duplicates a field as both
416
+ // body and query param (e.g. /sso/token's `code`).
417
+ const urlTag = ' url:"-"';
416
418
  if (field.description) {
417
419
  const fdLines = field.description.split('\n').filter((l) => l.trim());
418
420
  lines.push(`\t// ${fieldDocComment(goField, fdLines[0])}`);
@@ -82,6 +82,29 @@ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
82
82
  }
83
83
  const unique = [...new Set(variants)];
84
84
  if (unique.length === 1) return unique[0];
85
- // Go doesn't have union types; use interface{}
85
+ // Discriminated unions: Go has no sum types, so widen to the discriminator-
86
+ // resolver type when one is registered downstream (see resolveDiscriminatedUnionType).
87
+ // Fall back to interface{} for non-discriminated heterogeneous unions.
88
+ if (_ref.discriminator && _ref.discriminator.mapping) {
89
+ const resolverName = unionResolverName(_ref);
90
+ if (resolverName) return resolverName;
91
+ }
86
92
  return 'interface{}';
87
93
  }
94
+
95
+ /**
96
+ * Pick a stable type name for a discriminated union's runtime resolver. Today
97
+ * we emit no resolver struct, so we treat the union's first model variant as
98
+ * the public type — matching the pre-discriminator behavior where Go just
99
+ * referenced one variant directly. The Owner field stays typed, callers
100
+ * can still inspect Type to detect the user variant (data loss on
101
+ * non-overlapping fields like organization_id is documented in the SDK
102
+ * compat report rather than fixed in the emitter — Go callers who need the
103
+ * user-only fields can json.Unmarshal the raw payload manually).
104
+ */
105
+ function unionResolverName(ref: UnionType): string | null {
106
+ for (const v of ref.variants) {
107
+ if (v.kind === 'model') return `*${className(v.name)}`;
108
+ }
109
+ return null;
110
+ }
@@ -95,10 +95,17 @@ function joinUnionVariants(ref: UnionType, variants: string[]): string {
95
95
  return variants[0] ?? 'Any';
96
96
  }
97
97
  const unique = [...new Set(variants)];
98
- if (unique.length === 1) return unique[0];
99
98
 
99
+ // Register discriminated unions BEFORE the single-unique collapse so we
100
+ // don't lose the dispatcher when every variant aliases to the same canonical
101
+ // type. The base type used at the call site is the IR variant name, which
102
+ // matches how the dispatcher infrastructure looks up registered unions.
100
103
  if (ref.discriminator && ref.discriminator.mapping) {
101
- const baseName = unique[0];
104
+ const irVariantNames = ref.variants
105
+ .filter((v) => v.kind === 'model')
106
+ .map((v) => (v.kind === 'model' ? v.name : ''))
107
+ .filter(Boolean);
108
+ const baseName = irVariantNames[0] ?? unique[0];
102
109
  discriminatedUnions.set(baseName, {
103
110
  property: ref.discriminator.property,
104
111
  mapping: ref.discriminator.mapping,
@@ -108,6 +115,8 @@ function joinUnionVariants(ref: UnionType, variants: string[]): string {
108
115
  return baseName;
109
116
  }
110
117
 
118
+ if (unique.length === 1) return unique[0];
119
+
111
120
  // Non-discriminated unions fall back to the Kotlin top type. A generic
112
121
  // AnyOf<> is planned for a future phase if emitter tests prove it necessary.
113
122
  return 'Any';
package/src/php/models.ts CHANGED
@@ -220,6 +220,18 @@ function generateFromArrayValue(ref: TypeRef, accessor: string): string {
220
220
  case 'nullable':
221
221
  return generateFromArrayValue(ref.inner, accessor);
222
222
  case 'union': {
223
+ // Discriminated union: dispatch via match() on the discriminator
224
+ // property to call the matching variant's fromArray. Unknown values
225
+ // pass through as raw arrays so callers can introspect.
226
+ if (ref.discriminator && ref.discriminator.mapping) {
227
+ const entries = Object.entries(ref.discriminator.mapping);
228
+ if (entries.length > 0) {
229
+ const arms = entries
230
+ .map(([value, modelName]) => `'${value}' => ${className(modelName)}::fromArray(${accessor})`)
231
+ .join(', ');
232
+ return `match (${accessor}['${ref.discriminator.property}'] ?? null) { ${arms}, default => ${accessor} }`;
233
+ }
234
+ }
223
235
  const resolved = resolveDegenerateUnion(ref);
224
236
  if (resolved) return generateFromArrayValue(resolved, accessor);
225
237
  return accessor;
@@ -709,7 +709,22 @@ function deserializeField(ref: any, accessor: string, isRequired: boolean, walru
709
709
  return deserializeField(ref.inner, accessor, false, walrusVar);
710
710
  case 'union': {
711
711
  const modelVariants = (ref.variants ?? []).filter((v: any) => v.kind === 'model');
712
- const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))];
712
+ const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))] as string[];
713
+ // Discriminated union: dispatch on the discriminator property to call
714
+ // the matching variant's from_dict. Unknown discriminator values fall
715
+ // back to the raw payload so callers can introspect.
716
+ if (ref.discriminator && ref.discriminator.mapping) {
717
+ const entries = Object.entries(ref.discriminator.mapping as Record<string, string>);
718
+ if (entries.length > 0) {
719
+ const dispatchMap = entries.map(([value, modelName]) => `"${value}": ${className(modelName)}`).join(', ');
720
+ const dataExpr = isRequired ? accessor : walrusVar;
721
+ const dataCast = `cast(Dict[str, Any], ${dataExpr})`;
722
+ const lookupExpr = `{${dispatchMap}}.get(${dataCast}.get("${ref.discriminator.property}"))`;
723
+ const branch = `(_disc.from_dict(${dataCast}) if (_disc := ${lookupExpr}) is not None else ${dataExpr})`;
724
+ if (isRequired) return branch;
725
+ return `(${branch}) if (${walrusVar} := ${accessor}) is not None else None`;
726
+ }
727
+ }
713
728
  if (uniqueModels.length === 1) {
714
729
  return deserializeField({ kind: 'model', name: uniqueModels[0] }, accessor, isRequired, walrusVar);
715
730
  }
@@ -269,11 +269,30 @@ function deserializeExpression(
269
269
  }
270
270
 
271
271
  if (ref.kind === 'union') {
272
- // Unions: if all variants are models, try each; otherwise return raw value.
273
- const modelVariants = ref.variants.filter((v) => v.kind === 'model' && modelNames.has(v.name));
274
- if (modelVariants.length > 0 && modelVariants.length === ref.variants.length) {
275
- // Multiple model variants — default to first successful parse or raw value.
276
- return accessor; // simplification: return raw, let user inspect
272
+ // Discriminated union: dispatch on the discriminator property to construct
273
+ // the matching variant. Unknown values fall back to the raw hash so callers
274
+ // can still introspect (rather than crashing on an unmapped discriminator).
275
+ if (ref.discriminator && ref.discriminator.mapping) {
276
+ const entries = Object.entries(ref.discriminator.mapping).filter(([, name]) => modelNames.has(name));
277
+ if (entries.length > 0) {
278
+ const propAccess = rubyHashAccessor(accessor, ref.discriminator.property);
279
+ const branches = entries
280
+ .map(([value, modelName]) => {
281
+ const cls = `WorkOS::${className(modelName)}`;
282
+ return `when ${JSON.stringify(value)} then ${cls}.new(${accessor})`;
283
+ })
284
+ .join(' ');
285
+ const dispatcher = `(case ${propAccess} ${branches} else ${accessor} end)`;
286
+ return `${accessor} ? ${dispatcher} : nil`;
287
+ }
288
+ }
289
+ // Non-discriminated union of models: pick the first variant as a best-
290
+ // effort typed wrapper. Preserves the pre-discriminator behavior of
291
+ // wrapping inline-variant unions whose owner used to be a single model.
292
+ const firstModelVariant = ref.variants.find((v) => v.kind === 'model' && modelNames.has(v.name));
293
+ if (firstModelVariant && firstModelVariant.kind === 'model') {
294
+ const cls = `WorkOS::${className(firstModelVariant.name)}`;
295
+ return `${accessor} ? ${cls}.new(${accessor}) : nil`;
277
296
  }
278
297
  return accessor;
279
298
  }
@@ -220,11 +220,47 @@ describe('go/resources', () => {
220
220
  const files = generateResources(services, makeCtx(spec));
221
221
  const content = files[0].content;
222
222
  expect(content).toContain('type UsersCreateParams struct {');
223
- expect(content).toContain('Email string `json:"email"`');
223
+ expect(content).toContain('Email string `json:"email" url:"-"`');
224
224
  expect(content).toContain('Notify *bool `url:"notify,omitempty" json:"-"`');
225
225
  expect(content).toContain('request(ctx, "POST", "/users", params, params, &result, opts)');
226
226
  });
227
227
 
228
+ it('does not emit url tag for body fields shadowed by a same-named query param', () => {
229
+ // Spec quirk: an operation lists the same field name in both `parameters`
230
+ // (in: query) and `requestBody`. The body field must not also leak into
231
+ // the URL query string (e.g. /sso/token's `code` carries an auth code).
232
+ const services: Service[] = [
233
+ {
234
+ name: 'Sso',
235
+ operations: [
236
+ makeOp({
237
+ name: 'getProfileAndToken',
238
+ httpMethod: 'post',
239
+ path: '/sso/token',
240
+ requestBody: { kind: 'model', name: 'TokenQueryDto' },
241
+ queryParams: [
242
+ {
243
+ name: 'code',
244
+ type: { kind: 'primitive', type: 'string' },
245
+ required: true,
246
+ },
247
+ ],
248
+ }),
249
+ ],
250
+ },
251
+ ];
252
+ const spec = makeSpec(services, [
253
+ {
254
+ name: 'TokenQueryDto',
255
+ fields: [{ name: 'code', type: { kind: 'primitive', type: 'string' }, required: true }],
256
+ },
257
+ ]);
258
+ const files = generateResources(services, makeCtx(spec));
259
+ const content = files[0].content;
260
+ expect(content).toContain('Code string `json:"code" url:"-"`');
261
+ expect(content).not.toContain('json:"code" url:"code"');
262
+ });
263
+
228
264
  it('emits Deprecated comment for deprecated body field in params struct', () => {
229
265
  const services: Service[] = [
230
266
  {
@@ -42,6 +42,35 @@ function ctxFor(services: Service[], enums = baseSpec.enums): EmitterContext {
42
42
  }
43
43
 
44
44
  describe('kotlin/resources', () => {
45
+ it('wraps every path-template interpolation in encodePathSegment', () => {
46
+ const services: Service[] = [
47
+ {
48
+ name: 'Users',
49
+ operations: [
50
+ {
51
+ name: 'getUser',
52
+ httpMethod: 'get',
53
+ path: '/users/{id}/groups/{groupId}',
54
+ pathParams: [
55
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
56
+ { name: 'groupId', type: { kind: 'primitive', type: 'string' }, required: true },
57
+ ],
58
+ queryParams: [],
59
+ headerParams: [],
60
+ response: { kind: 'primitive', type: 'unknown' },
61
+ errors: [],
62
+ injectIdempotencyKey: false,
63
+ },
64
+ ],
65
+ },
66
+ ];
67
+ const files = generateResources(services, ctxFor(services));
68
+ const file = files.find((f) => f.path.endsWith('/Users.kt'))!;
69
+ expect(file.content).toContain('import com.workos.common.http.encodePathSegment');
70
+ expect(file.content).toContain('path = "/users/${encodePathSegment(id)}/groups/${encodePathSegment(groupId)}"');
71
+ expect(file.content).not.toMatch(/path = "[^"]*\$id[^{]/);
72
+ });
73
+
45
74
  it('collapses duplicated query/body params into a single Kotlin parameter', () => {
46
75
  const services: Service[] = [
47
76
  {