@workos/oagen-emitters 0.3.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 +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- 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 +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- 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 +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- 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/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- 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 +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- 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 +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- 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,228 @@
|
|
|
1
|
+
import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
|
|
2
|
+
import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
3
|
+
import { className, modelClassName } from './naming.js';
|
|
4
|
+
|
|
5
|
+
/** Known C# value types that need `?` for nullable. */
|
|
6
|
+
const VALUE_TYPES = new Set(['int', 'long', 'double', 'bool', 'float', 'decimal', 'byte', 'short', 'DateTimeOffset']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Module-level alias map for structurally-identical enums. Populated by
|
|
10
|
+
* `setEnumAliases` from the current spec's enum list; consulted by
|
|
11
|
+
* `mapTypeRef` so that every reference to a duplicate enum resolves to the
|
|
12
|
+
* canonical name. C# has no first-class type alias for enums, so dedup must
|
|
13
|
+
* happen at reference-rewrite time rather than via a runtime alias.
|
|
14
|
+
*/
|
|
15
|
+
const enumAliases = new Map<string, string>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Names of enums that resolve to a single wire value and should therefore be
|
|
19
|
+
* mapped to C# `string` at reference sites (the owning property emits a
|
|
20
|
+
* const initializer). Populated by `setSingleValueEnumNames`.
|
|
21
|
+
*/
|
|
22
|
+
const singleValueEnumNames = new Set<string>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Module-level alias map for structurally-identical models. Populated by
|
|
26
|
+
* `setModelAliases` from model deduplication; consulted by `mapTypeRef` so
|
|
27
|
+
* that every reference to a duplicate model resolves to the canonical name.
|
|
28
|
+
*/
|
|
29
|
+
const modelAliases = new Map<string, string>();
|
|
30
|
+
|
|
31
|
+
/** Replace the current enum-alias map. Safe to call more than once. */
|
|
32
|
+
export function setEnumAliases(aliases: Map<string, string>): void {
|
|
33
|
+
enumAliases.clear();
|
|
34
|
+
for (const [k, v] of aliases) enumAliases.set(k, v);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Replace the current model-alias map. Safe to call more than once. */
|
|
38
|
+
export function setModelAliases(aliases: Map<string, string>): void {
|
|
39
|
+
modelAliases.clear();
|
|
40
|
+
for (const [k, v] of aliases) modelAliases.set(k, v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check if a model name is an alias (i.e., structurally identical to another model). */
|
|
44
|
+
export function isModelAlias(name: string): boolean {
|
|
45
|
+
return modelAliases.has(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve a model name to its canonical form (identity if not an alias). */
|
|
49
|
+
export function resolveModelName(name: string): string {
|
|
50
|
+
return modelAliases.get(name) ?? name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Replace the set of enum names that are single-value discriminator stand-ins. */
|
|
54
|
+
export function setSingleValueEnumNames(names: Iterable<string>): void {
|
|
55
|
+
singleValueEnumNames.clear();
|
|
56
|
+
for (const n of names) singleValueEnumNames.add(n);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve an enum reference name to its canonical form. */
|
|
60
|
+
export function resolveEnumTypeName(name: string): string {
|
|
61
|
+
return enumAliases.get(name) ?? name;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map an IR TypeRef to a C# type string.
|
|
66
|
+
*/
|
|
67
|
+
export function mapTypeRef(ref: TypeRef): string {
|
|
68
|
+
return irMapTypeRef<string>(ref, {
|
|
69
|
+
primitive: mapPrimitive,
|
|
70
|
+
array: (_ref, items) => `List<${items}>`,
|
|
71
|
+
model: (r) => modelClassName(modelAliases.get(r.name) ?? r.name),
|
|
72
|
+
enum: (r) => {
|
|
73
|
+
// Single-value enums (discriminator consts in disguise) map to `string`
|
|
74
|
+
// so the caller can't misuse a public one-member enum type. The
|
|
75
|
+
// owning property emits a const initializer separately.
|
|
76
|
+
if ((r.values?.length ?? 0) === 1) return 'string';
|
|
77
|
+
if (singleValueEnumNames.has(r.name)) return 'string';
|
|
78
|
+
return className(resolveEnumTypeName(r.name));
|
|
79
|
+
},
|
|
80
|
+
union: (_r, variants) => joinUnionVariants(_r, variants),
|
|
81
|
+
nullable: (_ref, inner) => {
|
|
82
|
+
// With <Nullable>enable</Nullable>, all nullable types need `?`
|
|
83
|
+
if (inner.endsWith('?')) return inner; // already nullable (e.g., nested nullable)
|
|
84
|
+
return `${inner}?`;
|
|
85
|
+
},
|
|
86
|
+
literal: (r) => {
|
|
87
|
+
if (r.value === null) return 'object';
|
|
88
|
+
if (typeof r.value === 'string') return 'string';
|
|
89
|
+
if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'int' : 'double';
|
|
90
|
+
if (typeof r.value === 'boolean') return 'bool';
|
|
91
|
+
return 'object';
|
|
92
|
+
},
|
|
93
|
+
map: (_ref, value) => `Dictionary<string, ${value}>`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Map an IR TypeRef to a C# type string, making optional fields nullable.
|
|
99
|
+
* For value types, appends `?`. For reference types, returns as-is.
|
|
100
|
+
*/
|
|
101
|
+
export function mapTypeRefOptional(ref: TypeRef): string {
|
|
102
|
+
const baseType = mapTypeRef(ref);
|
|
103
|
+
if (isValueType(baseType)) return `${baseType}?`;
|
|
104
|
+
return baseType;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if a C# type is a value type (needs ? for nullable).
|
|
109
|
+
*/
|
|
110
|
+
export function isValueType(csType: string): boolean {
|
|
111
|
+
// Strip trailing ? if present
|
|
112
|
+
const bare = csType.endsWith('?') ? csType.slice(0, -1) : csType;
|
|
113
|
+
if (VALUE_TYPES.has(bare)) return true;
|
|
114
|
+
// Enums are value types, but we can't detect them purely from the type string.
|
|
115
|
+
// The caller should handle enum nullability explicitly when needed.
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if an IR TypeRef maps to a C# value type.
|
|
121
|
+
*/
|
|
122
|
+
export function isValueTypeRef(ref: TypeRef): boolean {
|
|
123
|
+
if (ref.kind === 'enum') return true;
|
|
124
|
+
if (ref.kind === 'primitive') {
|
|
125
|
+
// DateTimeOffset is a value type (struct)
|
|
126
|
+
if (ref.format === 'date-time') return true;
|
|
127
|
+
switch (ref.type) {
|
|
128
|
+
case 'integer':
|
|
129
|
+
case 'number':
|
|
130
|
+
case 'boolean':
|
|
131
|
+
return true;
|
|
132
|
+
default:
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a TypeRef directly names an enum (no nullable wrapper).
|
|
141
|
+
* Used to detect required enum request fields that must not silently serialize
|
|
142
|
+
* their default Unknown sentinel.
|
|
143
|
+
*/
|
|
144
|
+
export function isEnumRef(ref: TypeRef): boolean {
|
|
145
|
+
return ref.kind === 'enum';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Emit JSON attributes for a request-side property. Property name mapping is
|
|
150
|
+
* handled by a global SnakeCaseLower / SnakeCaseNamingStrategy configuration
|
|
151
|
+
* on both serializers, so per-property name attributes are not emitted.
|
|
152
|
+
*
|
|
153
|
+
* When `isRequiredEnum` is true, configure both serializers to skip the field
|
|
154
|
+
* when its value equals the enum default (0 = Unknown sentinel). This converts
|
|
155
|
+
* "unset required enum" from a silent `"unknown"` wire value into a clean
|
|
156
|
+
* omission so the API returns a clear `missing required field` error instead
|
|
157
|
+
* of a confusing 422.
|
|
158
|
+
*/
|
|
159
|
+
export function emitJsonPropertyAttributes(_wireName: string, options: { isRequiredEnum?: boolean } = {}): string[] {
|
|
160
|
+
if (options.isRequiredEnum) {
|
|
161
|
+
return [
|
|
162
|
+
` [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]`,
|
|
163
|
+
` [STJS.JsonIgnore(Condition = STJS.JsonIgnoreCondition.WhenWritingDefault)]`,
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
// Convention-based: SnakeCaseLower naming policy handles the name mapping.
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function mapPrimitive(ref: PrimitiveType): string {
|
|
171
|
+
if (ref.format === 'binary') return 'byte[]';
|
|
172
|
+
if (ref.format === 'int32') return 'int';
|
|
173
|
+
if (ref.format === 'int64') return 'long';
|
|
174
|
+
if (ref.format === 'date-time') return 'DateTimeOffset';
|
|
175
|
+
switch (ref.type) {
|
|
176
|
+
case 'string':
|
|
177
|
+
return 'string';
|
|
178
|
+
case 'integer':
|
|
179
|
+
return 'long';
|
|
180
|
+
case 'number':
|
|
181
|
+
return 'double';
|
|
182
|
+
case 'boolean':
|
|
183
|
+
return 'bool';
|
|
184
|
+
case 'unknown':
|
|
185
|
+
return 'object';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Track discriminated unions for downstream model generation.
|
|
191
|
+
* Key = generated base type name, Value = discriminator info.
|
|
192
|
+
*/
|
|
193
|
+
export const discriminatedUnions = new Map<
|
|
194
|
+
string,
|
|
195
|
+
{ property: string; mapping: Record<string, string>; variantTypes: string[] }
|
|
196
|
+
>();
|
|
197
|
+
|
|
198
|
+
function joinUnionVariants(_ref: UnionType, variants: string[]): string {
|
|
199
|
+
if (_ref.compositionKind === 'allOf') {
|
|
200
|
+
return variants[0] ?? 'object';
|
|
201
|
+
}
|
|
202
|
+
const unique = [...new Set(variants)];
|
|
203
|
+
if (unique.length === 1) return unique[0];
|
|
204
|
+
|
|
205
|
+
// Discriminated union: register for converter generation and return first variant as base
|
|
206
|
+
if (_ref.discriminator && _ref.discriminator.mapping) {
|
|
207
|
+
const baseName = unique[0];
|
|
208
|
+
discriminatedUnions.set(baseName, {
|
|
209
|
+
property: _ref.discriminator.property,
|
|
210
|
+
mapping: _ref.discriminator.mapping,
|
|
211
|
+
variantTypes: unique,
|
|
212
|
+
});
|
|
213
|
+
// Use object with JsonConverter for discriminated unions since
|
|
214
|
+
// AnyOf<> doesn't support discriminator-based deserialization
|
|
215
|
+
return 'object';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (unique.length >= 2 && unique.length <= 9) return `OneOf.OneOf<${unique.join(', ')}>`;
|
|
219
|
+
// OneOf supports arity 2-9. Higher-arity unions collapse to `object`,
|
|
220
|
+
// losing type information. Warn so the author knows the spec outgrew the
|
|
221
|
+
// runtime support instead of silently degrading.
|
|
222
|
+
if (unique.length >= 10) {
|
|
223
|
+
console.warn(
|
|
224
|
+
`[oagen:dotnet] Union with ${unique.length} variants exceeds OneOf<T0..T8> arity; falling back to object. Variants: ${unique.join(', ')}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return 'object';
|
|
228
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
|
|
2
|
+
import {
|
|
3
|
+
fieldName as csFieldName,
|
|
4
|
+
methodName as csMethodName,
|
|
5
|
+
localName,
|
|
6
|
+
csLiteral,
|
|
7
|
+
clientFieldExpression,
|
|
8
|
+
httpMethodHelperName,
|
|
9
|
+
escapeXml,
|
|
10
|
+
emitXmlDoc,
|
|
11
|
+
humanize,
|
|
12
|
+
appendAsyncSuffix,
|
|
13
|
+
modelClassName,
|
|
14
|
+
} from './naming.js';
|
|
15
|
+
import { sortPathParamsByTemplateOrder } from './resources.js';
|
|
16
|
+
import { resolveWrapperParams, formatWrapperDescription, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
|
|
17
|
+
import { mapTypeRef, isValueTypeRef, isEnumRef, emitJsonPropertyAttributes } from './type-map.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate C# wrapper method lines for union split operations.
|
|
21
|
+
*/
|
|
22
|
+
export function generateWrapperMethods(
|
|
23
|
+
_serviceType: string,
|
|
24
|
+
resolvedOp: ResolvedOperation,
|
|
25
|
+
ctx: EmitterContext,
|
|
26
|
+
): string[] {
|
|
27
|
+
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
28
|
+
|
|
29
|
+
const lines: string[] = [];
|
|
30
|
+
|
|
31
|
+
for (const wrapper of resolvedOp.wrappers) {
|
|
32
|
+
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
33
|
+
lines.push('');
|
|
34
|
+
emitWrapperMethod(lines, resolvedOp, wrapper, wrapperParams, ctx);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function emitWrapperMethod(
|
|
41
|
+
lines: string[],
|
|
42
|
+
resolvedOp: ResolvedOperation,
|
|
43
|
+
wrapper: ResolvedWrapper,
|
|
44
|
+
_wrapperParams: ResolvedWrapperParam[],
|
|
45
|
+
_ctx: EmitterContext,
|
|
46
|
+
): void {
|
|
47
|
+
const op = resolvedOp.operation;
|
|
48
|
+
const methodStem = csMethodName(wrapper.name);
|
|
49
|
+
const method = appendAsyncSuffix(methodStem);
|
|
50
|
+
const optionsClass = `${methodStem}Options`;
|
|
51
|
+
const responseType = wrapper.responseModelName ? modelClassName(wrapper.responseModelName) : null;
|
|
52
|
+
|
|
53
|
+
// XML doc
|
|
54
|
+
lines.push(` /// <summary>${formatWrapperDescription(wrapper.name)}.</summary>`);
|
|
55
|
+
for (const p of sortPathParamsByTemplateOrder(op)) {
|
|
56
|
+
const paramDesc = p.description ? escapeXml(p.description) : `The ${humanize(p.name)}.`;
|
|
57
|
+
lines.push(` /// <param name="${localName(p.name)}">${paramDesc}</param>`);
|
|
58
|
+
}
|
|
59
|
+
lines.push(` /// <param name="options">Request options.</param>`);
|
|
60
|
+
lines.push(` /// <param name="requestOptions">Per-request configuration overrides.</param>`);
|
|
61
|
+
lines.push(` /// <param name="cancellationToken">Cancellation token.</param>`);
|
|
62
|
+
if (responseType) {
|
|
63
|
+
lines.push(` /// <returns>The <see cref="${responseType}"/> result.</returns>`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Signature
|
|
67
|
+
const sigParams: string[] = [];
|
|
68
|
+
const argNames: string[] = [];
|
|
69
|
+
for (const p of sortPathParamsByTemplateOrder(op)) {
|
|
70
|
+
const name = localName(p.name);
|
|
71
|
+
sigParams.push(`string ${name}`);
|
|
72
|
+
argNames.push(name);
|
|
73
|
+
}
|
|
74
|
+
sigParams.push(`${optionsClass} options`);
|
|
75
|
+
argNames.push('options');
|
|
76
|
+
sigParams.push('RequestOptions? requestOptions = null');
|
|
77
|
+
argNames.push('requestOptions');
|
|
78
|
+
sigParams.push('CancellationToken cancellationToken = default');
|
|
79
|
+
argNames.push('cancellationToken');
|
|
80
|
+
|
|
81
|
+
const returnType = responseType ? `Task<${responseType}>` : 'Task';
|
|
82
|
+
lines.push(` public async ${returnType} ${method}(${sigParams.join(', ')})`);
|
|
83
|
+
lines.push(' {');
|
|
84
|
+
|
|
85
|
+
// Set defaults on options
|
|
86
|
+
for (const [key, value] of Object.entries(wrapper.defaults)) {
|
|
87
|
+
lines.push(` options.${csFieldName(key)} = ${csLiteral(value)};`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Set inferred fields from client. ClientId is required: fail loudly via RequireClientId()
|
|
91
|
+
// so that callers who forgot to configure it get a clear error instead of a 422 from the API.
|
|
92
|
+
for (const field of wrapper.inferFromClient) {
|
|
93
|
+
if (field === 'client_id') {
|
|
94
|
+
lines.push(` options.${csFieldName(field)} = this.Client.RequireClientId();`);
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(
|
|
97
|
+
` options.${csFieldName(field)} = this.Client.${clientFieldExpression(field)} ?? string.Empty;`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build path
|
|
103
|
+
let pathExpr: string;
|
|
104
|
+
if (op.pathParams.length > 0) {
|
|
105
|
+
let interpolated = op.path;
|
|
106
|
+
for (const p of sortPathParamsByTemplateOrder(op)) {
|
|
107
|
+
interpolated = interpolated.replace(`{${p.name}}`, `{Uri.EscapeDataString(${localName(p.name)})}`);
|
|
108
|
+
}
|
|
109
|
+
pathExpr = `$"${interpolated}"`;
|
|
110
|
+
} else {
|
|
111
|
+
pathExpr = `"${op.path}"`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Use the Service base-class helper so wrappers read as one-liners.
|
|
115
|
+
const helper = httpMethodHelperName(op.httpMethod);
|
|
116
|
+
if (responseType) {
|
|
117
|
+
lines.push(
|
|
118
|
+
` return await this.${helper}<${responseType}>(${pathExpr}, options, requestOptions, cancellationToken);`,
|
|
119
|
+
);
|
|
120
|
+
} else if (helper === 'DeleteAsync') {
|
|
121
|
+
lines.push(` await this.${helper}(${pathExpr}, options, requestOptions, cancellationToken);`);
|
|
122
|
+
} else {
|
|
123
|
+
lines.push(` await this.${helper}<object>(${pathExpr}, options, requestOptions, cancellationToken);`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
lines.push(' }');
|
|
127
|
+
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push(` /// <summary>Compatibility wrapper for <see cref="${method}"/>.</summary>`);
|
|
130
|
+
lines.push(` public Task${responseType ? `<${responseType}>` : ''} ${methodStem}(${sigParams.join(', ')})`);
|
|
131
|
+
lines.push(' {');
|
|
132
|
+
lines.push(` return this.${method}(${argNames.join(', ')});`);
|
|
133
|
+
lines.push(' }');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// NOTE: T26 (wrapper DRY) — the AuthenticateWith* wrappers share a small
|
|
137
|
+
// SendAuthenticateAsync helper at runtime to avoid 8x copies of the same
|
|
138
|
+
// MakeAPIRequest call. The helper itself lives in UserManagementService.cs as
|
|
139
|
+
// a hand-maintained method (it can't easily be expressed as a generic because
|
|
140
|
+
// the eight options classes don't share an interface). Keeping each generated
|
|
141
|
+
// wrapper's body short is the practical part of the DRY win.
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Generate wrapper options classes. Called from resources.ts options generation.
|
|
145
|
+
*/
|
|
146
|
+
export function generateWrapperOptionsClasses(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
|
|
147
|
+
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
148
|
+
|
|
149
|
+
const lines: string[] = [];
|
|
150
|
+
|
|
151
|
+
for (const wrapper of resolvedOp.wrappers) {
|
|
152
|
+
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
153
|
+
const optionsClass = `${csMethodName(wrapper.name)}Options`;
|
|
154
|
+
|
|
155
|
+
lines.push('');
|
|
156
|
+
lines.push(` public class ${optionsClass} : BaseOptions`);
|
|
157
|
+
lines.push(' {');
|
|
158
|
+
|
|
159
|
+
// Exposed params
|
|
160
|
+
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
161
|
+
const csField = csFieldName(paramName);
|
|
162
|
+
const csType = field ? resolveSimpleCsType(field.type, isOptional) : isOptional ? 'string?' : 'string';
|
|
163
|
+
const needsDefault = !isOptional && !csType.endsWith('?') && !(field && isValueTypeRef(field.type));
|
|
164
|
+
const initializer = needsDefault ? ' = default!;' : '';
|
|
165
|
+
|
|
166
|
+
const isRequiredEnum = !isOptional && !!field && isEnumRef(field.type);
|
|
167
|
+
lines.push(...emitXmlDoc(field?.description, ' '));
|
|
168
|
+
lines.push(...emitJsonPropertyAttributes(paramName, { isRequiredEnum }));
|
|
169
|
+
lines.push(` public ${csType} ${csField} { get; set; }${initializer}`);
|
|
170
|
+
lines.push('');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Hidden fields (defaults + inferred)
|
|
174
|
+
for (const key of Object.keys(wrapper.defaults)) {
|
|
175
|
+
const csField = csFieldName(key);
|
|
176
|
+
lines.push(` internal string ${csField} { get; set; } = default!;`);
|
|
177
|
+
lines.push('');
|
|
178
|
+
}
|
|
179
|
+
for (const key of wrapper.inferFromClient) {
|
|
180
|
+
const csField = csFieldName(key);
|
|
181
|
+
// Skip if already added as a default
|
|
182
|
+
if (Object.keys(wrapper.defaults).includes(key)) continue;
|
|
183
|
+
lines.push(` internal string ${csField} { get; set; } = default!;`);
|
|
184
|
+
lines.push('');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
lines.push(' }');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveSimpleCsType(ref: any, isOptional: boolean): string {
|
|
194
|
+
const base = mapTypeRef(ref);
|
|
195
|
+
if (isOptional && !base.endsWith('?')) return `${base}?`;
|
|
196
|
+
return base;
|
|
197
|
+
}
|
package/src/go/client.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oa
|
|
|
2
2
|
import { toPascalCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
// naming utilities used indirectly via resolveResourceClassName
|
|
4
4
|
import { resolveResourceClassName } from './resources.js';
|
|
5
|
-
import { unexportedName } from './naming.js';
|
|
5
|
+
import { className, unexportedName } from './naming.js';
|
|
6
6
|
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
7
|
+
import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Generate the Go client file with service accessors.
|
|
@@ -15,6 +16,16 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
|
|
|
15
16
|
return [generateWorkOSFile(spec, ctx)];
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Non-spec services marked with `hasClientAccessor: true` (passwordless, vault)
|
|
21
|
+
* are included in the generated Client struct, constructor, and accessor methods
|
|
22
|
+
* — identical to spec-driven services. Their service type (e.g. PasswordlessService)
|
|
23
|
+
* is defined in a hand-written @oagen-ignore-file, but the Client wiring is generated.
|
|
24
|
+
*
|
|
25
|
+
* Other non-spec modules (webhook_verification, actions, etc.) remain fully
|
|
26
|
+
* self-contained in their @oagen-ignore-file files.
|
|
27
|
+
*/
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* Deduplicate services by mount target.
|
|
20
31
|
*/
|
|
@@ -72,6 +83,8 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
|
72
83
|
lines.push('\tbaseURL string');
|
|
73
84
|
lines.push('\thttpClient *http.Client');
|
|
74
85
|
lines.push('\tmaxRetries int');
|
|
86
|
+
lines.push('\tlogger Logger');
|
|
87
|
+
lines.push('\tappInfo appInfo');
|
|
75
88
|
lines.push('');
|
|
76
89
|
// Service fields
|
|
77
90
|
for (const service of topLevel) {
|
|
@@ -80,6 +93,11 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
|
80
93
|
const serviceTypeName = serviceType(resolvedName);
|
|
81
94
|
lines.push(`\t${fieldNameStr} *${serviceTypeName}`);
|
|
82
95
|
}
|
|
96
|
+
// Non-spec service fields (hand-written types, generated wiring)
|
|
97
|
+
for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
|
|
98
|
+
const name = className(toPascalCase(ns.id));
|
|
99
|
+
lines.push(`\t${unexportedName(name)} *${serviceType(name)}`);
|
|
100
|
+
}
|
|
83
101
|
lines.push('}');
|
|
84
102
|
lines.push('');
|
|
85
103
|
|
|
@@ -102,6 +120,11 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
|
102
120
|
const serviceTypeName = serviceType(resolvedName);
|
|
103
121
|
lines.push(`\tc.${fieldNameStr} = &${serviceTypeName}{client: c}`);
|
|
104
122
|
}
|
|
123
|
+
// Initialize non-spec services
|
|
124
|
+
for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
|
|
125
|
+
const name = className(toPascalCase(ns.id));
|
|
126
|
+
lines.push(`\tc.${unexportedName(name)} = &${serviceType(name)}{client: c}`);
|
|
127
|
+
}
|
|
105
128
|
lines.push('\treturn c');
|
|
106
129
|
lines.push('}');
|
|
107
130
|
lines.push('');
|
|
@@ -118,7 +141,16 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
|
118
141
|
lines.push('}');
|
|
119
142
|
lines.push('');
|
|
120
143
|
}
|
|
121
|
-
|
|
144
|
+
// Non-spec service accessor methods
|
|
145
|
+
for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
|
|
146
|
+
const name = className(toPascalCase(ns.id));
|
|
147
|
+
const typeName = serviceType(name);
|
|
148
|
+
lines.push(`// ${name} returns the ${name} service.`);
|
|
149
|
+
lines.push(`func (c *Client) ${name}() *${typeName} {`);
|
|
150
|
+
lines.push(`\treturn c.${unexportedName(name)}`);
|
|
151
|
+
lines.push('}');
|
|
152
|
+
lines.push('');
|
|
153
|
+
}
|
|
122
154
|
return {
|
|
123
155
|
path: `${ctx.namespace}.go`,
|
|
124
156
|
content: lines.join('\n'),
|
|
@@ -137,5 +169,5 @@ function singularizePascal(name: string): string {
|
|
|
137
169
|
}
|
|
138
170
|
|
|
139
171
|
function serviceType(name: string): string {
|
|
140
|
-
return `${
|
|
172
|
+
return `${className(singularizePascal(name))}Service`;
|
|
141
173
|
}
|
package/src/go/enums.ts
CHANGED
|
@@ -29,6 +29,10 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
29
29
|
if (canonicalName) {
|
|
30
30
|
const aliasType = className(enumDef.name);
|
|
31
31
|
const canonicalType = className(canonicalName);
|
|
32
|
+
// Skip when different IR names map to the same Go type (e.g. synthetic
|
|
33
|
+
// enums from enrichModelsFromSpec whose underscore names collapse to the
|
|
34
|
+
// same PascalCase as the original enum).
|
|
35
|
+
if (aliasType === canonicalType) continue;
|
|
32
36
|
lines.push(`// ${aliasType} is an alias for ${canonicalType}.`);
|
|
33
37
|
lines.push(`type ${aliasType} = ${canonicalType}`);
|
|
34
38
|
lines.push('');
|
package/src/go/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
} from '@workos/oagen';
|
|
11
11
|
|
|
12
12
|
import { generateModels } from './models.js';
|
|
13
|
-
import { enrichModelsFromSpec } from '../shared/model-utils.js';
|
|
13
|
+
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
14
14
|
import { generateEnums } from './enums.js';
|
|
15
15
|
import { generateResources } from './resources.js';
|
|
16
16
|
import { generateClient } from './client.js';
|
|
@@ -37,7 +37,10 @@ export const goEmitter: Emitter = {
|
|
|
37
37
|
},
|
|
38
38
|
|
|
39
39
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
40
|
-
|
|
40
|
+
// Merge synthetic enums produced during model enrichment (inline oneOf
|
|
41
|
+
// definitions) so they get proper type definitions in enums.go.
|
|
42
|
+
const syntheticEnums = getSyntheticEnums();
|
|
43
|
+
return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
|
|
41
44
|
},
|
|
42
45
|
|
|
43
46
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -69,12 +72,17 @@ export const goEmitter: Emitter = {
|
|
|
69
72
|
return '// Code generated by oagen. DO NOT EDIT.';
|
|
70
73
|
},
|
|
71
74
|
|
|
72
|
-
formatCommand(
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
+
formatCommand(_targetDir: string): FormatCommand | null {
|
|
76
|
+
// oagen appends all generated file paths (including .json fixtures) to the
|
|
77
|
+
// format command. gofmt errors on non-.go files, so filter them out.
|
|
78
|
+
// Same pattern as the Python emitter's ruff wrapper.
|
|
75
79
|
return {
|
|
76
|
-
cmd: '
|
|
77
|
-
args: [
|
|
80
|
+
cmd: 'bash',
|
|
81
|
+
args: [
|
|
82
|
+
'-c',
|
|
83
|
+
'GO_FILES=$(printf "%s\\n" "$@" | grep "\\.go$"); [ -n "$GO_FILES" ] && echo "$GO_FILES" | xargs gofmt -w',
|
|
84
|
+
'--',
|
|
85
|
+
],
|
|
78
86
|
batchSize: 999999,
|
|
79
87
|
};
|
|
80
88
|
},
|
package/src/go/models.ts
CHANGED
|
@@ -59,9 +59,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
59
59
|
if (batchedAliases.has(model.name)) continue;
|
|
60
60
|
|
|
61
61
|
const canonicalStruct = className(canonicalName);
|
|
62
|
+
// Skip when different IR names map to the same Go type (e.g. synthetic
|
|
63
|
+
// models from enrichModelsFromSpec whose underscore names collapse to the
|
|
64
|
+
// same PascalCase as the original model).
|
|
65
|
+
if (structName === canonicalStruct) continue;
|
|
66
|
+
|
|
62
67
|
const hash = modelHashMap.get(model.name)!;
|
|
63
68
|
const groupNames = hashGroups.get(hash) ?? [];
|
|
64
|
-
const aliases = groupNames.filter((n) => aliasOf.has(n));
|
|
69
|
+
const aliases = groupNames.filter((n) => aliasOf.has(n) && className(n) !== className(aliasOf.get(n)!));
|
|
65
70
|
|
|
66
71
|
if (aliases.length >= 5) {
|
|
67
72
|
// Batch emit all aliases for this group at once
|
package/src/go/naming.ts
CHANGED
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
|
-
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
4
|
+
import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Go-specific acronym extensions beyond the shared base set.
|
|
8
|
+
* Go convention requires ALL_CAPS for well-known initialisms.
|
|
8
9
|
*/
|
|
9
|
-
const
|
|
10
|
-
[/Workos/g, 'WorkOS'],
|
|
11
|
-
[/Sso/g, 'SSO'],
|
|
12
|
-
[/Mfa/g, 'MFA'],
|
|
10
|
+
const GO_EXTRA_ACRONYM_FIXES: [RegExp, string][] = [
|
|
13
11
|
[/Jwks(?=[A-Z]|$)/g, 'JWKS'],
|
|
14
|
-
[/Jwt/g, 'JWT'],
|
|
15
12
|
[/Totp(?=[A-Z]|$)/g, 'TOTP'],
|
|
16
|
-
[/Cors/g, 'CORS'],
|
|
17
|
-
[/Saml/g, 'SAML'],
|
|
18
|
-
[/Scim/g, 'SCIM'],
|
|
19
|
-
[/Rbac/g, 'RBAC'],
|
|
20
|
-
[/Oauth/g, 'OAuth'],
|
|
21
|
-
[/Oidc/g, 'OIDC'],
|
|
22
13
|
[/Api(?=[A-Z]|$)/g, 'API'],
|
|
23
14
|
[/Urls(?=[A-Z]|$)/g, 'URLs'],
|
|
24
15
|
[/Url(?=[A-Z]|$)/g, 'URL'],
|
|
@@ -45,10 +36,7 @@ function fixTrailingId(s: string): string {
|
|
|
45
36
|
|
|
46
37
|
/** Apply all Go acronym conventions to a PascalCase string. */
|
|
47
38
|
function applyAcronyms(s: string): string {
|
|
48
|
-
let result = s;
|
|
49
|
-
for (const [pattern, replacement] of ACRONYM_FIXES) {
|
|
50
|
-
result = result.replace(pattern, replacement);
|
|
51
|
-
}
|
|
39
|
+
let result = applyAcronymFixes(s, GO_EXTRA_ACRONYM_FIXES);
|
|
52
40
|
result = fixTrailingId(result);
|
|
53
41
|
return result;
|
|
54
42
|
}
|