@workos/oagen-emitters 0.4.0 → 0.5.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +35 -224
- package/dist/index.d.mts +9 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -15234
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +5 -5
- package/oagen.config.ts +5 -373
- package/package.json +10 -34
- package/src/dotnet/index.ts +6 -4
- package/src/dotnet/models.ts +58 -82
- package/src/dotnet/naming.ts +44 -6
- package/src/dotnet/resources.ts +350 -29
- package/src/dotnet/tests.ts +44 -24
- package/src/dotnet/type-map.ts +44 -17
- package/src/dotnet/wrappers.ts +21 -10
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +10 -5
- package/src/go/models.ts +6 -1
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +14 -0
- package/src/kotlin/client.ts +7 -2
- package/src/kotlin/enums.ts +30 -3
- package/src/kotlin/models.ts +97 -6
- package/src/kotlin/naming.ts +7 -1
- package/src/kotlin/resources.ts +370 -39
- package/src/kotlin/tests.ts +120 -6
- package/src/node/client.ts +38 -11
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +156 -52
- package/src/node/tests.ts +76 -27
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/models.ts +0 -33
- package/src/php/resources.ts +199 -18
- package/src/php/tests.ts +26 -2
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +6 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +13 -3
- package/src/python/enums.ts +28 -3
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +138 -1
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +131 -7
- package/src/shared/naming-utils.ts +36 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/test/dotnet/client.test.ts +2 -2
- package/test/dotnet/models.test.ts +7 -9
- package/test/dotnet/resources.test.ts +135 -3
- package/test/dotnet/tests.test.ts +5 -5
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +1 -1
- package/test/kotlin/resources.test.ts +210 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +134 -26
- package/test/node/utils.test.ts +140 -0
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +66 -1
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { EmitterContext, Operation, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
|
|
2
|
+
import { className, fieldName, safeParamName } from './naming.js';
|
|
3
|
+
import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
|
|
4
|
+
import { mapTypeRefForYard } from './type-map.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate Ruby wrapper method lines for union split operations.
|
|
8
|
+
*
|
|
9
|
+
* Each wrapper is a typed convenience method that:
|
|
10
|
+
* - Accepts only the exposed params (keyword args)
|
|
11
|
+
* - Injects constant defaults (e.g., grant_type)
|
|
12
|
+
* - Reads inferred fields from client config (e.g., client_id)
|
|
13
|
+
* - Delegates to the same HTTP runtime as the main method
|
|
14
|
+
*/
|
|
15
|
+
export function generateWrapperMethods(
|
|
16
|
+
resolvedOp: ResolvedOperation,
|
|
17
|
+
ctx: EmitterContext,
|
|
18
|
+
modelNames: Set<string>,
|
|
19
|
+
requires: Set<string>,
|
|
20
|
+
): string[] {
|
|
21
|
+
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
22
|
+
const out: string[] = [];
|
|
23
|
+
for (const wrapper of resolvedOp.wrappers) {
|
|
24
|
+
const body = emitWrapperMethod(resolvedOp.operation, wrapper, ctx, modelNames, requires);
|
|
25
|
+
out.push(body);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Collect response model filenames needed for wrapper imports. */
|
|
31
|
+
export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
|
|
32
|
+
const models = new Set<string>();
|
|
33
|
+
for (const w of resolvedOp.wrappers ?? []) {
|
|
34
|
+
if (w.responseModelName) models.add(w.responseModelName);
|
|
35
|
+
}
|
|
36
|
+
return models;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function emitWrapperMethod(
|
|
40
|
+
op: Operation,
|
|
41
|
+
wrapper: ResolvedWrapper,
|
|
42
|
+
ctx: EmitterContext,
|
|
43
|
+
modelNames: Set<string>,
|
|
44
|
+
requires: Set<string>,
|
|
45
|
+
): string {
|
|
46
|
+
void requires;
|
|
47
|
+
const method = wrapper.name; // already snake_case
|
|
48
|
+
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
|
|
51
|
+
// YARD doc
|
|
52
|
+
lines.push(` # ${formatWrapperDescription(wrapper.name)}.`);
|
|
53
|
+
for (const p of op.pathParams ?? []) {
|
|
54
|
+
const pyType = mapTypeRefForYard(p.type);
|
|
55
|
+
lines.push(` # @param ${safeParamName(p.name)} [${pyType}]`);
|
|
56
|
+
}
|
|
57
|
+
for (const wp of wrapperParams) {
|
|
58
|
+
const type = wp.field ? mapTypeRefForYard(wp.field.type) : 'String';
|
|
59
|
+
const alreadyNilable = type.split(', ').includes('nil');
|
|
60
|
+
const suffix = wp.isOptional && !alreadyNilable ? ', nil' : '';
|
|
61
|
+
lines.push(` # @param ${fieldName(wp.paramName)} [${type}${suffix}]`);
|
|
62
|
+
}
|
|
63
|
+
lines.push(` # @param request_options [Hash] Per-request overrides.`);
|
|
64
|
+
if (wrapper.responseModelName) {
|
|
65
|
+
lines.push(` # @return [WorkOS::${className(wrapper.responseModelName)}]`);
|
|
66
|
+
} else {
|
|
67
|
+
lines.push(` # @return [nil]`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Signature
|
|
71
|
+
const sigParts: string[] = [];
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
for (const p of op.pathParams ?? []) {
|
|
74
|
+
const n = safeParamName(p.name);
|
|
75
|
+
if (seen.has(n)) continue;
|
|
76
|
+
seen.add(n);
|
|
77
|
+
sigParts.push(`${n}:`);
|
|
78
|
+
}
|
|
79
|
+
// Required first, then optional
|
|
80
|
+
for (const wp of wrapperParams) {
|
|
81
|
+
if (wp.isOptional) continue;
|
|
82
|
+
const n = fieldName(wp.paramName);
|
|
83
|
+
if (seen.has(n)) continue;
|
|
84
|
+
seen.add(n);
|
|
85
|
+
sigParts.push(`${n}:`);
|
|
86
|
+
}
|
|
87
|
+
for (const wp of wrapperParams) {
|
|
88
|
+
if (!wp.isOptional) continue;
|
|
89
|
+
const n = fieldName(wp.paramName);
|
|
90
|
+
if (seen.has(n)) continue;
|
|
91
|
+
seen.add(n);
|
|
92
|
+
sigParts.push(`${n}: nil`);
|
|
93
|
+
}
|
|
94
|
+
sigParts.push('request_options: {}');
|
|
95
|
+
|
|
96
|
+
if (sigParts.length === 1 && sigParts[0].length < 60) {
|
|
97
|
+
lines.push(` def ${method}(${sigParts[0]})`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(` def ${method}(`);
|
|
100
|
+
for (let i = 0; i < sigParts.length; i++) {
|
|
101
|
+
const sep = i === sigParts.length - 1 ? '' : ',';
|
|
102
|
+
lines.push(` ${sigParts[i]}${sep}`);
|
|
103
|
+
}
|
|
104
|
+
lines.push(' )');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Body hash
|
|
108
|
+
const bodyEntries: string[] = [];
|
|
109
|
+
for (const [k, v] of Object.entries(wrapper.defaults)) {
|
|
110
|
+
const lit = typeof v === 'string' ? rubyStringLit(v) : String(v);
|
|
111
|
+
bodyEntries.push(`${rubyStringLit(k)} => ${lit}`);
|
|
112
|
+
}
|
|
113
|
+
for (const fc of wrapper.inferFromClient) {
|
|
114
|
+
const clientProp = fc === 'client_secret' ? 'api_key' : fc;
|
|
115
|
+
bodyEntries.push(`${rubyStringLit(fc)} => @client.${clientProp}`);
|
|
116
|
+
}
|
|
117
|
+
for (const wp of wrapperParams) {
|
|
118
|
+
bodyEntries.push(`${rubyStringLit(wp.paramName)} => ${fieldName(wp.paramName)}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push(' body = {');
|
|
121
|
+
for (let i = 0; i < bodyEntries.length; i++) {
|
|
122
|
+
const sep = i === bodyEntries.length - 1 ? '' : ',';
|
|
123
|
+
lines.push(` ${bodyEntries[i]}${sep}`);
|
|
124
|
+
}
|
|
125
|
+
lines.push(' }.compact');
|
|
126
|
+
|
|
127
|
+
// Path string — use the unified @client.request helper.
|
|
128
|
+
const rubyPath = interpolateRubyPath(op.path, op.pathParams ?? []);
|
|
129
|
+
const verbSym = op.httpMethod.toLowerCase();
|
|
130
|
+
lines.push(' response = @client.request(');
|
|
131
|
+
lines.push(` method: :${verbSym},`);
|
|
132
|
+
lines.push(` path: ${rubyPath},`);
|
|
133
|
+
lines.push(' auth: true,');
|
|
134
|
+
lines.push(' body: body,');
|
|
135
|
+
lines.push(' request_options: request_options');
|
|
136
|
+
lines.push(' )');
|
|
137
|
+
|
|
138
|
+
// Response handling
|
|
139
|
+
if (wrapper.responseModelName && modelNames.has(wrapper.responseModelName)) {
|
|
140
|
+
lines.push(` WorkOS::${className(wrapper.responseModelName)}.new(response.body)`);
|
|
141
|
+
} else {
|
|
142
|
+
lines.push(' JSON.parse(response.body)');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
lines.push(' end');
|
|
146
|
+
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function interpolateRubyPath(path: string, pathParams: Operation['pathParams']): string {
|
|
151
|
+
if (!pathParams || pathParams.length === 0) return `'${path}'`;
|
|
152
|
+
let result = path;
|
|
153
|
+
for (const p of pathParams) {
|
|
154
|
+
result = result.split(`{${p.name}}`).join(`#{WorkOS::Util.encode_path(${safeParamName(p.name)})}`);
|
|
155
|
+
}
|
|
156
|
+
return `"${result}"`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function rubyStringLit(s: string): string {
|
|
160
|
+
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
161
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Model, Field, TypeRef, Enum } from '@workos/oagen';
|
|
2
|
+
import { toSnakeCase } from '@workos/oagen';
|
|
2
3
|
import { readFileSync, existsSync } from 'node:fs';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
5
|
// @ts-ignore -- js-yaml has no type declarations in this project
|
|
@@ -159,7 +160,7 @@ function rawSchemaToTypeRef(
|
|
|
159
160
|
if (schema.enum && collector && parentModelName && fName) {
|
|
160
161
|
// Generate a synthetic enum
|
|
161
162
|
const syntheticName = `${parentModelName}_${fName}`;
|
|
162
|
-
if (!collector.usedNames.has(syntheticName)) {
|
|
163
|
+
if (!collector.usedNames.has(syntheticName) && !collector.usedNames.has(toSnakeCase(syntheticName))) {
|
|
163
164
|
collector.usedNames.add(syntheticName);
|
|
164
165
|
collector.enums.push({
|
|
165
166
|
name: syntheticName,
|
|
@@ -197,7 +198,7 @@ function rawSchemaToTypeRef(
|
|
|
197
198
|
if (baseType === 'object' && schema.properties && collector && parentModelName && fName) {
|
|
198
199
|
// Inline object -- generate a synthetic model
|
|
199
200
|
const syntheticName = `${parentModelName}_${fName}`;
|
|
200
|
-
if (!collector.usedNames.has(syntheticName)) {
|
|
201
|
+
if (!collector.usedNames.has(syntheticName) && !collector.usedNames.has(toSnakeCase(syntheticName))) {
|
|
201
202
|
collector.usedNames.add(syntheticName);
|
|
202
203
|
const fields: Field[] = [];
|
|
203
204
|
const requiredSet = new Set<string>(schema.required ?? []);
|
|
@@ -388,6 +389,102 @@ export function getSyntheticEnums(): Enum[] {
|
|
|
388
389
|
return _lastSyntheticEnums;
|
|
389
390
|
}
|
|
390
391
|
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Implicit discriminator detection
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Find a property name that has a `const` value in ALL oneOf variants.
|
|
398
|
+
* Returns null if no shared const property is found.
|
|
399
|
+
*/
|
|
400
|
+
function findSharedConstProperty(oneOfSchemas: Record<string, any>[]): string | null {
|
|
401
|
+
if (oneOfSchemas.length === 0) return null;
|
|
402
|
+
|
|
403
|
+
const first = oneOfSchemas[0];
|
|
404
|
+
if (!first.properties) return null;
|
|
405
|
+
|
|
406
|
+
// Candidate properties from the first variant that have const values
|
|
407
|
+
const candidates = Object.keys(first.properties).filter((name) => first.properties[name].const !== undefined);
|
|
408
|
+
|
|
409
|
+
// Return the first candidate that has const values in ALL variants
|
|
410
|
+
for (const candidate of candidates) {
|
|
411
|
+
const allHaveConst = oneOfSchemas.every((variant) => variant.properties?.[candidate]?.const !== undefined);
|
|
412
|
+
if (allHaveConst) return candidate;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Build a discriminator mapping from const values to IR model names.
|
|
420
|
+
* For each oneOf variant's const value on `discProperty`, find the IR model
|
|
421
|
+
* whose field with the same name is a Literal type with that value.
|
|
422
|
+
*/
|
|
423
|
+
function buildDiscriminatorMapping(
|
|
424
|
+
discProperty: string,
|
|
425
|
+
oneOfSchemas: Record<string, any>[],
|
|
426
|
+
models: Model[],
|
|
427
|
+
parentModelName: string,
|
|
428
|
+
): Record<string, string> {
|
|
429
|
+
const mapping: Record<string, string> = {};
|
|
430
|
+
|
|
431
|
+
for (const variant of oneOfSchemas) {
|
|
432
|
+
const constValue = variant.properties?.[discProperty]?.const;
|
|
433
|
+
if (constValue === undefined) continue;
|
|
434
|
+
|
|
435
|
+
const variantModel = models.find(
|
|
436
|
+
(m) =>
|
|
437
|
+
m.name !== parentModelName &&
|
|
438
|
+
m.fields.some(
|
|
439
|
+
(f) => f.name === discProperty && f.type.kind === 'literal' && (f.type as any).value === constValue,
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
if (variantModel) {
|
|
443
|
+
mapping[String(constValue)] = variantModel.name;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return mapping;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Detect implicit discriminators on models without full oneOf flattening.
|
|
452
|
+
* Returns a new array with discriminator annotations; models without
|
|
453
|
+
* discriminators are returned as-is. Use this when you need discriminator
|
|
454
|
+
* info but don't want the side-effects of full enrichment (synthetic
|
|
455
|
+
* models/enums, field flattening).
|
|
456
|
+
*/
|
|
457
|
+
export function detectDiscriminators(models: Model[]): Model[] {
|
|
458
|
+
const spec = loadRawSpec();
|
|
459
|
+
if (!spec) return models;
|
|
460
|
+
|
|
461
|
+
let changed = false;
|
|
462
|
+
const result = models.map((model) => {
|
|
463
|
+
if ((model as any).discriminator) return model;
|
|
464
|
+
|
|
465
|
+
const rawSchema = lookupRawSchema(model.name);
|
|
466
|
+
if (!rawSchema) return model;
|
|
467
|
+
|
|
468
|
+
const oneOfContainer = rawSchema.allOf?.find((s: any) => s.oneOf);
|
|
469
|
+
if (!oneOfContainer?.oneOf || oneOfContainer.oneOf.length === 0) return model;
|
|
470
|
+
|
|
471
|
+
const discProperty = findSharedConstProperty(oneOfContainer.oneOf);
|
|
472
|
+
if (!discProperty) return model;
|
|
473
|
+
|
|
474
|
+
const mapping = buildDiscriminatorMapping(discProperty, oneOfContainer.oneOf, models, model.name);
|
|
475
|
+
if (Object.keys(mapping).length === 0) return model;
|
|
476
|
+
|
|
477
|
+
changed = true;
|
|
478
|
+
return {
|
|
479
|
+
...model,
|
|
480
|
+
fields: [],
|
|
481
|
+
discriminator: { property: discProperty, mapping },
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return changed ? result : models;
|
|
486
|
+
}
|
|
487
|
+
|
|
391
488
|
/**
|
|
392
489
|
* Enrich IR models by flattening oneOf/allOf+oneOf variant fields from the raw spec.
|
|
393
490
|
*
|
|
@@ -411,8 +508,13 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
|
411
508
|
}
|
|
412
509
|
|
|
413
510
|
const collector = createCollector();
|
|
414
|
-
// Avoid name collisions with existing models
|
|
415
|
-
|
|
511
|
+
// Avoid name collisions with existing models (check both PascalCase and
|
|
512
|
+
// snake_case to prevent synthetic models from shadowing existing ones when
|
|
513
|
+
// they share a file name, e.g. FooBar vs Foo_bar -> foo_bar).
|
|
514
|
+
for (const m of models) {
|
|
515
|
+
collector.usedNames.add(m.name);
|
|
516
|
+
collector.usedNames.add(toSnakeCase(m.name));
|
|
517
|
+
}
|
|
416
518
|
|
|
417
519
|
const enriched = models.map((model) => {
|
|
418
520
|
const rawSchema = lookupRawSchema(model.name);
|
|
@@ -421,13 +523,32 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
|
421
523
|
const hasOneOf = rawSchema.oneOf || rawSchema.allOf?.some((s: any) => s.oneOf);
|
|
422
524
|
if (!hasOneOf) return model;
|
|
423
525
|
|
|
424
|
-
// Skip schemas with discriminator -- those are intentional unions
|
|
526
|
+
// Skip schemas with explicit discriminator -- those are intentional unions
|
|
425
527
|
const hasDiscriminator =
|
|
426
528
|
rawSchema.discriminator ||
|
|
427
529
|
rawSchema.oneOf?.some((v: any) => v.discriminator) ||
|
|
428
530
|
rawSchema.allOf?.some((s: any) => s.discriminator || s.oneOf?.some((v: any) => v.discriminator));
|
|
429
531
|
if (hasDiscriminator) return model;
|
|
430
532
|
|
|
533
|
+
// Detect implicit discriminators: allOf+oneOf where all variants share a
|
|
534
|
+
// property with const values (e.g., EventSchema with event: const: "user.created").
|
|
535
|
+
// When found, attach a discriminator mapping and clear fields so the emitter
|
|
536
|
+
// generates a dispatcher class instead of a flat dataclass.
|
|
537
|
+
const oneOfContainer = rawSchema.allOf?.find((s: any) => s.oneOf);
|
|
538
|
+
if (oneOfContainer?.oneOf && oneOfContainer.oneOf.length > 0) {
|
|
539
|
+
const discProperty = findSharedConstProperty(oneOfContainer.oneOf);
|
|
540
|
+
if (discProperty) {
|
|
541
|
+
const mapping = buildDiscriminatorMapping(discProperty, oneOfContainer.oneOf, models, model.name);
|
|
542
|
+
if (Object.keys(mapping).length > 0) {
|
|
543
|
+
return {
|
|
544
|
+
...model,
|
|
545
|
+
fields: [],
|
|
546
|
+
discriminator: { property: discProperty, mapping },
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
431
552
|
// Collect all variant fields from the raw schema, generating synthetic
|
|
432
553
|
// models/enums for inline definitions along the way.
|
|
433
554
|
const variantFields = collectOneOfFields(rawSchema, model.name, collector);
|
|
@@ -467,6 +588,9 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
|
467
588
|
values: e.values.map((v) => ({ value: v.value, description: v.description })),
|
|
468
589
|
})) as Enum[];
|
|
469
590
|
|
|
470
|
-
// Append synthetic models
|
|
471
|
-
|
|
591
|
+
// Append synthetic models, skipping those whose snake_case name collides
|
|
592
|
+
// with an existing model (prevents broken TypeAlias self-imports).
|
|
593
|
+
const existingSnakeNames = new Set(enriched2.map((m) => toSnakeCase(m.name)));
|
|
594
|
+
const filteredSynthetic = collector.models.filter((m) => !existingSnakeNames.has(toSnakeCase(m.name)));
|
|
595
|
+
return [...enriched2, ...filteredSynthetic];
|
|
472
596
|
}
|
|
@@ -125,10 +125,46 @@ function startsWithVerb(desc: string): boolean {
|
|
|
125
125
|
return VERB_STARTERS.has(firstWord);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Words beginning with a vowel letter but a consonant /j/ or /w/ sound —
|
|
130
|
+
* take "a", not "an".
|
|
131
|
+
*/
|
|
132
|
+
const CONSONANT_SOUND_INITIAL_VOWEL = new Set([
|
|
133
|
+
'user',
|
|
134
|
+
'unit',
|
|
135
|
+
'unique',
|
|
136
|
+
'united',
|
|
137
|
+
'universal',
|
|
138
|
+
'university',
|
|
139
|
+
'european',
|
|
140
|
+
'one',
|
|
141
|
+
'once',
|
|
142
|
+
'useful',
|
|
143
|
+
'used',
|
|
144
|
+
'usable',
|
|
145
|
+
'ubiquitous',
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Words beginning with a consonant letter but a vowel sound (silent h) —
|
|
150
|
+
* take "an", not "a".
|
|
151
|
+
*/
|
|
152
|
+
const VOWEL_SOUND_INITIAL_CONSONANT = new Set(['hour', 'honest', 'honor', 'honorable', 'heir']);
|
|
153
|
+
|
|
128
154
|
/**
|
|
129
155
|
* Select the correct indefinite article ("a" or "an") for a word.
|
|
156
|
+
*
|
|
157
|
+
* Matches English phonetics, not orthography: "a user" (consonant /j/ sound
|
|
158
|
+
* despite leading 'u'), "an hour" (vowel sound despite leading 'h'). Falls
|
|
159
|
+
* back to a vowel-letter regex for words not in either exception set.
|
|
130
160
|
*/
|
|
131
161
|
export function articleFor(word: string): string {
|
|
162
|
+
const firstWord = word
|
|
163
|
+
.split(/\s+/)[0]
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.replace(/[^a-z]/g, '');
|
|
166
|
+
if (CONSONANT_SOUND_INITIAL_VOWEL.has(firstWord)) return 'a';
|
|
167
|
+
if (VOWEL_SOUND_INITIAL_CONSONANT.has(firstWord)) return 'an';
|
|
132
168
|
return /^[aeiou]/i.test(word) ? 'an' : 'a';
|
|
133
169
|
}
|
|
134
170
|
|
|
@@ -18,6 +18,17 @@ export interface NonSpecService {
|
|
|
18
18
|
* someone reading this file understands what the service does.
|
|
19
19
|
*/
|
|
20
20
|
description: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* When true, the generated Client struct includes a cached field for this
|
|
24
|
+
* service and a public accessor method — identical to spec-driven services.
|
|
25
|
+
* The hand-written file must export the service type (e.g. PasswordlessService)
|
|
26
|
+
* but should NOT define its own Client accessor (the generated code handles that).
|
|
27
|
+
*
|
|
28
|
+
* Defaults to false — most non-spec modules are standalone helpers, not
|
|
29
|
+
* Client-mounted services.
|
|
30
|
+
*/
|
|
31
|
+
hasClientAccessor?: boolean;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
/**
|
|
@@ -29,10 +40,12 @@ export const NON_SPEC_SERVICES: readonly NonSpecService[] = [
|
|
|
29
40
|
{
|
|
30
41
|
id: 'passwordless',
|
|
31
42
|
description: 'Passwordless (magic-link) session endpoints, not yet in the OpenAPI spec.',
|
|
43
|
+
hasClientAccessor: true,
|
|
32
44
|
},
|
|
33
45
|
{
|
|
34
46
|
id: 'vault',
|
|
35
47
|
description: 'Vault KV storage, key operations, and client-side AES-GCM encrypt/decrypt.',
|
|
48
|
+
hasClientAccessor: true,
|
|
36
49
|
},
|
|
37
50
|
{
|
|
38
51
|
id: 'webhook_verification',
|
|
@@ -1,11 +1,42 @@
|
|
|
1
|
-
import type { Operation, EmitterContext, Service, ResolvedOperation } from '@workos/oagen';
|
|
1
|
+
import type { Operation, EmitterContext, Service, ResolvedOperation, Model, TypeRef } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Fail fast when two distinct paths in the same mount resolve to the same SDK
|
|
6
|
+
* method name. Emitters can sometimes paper over this with per-language
|
|
7
|
+
* deduplication, but manifests and cross-language parity become inconsistent.
|
|
8
|
+
*/
|
|
9
|
+
export function assertUniqueResolvedMethods(ctx: EmitterContext): void {
|
|
10
|
+
const seen = new Map<string, { path: string; httpMethod: string }>();
|
|
11
|
+
|
|
12
|
+
for (const resolved of ctx.resolvedOperations ?? []) {
|
|
13
|
+
const key = `${resolved.mountOn}.${resolved.methodName}`;
|
|
14
|
+
const current = {
|
|
15
|
+
path: resolved.operation.path,
|
|
16
|
+
httpMethod: resolved.operation.httpMethod.toUpperCase(),
|
|
17
|
+
};
|
|
18
|
+
const existing = seen.get(key);
|
|
19
|
+
|
|
20
|
+
if (existing && existing.path !== current.path) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Resolved operation name collision for ${key}: ` +
|
|
23
|
+
`${existing.httpMethod} ${existing.path} conflicts with ${current.httpMethod} ${current.path}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!existing) {
|
|
28
|
+
seen.set(key, current);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
4
33
|
/**
|
|
5
34
|
* Build a lookup map from "METHOD /path" to ResolvedOperation.
|
|
6
35
|
* Used by emitters to find the resolved method name for any IR operation.
|
|
7
36
|
*/
|
|
8
37
|
export function buildResolvedLookup(ctx: EmitterContext): Map<string, ResolvedOperation> {
|
|
38
|
+
assertUniqueResolvedMethods(ctx);
|
|
39
|
+
|
|
9
40
|
const map = new Map<string, ResolvedOperation>();
|
|
10
41
|
for (const r of ctx.resolvedOperations ?? []) {
|
|
11
42
|
const key = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}`;
|
|
@@ -107,3 +138,46 @@ export function buildHiddenParams(resolvedOp?: ResolvedOperation): Set<string> {
|
|
|
107
138
|
export function hasHiddenParams(resolvedOp?: ResolvedOperation): boolean {
|
|
108
139
|
return Object.keys(getOpDefaults(resolvedOp)).length > 0 || getOpInferFromClient(resolvedOp).length > 0;
|
|
109
140
|
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Parameter group helpers
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Collect all parameter names that belong to any mutually-exclusive parameter group.
|
|
148
|
+
* These params are serialized via group-level dispatch (e.g. applyToQuery, isinstance,
|
|
149
|
+
* sealed-class when, etc.) instead of individual struct/class fields.
|
|
150
|
+
*/
|
|
151
|
+
export function collectGroupedParamNames(op: Operation): Set<string> {
|
|
152
|
+
const names = new Set<string>();
|
|
153
|
+
for (const group of op.parameterGroups ?? []) {
|
|
154
|
+
for (const variant of group.variants) {
|
|
155
|
+
for (const param of variant.parameters) {
|
|
156
|
+
names.add(param.name);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return names;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build a fallback map from request-body wire field name to TypeRef.
|
|
165
|
+
*
|
|
166
|
+
* Some parameter-group variants lose array/object fidelity in the IR and fall
|
|
167
|
+
* back to primitive strings. Cross-referencing the request body model restores
|
|
168
|
+
* the actual field type when the grouped params belong to the body.
|
|
169
|
+
*/
|
|
170
|
+
export function collectBodyFieldTypes(op: Operation, models: Model[]): Map<string, TypeRef> {
|
|
171
|
+
const fieldTypes = new Map<string, TypeRef>();
|
|
172
|
+
const reqBody = op.requestBody;
|
|
173
|
+
if (reqBody?.kind !== 'model') return fieldTypes;
|
|
174
|
+
|
|
175
|
+
const bodyModel = models.find((model) => model.name === reqBody.name);
|
|
176
|
+
if (!bodyModel) return fieldTypes;
|
|
177
|
+
|
|
178
|
+
for (const field of bodyModel.fields) {
|
|
179
|
+
fieldTypes.set(field.name, field.type);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return fieldTypes;
|
|
183
|
+
}
|
|
@@ -76,8 +76,8 @@ describe('dotnet/client', () => {
|
|
|
76
76
|
expect(content).not.toContain('SendAsync');
|
|
77
77
|
expect(content).not.toContain('RequestAsync');
|
|
78
78
|
expect(content).not.toContain('ApiBaseURL');
|
|
79
|
-
expect(content).not.toContain('
|
|
80
|
-
expect(content).not.toContain('
|
|
79
|
+
expect(content).not.toContain('AuthenticationException');
|
|
80
|
+
expect(content).not.toContain('RateLimitExceededException');
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
it('deduplicates services by mount target', () => {
|
|
@@ -80,18 +80,17 @@ describe('dotnet/models', () => {
|
|
|
80
80
|
// Class definition
|
|
81
81
|
expect(content).toContain('public class Organization');
|
|
82
82
|
|
|
83
|
-
// Required fields
|
|
84
|
-
expect(content).toContain('[JsonProperty("id")]');
|
|
83
|
+
// Required fields — convention-based naming (no per-property JSON attributes)
|
|
85
84
|
expect(content).toContain('public string Id');
|
|
86
|
-
expect(content).toContain('[JsonProperty("name")]');
|
|
87
85
|
expect(content).toContain('public string Name');
|
|
86
|
+
expect(content).not.toContain('[JsonProperty("id")]');
|
|
87
|
+
expect(content).not.toContain('[STJS.JsonPropertyName(');
|
|
88
88
|
|
|
89
89
|
// DateTime field
|
|
90
90
|
expect(content).toContain('DateTimeOffset');
|
|
91
|
-
expect(content).toContain('[JsonProperty("created_at")]');
|
|
92
91
|
|
|
93
92
|
// Optional/nullable field
|
|
94
|
-
expect(content).toContain('
|
|
93
|
+
expect(content).toContain('public string? ExternalId');
|
|
95
94
|
});
|
|
96
95
|
|
|
97
96
|
it('skips list wrapper and list metadata models', () => {
|
|
@@ -168,10 +167,9 @@ describe('dotnet/models', () => {
|
|
|
168
167
|
expect(canonicalFile).toBeDefined();
|
|
169
168
|
expect(canonicalFile.content).toContain('public class OrganizationDomain');
|
|
170
169
|
|
|
171
|
-
// Alias model should be
|
|
172
|
-
const aliasFile = files.find((f) => f.path.includes('OrganizationDomainStandAlone.cs'))
|
|
173
|
-
expect(aliasFile).
|
|
174
|
-
expect(aliasFile.content).toContain('OrganizationDomain');
|
|
170
|
+
// Alias model should NOT be emitted — references are rewritten to the canonical
|
|
171
|
+
const aliasFile = files.find((f) => f.path.includes('OrganizationDomainStandAlone.cs'));
|
|
172
|
+
expect(aliasFile).toBeUndefined();
|
|
175
173
|
});
|
|
176
174
|
|
|
177
175
|
it('emits [System.Obsolete] for deprecated fields', () => {
|