@workos/oagen-emitters 0.7.5 → 0.8.1
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 +19 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BoTAX4nl.mjs → plugin-DOE0FqrZ.mjs} +164 -17
- package/dist/plugin-DOE0FqrZ.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +2 -2
- package/src/dotnet/index.ts +8 -4
- package/src/dotnet/models.ts +101 -1
- package/src/dotnet/type-map.ts +12 -3
- package/src/go/fixtures.ts +10 -2
- 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/client.ts +4 -6
- package/src/python/models.ts +45 -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-DOE0FqrZ.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.1",
|
|
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
|
/**
|
|
@@ -133,8 +133,11 @@ export const dotnetEmitter: Emitter = {
|
|
|
133
133
|
lines.push(' {');
|
|
134
134
|
lines.push(' public override bool CanConvert(Type objectType) => objectType == typeof(object);');
|
|
135
135
|
lines.push('');
|
|
136
|
+
// Override returns `object?` to match Newtonsoft.Json 13+'s nullable
|
|
137
|
+
// signature; `JToken.ToObject<T>` is itself `T?`, so a non-nullable
|
|
138
|
+
// override would trigger CS8603 under <Nullable>enable</Nullable>.
|
|
136
139
|
lines.push(
|
|
137
|
-
' public override object ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
|
|
140
|
+
' public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
|
|
138
141
|
);
|
|
139
142
|
lines.push(' {');
|
|
140
143
|
lines.push(' var jObject = JObject.Load(reader);');
|
|
@@ -142,7 +145,7 @@ export const dotnetEmitter: Emitter = {
|
|
|
142
145
|
lines.push(' switch (discriminatorValue)');
|
|
143
146
|
lines.push(' {');
|
|
144
147
|
for (const [value, modelName] of Object.entries(disc.mapping)) {
|
|
145
|
-
const csName = modelClassName(modelName);
|
|
148
|
+
const csName = modelClassName(resolveModelName(modelName));
|
|
146
149
|
lines.push(` case "${value}": return jObject.ToObject<${csName}>(serializer);`);
|
|
147
150
|
}
|
|
148
151
|
lines.push(' default: return jObject.ToObject<object>(serializer);');
|
|
@@ -194,8 +197,9 @@ export const dotnetEmitter: Emitter = {
|
|
|
194
197
|
` public override bool CanConvert(Type objectType) => typeof(${baseClass}).IsAssignableFrom(objectType);`,
|
|
195
198
|
);
|
|
196
199
|
lines.push('');
|
|
200
|
+
// See first converter — `object?` matches Newtonsoft 13+ to avoid CS8603.
|
|
197
201
|
lines.push(
|
|
198
|
-
' public override object ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
|
|
202
|
+
' public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
|
|
199
203
|
);
|
|
200
204
|
lines.push(' {');
|
|
201
205
|
lines.push(' var jObject = JObject.Load(reader);');
|
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,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
emitJsonPropertyAttributes,
|
|
7
8
|
setModelAliases,
|
|
8
9
|
isModelAlias,
|
|
10
|
+
resolveModelName,
|
|
9
11
|
} from './type-map.js';
|
|
10
12
|
import {
|
|
11
13
|
articleFor,
|
|
@@ -56,8 +58,23 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
56
58
|
const files: GeneratedFile[] = [];
|
|
57
59
|
|
|
58
60
|
// Compute and publish model aliases so mapTypeRef rewrites references.
|
|
61
|
+
// Must run BEFORE collectRequestBodyOnlyModelNames so the body/non-body
|
|
62
|
+
// tally collapses aliased pairs onto their canonical name — otherwise a
|
|
63
|
+
// model that's only a request body in name (e.g. `AddRolePermissionDto`)
|
|
64
|
+
// but is the canonical for a field-referenced alias (e.g. `SlimRole`)
|
|
65
|
+
// would be wrongly classified as body-only and skipped from emission,
|
|
66
|
+
// leaving every alias-rewritten field reference dangling.
|
|
59
67
|
primeModelAliases(models);
|
|
60
68
|
|
|
69
|
+
// Models that are referenced ONLY as an operation request body (not by any
|
|
70
|
+
// response, field, or other operation type) are dead surface in .NET because
|
|
71
|
+
// the wrapper generator emits a per-operation `*Options` class containing
|
|
72
|
+
// the same fields. The method signature consumes the Options class — the
|
|
73
|
+
// named entity is never instantiated by callers and just clutters the SDK
|
|
74
|
+
// (see workos-dotnet#248 with `CreateUserApiKey` /
|
|
75
|
+
// `UserManagementCreateApiKeyOptions`). Skip emission for those.
|
|
76
|
+
const requestBodyOnlyNames = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
|
|
77
|
+
|
|
61
78
|
// Build a lookup of base model field C# names → C# types for inheritance.
|
|
62
79
|
// Variant models skip inherited fields and use `new` for type-divergent ones.
|
|
63
80
|
const baseFieldLookup = new Map<string, Map<string, string>>();
|
|
@@ -75,6 +92,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
75
92
|
|
|
76
93
|
for (const model of models) {
|
|
77
94
|
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
95
|
+
if (requestBodyOnlyNames.has(model.name)) continue;
|
|
78
96
|
|
|
79
97
|
const csClassName = modelClassName(model.name);
|
|
80
98
|
|
|
@@ -217,6 +235,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
217
235
|
|
|
218
236
|
const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
|
|
219
237
|
lines.push(...emitJsonPropertyAttributes(field.name, { isRequiredEnum }));
|
|
238
|
+
// Discriminated-union-typed field: attach the variant-dispatching converter
|
|
239
|
+
// so Newtonsoft picks the right subtype on deserialization. The converter
|
|
240
|
+
// name is keyed off the first IR variant model name (matches how
|
|
241
|
+
// `joinUnionVariants` registered it in `discriminatedUnions`).
|
|
242
|
+
const discriminatedUnionConverter = discriminatedUnionConverterName(field.type);
|
|
243
|
+
if (discriminatedUnionConverter) {
|
|
244
|
+
lines.push(` [Newtonsoft.Json.JsonConverter(typeof(${discriminatedUnionConverter}))]`);
|
|
245
|
+
}
|
|
220
246
|
const newMod = useNewModifier ? 'new ' : '';
|
|
221
247
|
lines.push(` public ${newMod}${csType} ${csFieldName} { get; ${setterModifier}set; }${initializer}`);
|
|
222
248
|
|
|
@@ -289,6 +315,24 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
|
|
|
289
315
|
return files;
|
|
290
316
|
}
|
|
291
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Compute the name of the discriminator converter class for a field whose
|
|
320
|
+
* type is a discriminated union, mirroring the keying used in
|
|
321
|
+
* `joinUnionVariants` (first IR model variant name + "DiscriminatorConverter").
|
|
322
|
+
* Returns null when the type isn't a discriminated union with a populated
|
|
323
|
+
* mapping. Also walks through `nullable` so an optional discriminated field
|
|
324
|
+
* still gets the converter applied.
|
|
325
|
+
*/
|
|
326
|
+
function discriminatedUnionConverterName(ref: TypeRef): string | null {
|
|
327
|
+
const inner = ref.kind === 'nullable' ? ref.inner : ref;
|
|
328
|
+
if (inner.kind !== 'union') return null;
|
|
329
|
+
if (!inner.discriminator || !inner.discriminator.mapping) return null;
|
|
330
|
+
if (Object.keys(inner.discriminator.mapping).length === 0) return null;
|
|
331
|
+
const firstModel = inner.variants.find((v) => v.kind === 'model');
|
|
332
|
+
if (!firstModel || firstModel.kind !== 'model') return null;
|
|
333
|
+
return `${modelClassName(firstModel.name)}DiscriminatorConverter`;
|
|
334
|
+
}
|
|
335
|
+
|
|
292
336
|
/**
|
|
293
337
|
* Whether the emitted C# type is `Dictionary<string, object>` or its
|
|
294
338
|
* nullable variant — the usual shape of metadata / additional-properties
|
|
@@ -403,3 +447,59 @@ function structuralHash(model: Model, aliasOf: Map<string, string> = new Map()):
|
|
|
403
447
|
.sort()
|
|
404
448
|
.join('|');
|
|
405
449
|
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Names of models referenced **only** as a named operation request body —
|
|
453
|
+
* i.e. never appearing in a response, an error, a paginated item type, or as
|
|
454
|
+
* a field type on another model. The .NET wrapper generator emits a
|
|
455
|
+
* per-operation `*Options` class containing the same fields, so the named
|
|
456
|
+
* entity is never instantiated by callers and just clutters the SDK
|
|
457
|
+
* (workos-dotnet#248: `CreateUserApiKey` vs `UserManagementCreateApiKeyOptions`).
|
|
458
|
+
*/
|
|
459
|
+
function collectRequestBodyOnlyModelNames(services: Service[], models: Model[]): Set<string> {
|
|
460
|
+
const requestBodyNames = new Set<string>();
|
|
461
|
+
const otherReferences = new Set<string>();
|
|
462
|
+
|
|
463
|
+
// Resolve every reference through the alias map so structurally-identical
|
|
464
|
+
// models share a body/non-body classification. Without this, an alias being
|
|
465
|
+
// used as a field would only mark the alias name as non-body — leaving its
|
|
466
|
+
// canonical (which carries the same shape and gets emitted) wrongly tagged
|
|
467
|
+
// as body-only and skipped.
|
|
468
|
+
const collect = (ref: TypeRef | undefined, into: Set<string>): void => {
|
|
469
|
+
if (!ref) return;
|
|
470
|
+
walkTypeRef(ref, {
|
|
471
|
+
model: (r) => into.add(resolveModelName(r.name)),
|
|
472
|
+
});
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
for (const service of services) {
|
|
476
|
+
for (const op of service.operations) {
|
|
477
|
+
if (op.requestBody?.kind === 'model') {
|
|
478
|
+
requestBodyNames.add(resolveModelName(op.requestBody.name));
|
|
479
|
+
}
|
|
480
|
+
collect(op.response, otherReferences);
|
|
481
|
+
if (op.pagination) collect(op.pagination.itemType, otherReferences);
|
|
482
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
483
|
+
collect(p.type, otherReferences);
|
|
484
|
+
}
|
|
485
|
+
if (op.successResponses) {
|
|
486
|
+
for (const sr of op.successResponses) collect(sr.type, otherReferences);
|
|
487
|
+
}
|
|
488
|
+
for (const err of op.errors) {
|
|
489
|
+
if (err.type) collect(err.type, otherReferences);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
for (const model of models) {
|
|
495
|
+
for (const field of model.fields) {
|
|
496
|
+
collect(field.type, otherReferences);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const result = new Set<string>();
|
|
501
|
+
for (const name of requestBodyNames) {
|
|
502
|
+
if (!otherReferences.has(name)) result.add(name);
|
|
503
|
+
}
|
|
504
|
+
return result;
|
|
505
|
+
}
|
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/fixtures.ts
CHANGED
|
@@ -46,7 +46,12 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// Generate list fixtures for paginated responses
|
|
49
|
+
// Generate list fixtures for paginated responses. Multiple operations may
|
|
50
|
+
// share the same item model (e.g. several role-assignment list endpoints all
|
|
51
|
+
// returning UserRoleAssignmentList) — emit each fixture path once so the
|
|
52
|
+
// content-dedup pass below doesn't see N copies of the same path and drop
|
|
53
|
+
// the file entirely.
|
|
54
|
+
const seenListPaths = new Set<string>();
|
|
50
55
|
for (const service of spec.services) {
|
|
51
56
|
for (const op of service.operations) {
|
|
52
57
|
if (op.pagination) {
|
|
@@ -55,6 +60,9 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
|
|
|
55
60
|
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
56
61
|
if (unwrapped) itemModel = unwrapped;
|
|
57
62
|
if (itemModel.fields.length === 0) continue;
|
|
63
|
+
const path = `testdata/list_${fileName(itemModel.name)}.json`;
|
|
64
|
+
if (seenListPaths.has(path)) continue;
|
|
65
|
+
seenListPaths.add(path);
|
|
58
66
|
const fixture = generateModelFixture(itemModel, modelMap, enumMap);
|
|
59
67
|
const listFixture = {
|
|
60
68
|
data: [fixture],
|
|
@@ -64,7 +72,7 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
|
|
|
64
72
|
},
|
|
65
73
|
};
|
|
66
74
|
files.push({
|
|
67
|
-
path
|
|
75
|
+
path,
|
|
68
76
|
content: JSON.stringify(listFixture, null, 2),
|
|
69
77
|
});
|
|
70
78
|
}
|
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/client.ts
CHANGED
|
@@ -286,12 +286,10 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
286
286
|
overwriteExisting: true,
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
skipIfExists: true,
|
|
294
|
-
});
|
|
289
|
+
// models/__init__.py is emitted unconditionally by `models.ts` — including
|
|
290
|
+
// an empty barrel for services with no models — so we don't need a safety
|
|
291
|
+
// net here. (A `skipIfExists` safety net previously caused stale imports
|
|
292
|
+
// to survive when the underlying module was pruned.)
|
|
295
293
|
}
|
|
296
294
|
|
|
297
295
|
return files;
|
package/src/python/models.ts
CHANGED
|
@@ -447,6 +447,24 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
447
447
|
serviceDirModelPaths.add(`src/${ctx.namespace}/${dirName}/models`);
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
// Emit an empty barrel for every service-models dir that has no symbols of
|
|
451
|
+
// its own (e.g. a service whose models live in another package via
|
|
452
|
+
// cross-domain aliases). Otherwise the live SDK can keep a stale
|
|
453
|
+
// `__init__.py` from a previous spec revision — when the underlying module
|
|
454
|
+
// gets pruned the dangling re-export survives and breaks pyright. Done here
|
|
455
|
+
// (not in client.ts) so a subsequent emission for the same path with real
|
|
456
|
+
// content always wins last-write-wins.
|
|
457
|
+
for (const dirPath of serviceDirModelPaths) {
|
|
458
|
+
if (!symbolsByDir.has(dirPath)) {
|
|
459
|
+
files.push({
|
|
460
|
+
path: `${dirPath}/__init__.py`,
|
|
461
|
+
content: '',
|
|
462
|
+
integrateTarget: true,
|
|
463
|
+
overwriteExisting: true,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
450
468
|
for (const [dirPath, names] of symbolsByDir) {
|
|
451
469
|
// Use `import X as X` syntax for explicit re-exports (required by pyright strict)
|
|
452
470
|
const uniqueNames = [...new Set(names)].sort();
|
|
@@ -709,7 +727,27 @@ function deserializeField(ref: any, accessor: string, isRequired: boolean, walru
|
|
|
709
727
|
return deserializeField(ref.inner, accessor, false, walrusVar);
|
|
710
728
|
case 'union': {
|
|
711
729
|
const modelVariants = (ref.variants ?? []).filter((v: any) => v.kind === 'model');
|
|
712
|
-
const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))];
|
|
730
|
+
const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))] as string[];
|
|
731
|
+
// Discriminated union: dispatch on the discriminator property to call
|
|
732
|
+
// the matching variant's from_dict. Unknown discriminator values fall
|
|
733
|
+
// back to the raw payload so callers can introspect.
|
|
734
|
+
if (ref.discriminator && ref.discriminator.mapping) {
|
|
735
|
+
const entries = Object.entries(ref.discriminator.mapping as Record<string, string>);
|
|
736
|
+
if (entries.length > 0) {
|
|
737
|
+
const dispatchMap = entries.map(([value, modelName]) => `"${value}": ${className(modelName)}`).join(', ');
|
|
738
|
+
const dataExpr = isRequired ? accessor : walrusVar;
|
|
739
|
+
const dataCast = `cast(Dict[str, Any], ${dataExpr})`;
|
|
740
|
+
// The dispatch dict has `str` keys, so pyright (strict) rejects the
|
|
741
|
+
// raw `Any | None` returned by `.get(prop)` even though `dict.get`
|
|
742
|
+
// accepts any hashable. Cast through `str` to satisfy the parameter
|
|
743
|
+
// type — runtime semantics are unchanged because a missing/`None`
|
|
744
|
+
// discriminator simply misses the dispatch and falls through.
|
|
745
|
+
const lookupExpr = `{${dispatchMap}}.get(cast(str, ${dataCast}.get("${ref.discriminator.property}")))`;
|
|
746
|
+
const branch = `(_disc.from_dict(${dataCast}) if (_disc := ${lookupExpr}) is not None else ${dataExpr})`;
|
|
747
|
+
if (isRequired) return branch;
|
|
748
|
+
return `(${branch}) if (${walrusVar} := ${accessor}) is not None else None`;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
713
751
|
if (uniqueModels.length === 1) {
|
|
714
752
|
return deserializeField({ kind: 'model', name: uniqueModels[0] }, accessor, isRequired, walrusVar);
|
|
715
753
|
}
|
|
@@ -745,6 +783,12 @@ function serializeField(ref: any, accessor: string): string {
|
|
|
745
783
|
if (uniqueModels.length === 1) {
|
|
746
784
|
return `${accessor}.to_dict()`;
|
|
747
785
|
}
|
|
786
|
+
// Discriminated union: deserialize produced a dataclass instance for
|
|
787
|
+
// known discriminator values and the raw dict for unknowns. Round-trip
|
|
788
|
+
// both — call `.to_dict()` if it exists, otherwise pass through.
|
|
789
|
+
if (ref.discriminator && ref.discriminator.mapping && modelVariants.length > 0) {
|
|
790
|
+
return `${accessor}.to_dict() if hasattr(${accessor}, "to_dict") else ${accessor}`;
|
|
791
|
+
}
|
|
748
792
|
return accessor;
|
|
749
793
|
}
|
|
750
794
|
default:
|
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
|
{
|