@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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +12 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BoTAX4nl.mjs → plugin-bCMdV7KX.mjs} +150 -9
- package/dist/plugin-bCMdV7KX.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +2 -2
- package/src/dotnet/index.ts +2 -2
- package/src/dotnet/models.ts +89 -1
- package/src/dotnet/type-map.ts +12 -3
- package/src/go/models.ts +67 -1
- package/src/go/resources.ts +5 -3
- package/src/go/type-map.ts +24 -1
- package/src/kotlin/type-map.ts +11 -2
- package/src/php/models.ts +12 -0
- package/src/python/models.ts +16 -1
- package/src/ruby/models.ts +24 -5
- package/test/go/resources.test.ts +37 -1
- package/test/kotlin/resources.test.ts +29 -0
- package/dist/plugin-BoTAX4nl.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
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.
|
|
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.
|
|
57
|
+
"@workos/oagen": "^0.17.0"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/dotnet/index.ts
CHANGED
|
@@ -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);');
|
package/src/dotnet/models.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/dotnet/type-map.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/go/resources.ts
CHANGED
|
@@ -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
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
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])}`);
|
package/src/go/type-map.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/kotlin/type-map.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/python/models.ts
CHANGED
|
@@ -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
|
}
|
package/src/ruby/models.ts
CHANGED
|
@@ -269,11 +269,30 @@ function deserializeExpression(
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
if (ref.kind === 'union') {
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
{
|