@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,360 @@
|
|
|
1
|
+
import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
|
|
2
|
+
import { walkTypeRef, assignModelsToServices } from '@workos/oagen';
|
|
3
|
+
import { className, fieldName, fileName, buildMountDirMap } from './naming.js';
|
|
4
|
+
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
5
|
+
|
|
6
|
+
/** Folder under lib/workos/ for models not owned by any service. */
|
|
7
|
+
export const SHARED_MODEL_DIR = 'shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Prefix → mount-target-dir map for models that `assignModelsToServices` can't
|
|
11
|
+
* place (mostly webhook event payloads whose types aren't directly returned by
|
|
12
|
+
* any API operation). The prefix is the PascalCase model-name prefix.
|
|
13
|
+
*
|
|
14
|
+
* Order matters: longest prefix wins (checked first). Prefixes not in this map
|
|
15
|
+
* land in `shared/`.
|
|
16
|
+
*/
|
|
17
|
+
export const EVENT_MODEL_DIR_BY_PREFIX: ReadonlyArray<readonly [string, string]> = [
|
|
18
|
+
// Longer prefixes first — prefix matching is first-hit wins.
|
|
19
|
+
['ApiKey', 'api_keys'],
|
|
20
|
+
['OrganizationDomain', 'organization_domains'],
|
|
21
|
+
['Organization', 'organizations'],
|
|
22
|
+
['Authentication', 'user_management'],
|
|
23
|
+
['Invitation', 'user_management'],
|
|
24
|
+
['MagicAuth', 'user_management'],
|
|
25
|
+
['Magic', 'user_management'],
|
|
26
|
+
['Password', 'user_management'],
|
|
27
|
+
['Email', 'user_management'],
|
|
28
|
+
['Session', 'user_management'],
|
|
29
|
+
['Action', 'user_management'],
|
|
30
|
+
['User', 'user_management'],
|
|
31
|
+
['Connection', 'sso'],
|
|
32
|
+
['Dsync', 'directory_sync'],
|
|
33
|
+
['Directory', 'directory_sync'],
|
|
34
|
+
['Flag', 'feature_flags'],
|
|
35
|
+
['Role', 'authorization'],
|
|
36
|
+
['Permission', 'authorization'],
|
|
37
|
+
// Vault has no generated resource class (service is hand-maintained in the
|
|
38
|
+
// Ruby SDK), but the webhook payload models ship from the spec — group them
|
|
39
|
+
// under vault/ so the folder tree matches the hand-written client.
|
|
40
|
+
['Vault', 'vault'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Pick a subfolder for a model not assigned to any service. */
|
|
44
|
+
export function classifyUnassignedModel(modelName: string): string {
|
|
45
|
+
for (const [prefix, dir] of EVENT_MODEL_DIR_BY_PREFIX) {
|
|
46
|
+
if (modelName.startsWith(prefix)) return dir;
|
|
47
|
+
}
|
|
48
|
+
return SHARED_MODEL_DIR;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate Ruby model classes from IR Model definitions.
|
|
53
|
+
*
|
|
54
|
+
* Each model becomes a file at `lib/workos/{snake_name}.rb` containing
|
|
55
|
+
* a class under `WorkOS::` with:
|
|
56
|
+
* - attr_accessor for all fields
|
|
57
|
+
* - initialize(json) that parses JSON
|
|
58
|
+
* - to_json(*) that returns a hash
|
|
59
|
+
*/
|
|
60
|
+
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
61
|
+
if (models.length === 0) return [];
|
|
62
|
+
|
|
63
|
+
// Enum name set — used to detect enum-typed fields (treated as plain strings in Ruby).
|
|
64
|
+
const enumNames = new Set(ctx.spec.enums.map((e) => e.name));
|
|
65
|
+
const modelNames = new Set(models.map((m) => m.name));
|
|
66
|
+
|
|
67
|
+
// Model → mount target directory. Each model is assigned to the first service
|
|
68
|
+
// that references it (transitively). Orphans land in `shared/`. Zeitwerk is
|
|
69
|
+
// told to collapse each subfolder (in client.ts) so the namespace stays flat.
|
|
70
|
+
const modelToService = assignModelsToServices(models, ctx.spec.services);
|
|
71
|
+
const mountDirMap = buildMountDirMap(ctx);
|
|
72
|
+
const dirFor = (modelName: string): string => {
|
|
73
|
+
const service = modelToService.get(modelName);
|
|
74
|
+
if (!service) return classifyUnassignedModel(modelName);
|
|
75
|
+
return mountDirMap.get(service) ?? classifyUnassignedModel(modelName);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const files: GeneratedFile[] = [];
|
|
79
|
+
|
|
80
|
+
// Dedup identical models (by recursive structural hash).
|
|
81
|
+
const recursiveHashes = buildRecursiveHashMap(models, enumNames);
|
|
82
|
+
const hashGroups = new Map<string, string[]>();
|
|
83
|
+
for (const m of models) {
|
|
84
|
+
if (isListWrapperModel(m) || isListMetadataModel(m)) continue;
|
|
85
|
+
const h = recursiveHashes.get(m.name) ?? '';
|
|
86
|
+
if (!hashGroups.has(h)) hashGroups.set(h, []);
|
|
87
|
+
hashGroups.get(h)!.push(m.name);
|
|
88
|
+
}
|
|
89
|
+
const aliasOf = new Map<string, string>();
|
|
90
|
+
for (const names of hashGroups.values()) {
|
|
91
|
+
if (names.length <= 1) continue;
|
|
92
|
+
const sorted = [...names].sort();
|
|
93
|
+
const canonical = sorted[0];
|
|
94
|
+
for (let i = 1; i < sorted.length; i++) aliasOf.set(sorted[i], canonical);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const model of models) {
|
|
98
|
+
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
99
|
+
|
|
100
|
+
const cls = className(model.name);
|
|
101
|
+
const file = fileName(model.name);
|
|
102
|
+
|
|
103
|
+
// Ruby constant alias for duplicate models. Zeitwerk resolves the
|
|
104
|
+
// canonical on reference; the alias file just assigns the constant.
|
|
105
|
+
const canonical = aliasOf.get(model.name);
|
|
106
|
+
if (canonical) {
|
|
107
|
+
const canonCls = className(canonical);
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
lines.push('module WorkOS');
|
|
110
|
+
lines.push(` ${cls} = ${canonCls}`);
|
|
111
|
+
lines.push('end');
|
|
112
|
+
files.push({
|
|
113
|
+
path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
|
|
114
|
+
content: lines.join('\n'),
|
|
115
|
+
integrateTarget: true,
|
|
116
|
+
overwriteExisting: true,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Deduplicate field names that collide after snake_case.
|
|
122
|
+
const seenFieldNames = new Set<string>();
|
|
123
|
+
const fields = model.fields.filter((f) => {
|
|
124
|
+
const n = fieldName(f.name);
|
|
125
|
+
if (seenFieldNames.has(n)) return false;
|
|
126
|
+
seenFieldNames.add(n);
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const lines: string[] = [];
|
|
131
|
+
lines.push('module WorkOS');
|
|
132
|
+
lines.push(` class ${cls} < WorkOS::Types::BaseModel`);
|
|
133
|
+
lines.push('');
|
|
134
|
+
|
|
135
|
+
// Split fields into non-deprecated (attr_accessor) and deprecated (explicit accessors with warn).
|
|
136
|
+
const accessorFields = fields.filter((f) => !f.deprecated);
|
|
137
|
+
const deprecatedFields = fields.filter((f) => f.deprecated);
|
|
138
|
+
|
|
139
|
+
// HASH_ATTRS maps wire names (symbols) to Ruby attribute names (symbols).
|
|
140
|
+
// HashProvider uses this for to_h, to_json, and inspect.
|
|
141
|
+
lines.push(' HASH_ATTRS = {');
|
|
142
|
+
for (let i = 0; i < fields.length; i++) {
|
|
143
|
+
const field = fields[i];
|
|
144
|
+
const fname = fieldName(field.name);
|
|
145
|
+
const sep = i === fields.length - 1 ? '' : ',';
|
|
146
|
+
lines.push(` ${rubyHashLiteralKey(field.name)} :${fname}${sep}`);
|
|
147
|
+
}
|
|
148
|
+
lines.push(' }.freeze');
|
|
149
|
+
lines.push('');
|
|
150
|
+
|
|
151
|
+
// Emit @deprecated YARD tags for any deprecated fields before the accessor block.
|
|
152
|
+
if (deprecatedFields.length > 0) {
|
|
153
|
+
for (const f of deprecatedFields) {
|
|
154
|
+
const desc = f.description ? ` ${f.description.split('\n')[0].trim()}` : '';
|
|
155
|
+
lines.push(` # @!attribute ${fieldName(f.name)}`);
|
|
156
|
+
lines.push(` # @deprecated${desc}`);
|
|
157
|
+
}
|
|
158
|
+
lines.push('');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (accessorFields.length > 0) {
|
|
162
|
+
const attrs = accessorFields.map((f) => `:${fieldName(f.name)}`);
|
|
163
|
+
if (attrs.length === 1) {
|
|
164
|
+
lines.push(` attr_accessor ${attrs[0]}`);
|
|
165
|
+
} else {
|
|
166
|
+
lines.push(` attr_accessor \\`);
|
|
167
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
168
|
+
const sep = i === attrs.length - 1 ? '' : ',';
|
|
169
|
+
lines.push(` ${attrs[i]}${sep}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
lines.push('');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Emit deprecated field accessors with runtime warnings.
|
|
176
|
+
for (const f of deprecatedFields) {
|
|
177
|
+
const fname = fieldName(f.name);
|
|
178
|
+
lines.push(` def ${fname}`);
|
|
179
|
+
lines.push(
|
|
180
|
+
` warn "[DEPRECATION] \\\`${fname}\\\` is deprecated and will be removed in a future version.", uplevel: 1`,
|
|
181
|
+
);
|
|
182
|
+
lines.push(` @${fname}`);
|
|
183
|
+
lines.push(' end');
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push(` def ${fname}=(value)`);
|
|
186
|
+
lines.push(` @${fname} = value`);
|
|
187
|
+
lines.push(' end');
|
|
188
|
+
lines.push('');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// initialize(json)
|
|
192
|
+
lines.push(' def initialize(json)');
|
|
193
|
+
lines.push(' hash = self.class.normalize(json)');
|
|
194
|
+
for (const field of fields) {
|
|
195
|
+
const fname = fieldName(field.name);
|
|
196
|
+
const rawKey = field.name;
|
|
197
|
+
lines.push(` ${deserializeAssignment(fname, rawKey, field.type, field.required, enumNames, modelNames)}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push(' end');
|
|
200
|
+
|
|
201
|
+
lines.push(' end');
|
|
202
|
+
lines.push('end');
|
|
203
|
+
|
|
204
|
+
files.push({
|
|
205
|
+
path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
|
|
206
|
+
content: lines.join('\n'),
|
|
207
|
+
integrateTarget: true,
|
|
208
|
+
overwriteExisting: true,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return files;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Produce a Ruby assignment line like `@foo = hash[:foo]` or the appropriate
|
|
217
|
+
* nested-model construction for the field's type.
|
|
218
|
+
*/
|
|
219
|
+
function deserializeAssignment(
|
|
220
|
+
rubyFieldName: string,
|
|
221
|
+
rawKey: string,
|
|
222
|
+
ref: TypeRef,
|
|
223
|
+
required: boolean,
|
|
224
|
+
enumNames: Set<string>,
|
|
225
|
+
modelNames: Set<string>,
|
|
226
|
+
): string {
|
|
227
|
+
const accessor = rubyHashAccessor('hash', rawKey);
|
|
228
|
+
const expr = deserializeExpression(accessor, ref, required, enumNames, modelNames);
|
|
229
|
+
return `@${rubyFieldName} = ${expr}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Build an expression that deserializes a TypeRef from the given accessor. */
|
|
233
|
+
function deserializeExpression(
|
|
234
|
+
accessor: string,
|
|
235
|
+
ref: TypeRef,
|
|
236
|
+
required: boolean,
|
|
237
|
+
enumNames: Set<string>,
|
|
238
|
+
modelNames: Set<string>,
|
|
239
|
+
): string {
|
|
240
|
+
void enumNames;
|
|
241
|
+
// Unwrap nullable — nullable behaves like optional at the value level.
|
|
242
|
+
if (ref.kind === 'nullable') {
|
|
243
|
+
return deserializeExpression(accessor, ref.inner, false, enumNames, modelNames);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (ref.kind === 'array') {
|
|
247
|
+
const inner = ref.items;
|
|
248
|
+
const itemDeser = deserializeExpression('item', inner, true, enumNames, modelNames);
|
|
249
|
+
if (itemDeser === 'item') {
|
|
250
|
+
return `(${accessor} || [])`;
|
|
251
|
+
}
|
|
252
|
+
return `(${accessor} || []).map { |item| ${itemDeser} }`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (ref.kind === 'map') {
|
|
256
|
+
return `${accessor} || {}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (ref.kind === 'model' && modelNames.has(ref.name)) {
|
|
260
|
+
const cls = `WorkOS::${className(ref.name)}`;
|
|
261
|
+
if (required) {
|
|
262
|
+
return `${accessor} ? ${cls}.new(${accessor}) : nil`;
|
|
263
|
+
}
|
|
264
|
+
return `${accessor} ? ${cls}.new(${accessor}) : nil`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (ref.kind === 'enum' && enumNames.has(ref.name)) {
|
|
268
|
+
return accessor; // enums are plain strings in Ruby (values match server)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (ref.kind === 'union') {
|
|
272
|
+
// Unions: if all variants are models, try each; otherwise return raw value.
|
|
273
|
+
const modelVariants = ref.variants.filter((v) => v.kind === 'model' && modelNames.has(v.name));
|
|
274
|
+
if (modelVariants.length > 0 && modelVariants.length === ref.variants.length) {
|
|
275
|
+
// Multiple model variants — default to first successful parse or raw value.
|
|
276
|
+
return accessor; // simplification: return raw, let user inspect
|
|
277
|
+
}
|
|
278
|
+
return accessor;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (ref.kind === 'literal') {
|
|
282
|
+
return accessor;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// primitive
|
|
286
|
+
return accessor;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Produce a Ruby accessor expression `hash[:foo]` or `hash[:"weird#name"]` for a raw field name. */
|
|
290
|
+
function rubyHashAccessor(accessor: string, name: string): string {
|
|
291
|
+
if (/^[a-z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
292
|
+
return `${accessor}[:${name}]`;
|
|
293
|
+
}
|
|
294
|
+
// Non-simple identifier: quoted symbol literal.
|
|
295
|
+
return `${accessor}[:"${name.replace(/"/g, '\\"')}"]`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Produce a Ruby hash key literal (e.g., `foo:` shorthand or `"foo#bar" =>`).
|
|
299
|
+
* Simple identifiers (including camelCase) use symbol shorthand; names with
|
|
300
|
+
* special characters fall back to string-key `=>` syntax. */
|
|
301
|
+
function rubyHashLiteralKey(name: string): string {
|
|
302
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
303
|
+
return `${name}:`;
|
|
304
|
+
}
|
|
305
|
+
return `"${name.replace(/"/g, '\\"')}" =>`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Build a recursive structural hash map for model deduplication. */
|
|
309
|
+
function buildRecursiveHashMap(models: Model[], enumNames: Set<string>): Map<string, string> {
|
|
310
|
+
const modelByName = new Map(models.map((m) => [m.name, m]));
|
|
311
|
+
const memo = new Map<string, string>();
|
|
312
|
+
|
|
313
|
+
const hashTypeRef = (ref: TypeRef, visiting: Set<string>): string => {
|
|
314
|
+
switch (ref.kind) {
|
|
315
|
+
case 'primitive':
|
|
316
|
+
return `P:${ref.type}${ref.format ?? ''}`;
|
|
317
|
+
case 'array':
|
|
318
|
+
return `A[${hashTypeRef(ref.items, visiting)}]`;
|
|
319
|
+
case 'nullable':
|
|
320
|
+
return `N[${hashTypeRef(ref.inner, visiting)}]`;
|
|
321
|
+
case 'map':
|
|
322
|
+
return `M[${hashTypeRef(ref.valueType, visiting)}]`;
|
|
323
|
+
case 'literal':
|
|
324
|
+
return `L:${String(ref.value)}`;
|
|
325
|
+
case 'enum':
|
|
326
|
+
return `E:${ref.name}`;
|
|
327
|
+
case 'model': {
|
|
328
|
+
if (visiting.has(ref.name)) return `R:${ref.name}`; // cycle
|
|
329
|
+
return `Ref[${hashModel(ref.name, visiting)}]`;
|
|
330
|
+
}
|
|
331
|
+
case 'union':
|
|
332
|
+
return `U[${ref.variants.map((v) => hashTypeRef(v, visiting)).join(',')}]`;
|
|
333
|
+
}
|
|
334
|
+
return 'unknown';
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const hashField = (f: Field, visiting: Set<string>): string => {
|
|
338
|
+
return `${f.name}:${f.required ? 'R' : 'O'}:${hashTypeRef(f.type, visiting)}`;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const hashModel = (name: string, visiting: Set<string>): string => {
|
|
342
|
+
if (memo.has(name)) return memo.get(name)!;
|
|
343
|
+
const m = modelByName.get(name);
|
|
344
|
+
if (!m) return `?:${name}`;
|
|
345
|
+
visiting.add(name);
|
|
346
|
+
const fieldsSorted = [...m.fields].sort((a, b) => a.name.localeCompare(b.name));
|
|
347
|
+
const h = fieldsSorted.map((f) => hashField(f, visiting)).join('|');
|
|
348
|
+
visiting.delete(name);
|
|
349
|
+
memo.set(name, h);
|
|
350
|
+
return h;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
void enumNames;
|
|
354
|
+
void walkTypeRef;
|
|
355
|
+
const out = new Map<string, string>();
|
|
356
|
+
for (const m of models) {
|
|
357
|
+
out.set(m.name, hashModel(m.name, new Set()));
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase, toSnakeCase } from '@workos/oagen';
|
|
3
|
+
import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
|
+
import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ruby class names that collide with core classes. When a model name resolves
|
|
8
|
+
* to one of these, suffix with "Model".
|
|
9
|
+
*/
|
|
10
|
+
const RUBY_RESERVED_CLASS_NAMES = new Set([
|
|
11
|
+
'Array',
|
|
12
|
+
'Hash',
|
|
13
|
+
'String',
|
|
14
|
+
'Integer',
|
|
15
|
+
'Float',
|
|
16
|
+
'Object',
|
|
17
|
+
'Module',
|
|
18
|
+
'Class',
|
|
19
|
+
'Comparable',
|
|
20
|
+
'Enumerable',
|
|
21
|
+
'Range',
|
|
22
|
+
'Proc',
|
|
23
|
+
'Method',
|
|
24
|
+
'Regexp',
|
|
25
|
+
'Symbol',
|
|
26
|
+
'File',
|
|
27
|
+
'Dir',
|
|
28
|
+
'IO',
|
|
29
|
+
'Data',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** PascalCase class name with acronym preservation. */
|
|
33
|
+
export function className(name: string): string {
|
|
34
|
+
let result = applyAcronymFixes(toPascalCase(stripUrnPrefix(name)));
|
|
35
|
+
if (RUBY_RESERVED_CLASS_NAMES.has(result)) {
|
|
36
|
+
result += 'Model';
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** snake_case file name (without extension). */
|
|
42
|
+
export function fileName(name: string): string {
|
|
43
|
+
return toSnakeCase(stripUrnPrefix(name));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** snake_case method name. */
|
|
47
|
+
export function methodName(name: string): string {
|
|
48
|
+
return toSnakeCase(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** snake_case field name. */
|
|
52
|
+
export function fieldName(name: string): string {
|
|
53
|
+
return toSnakeCase(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ruby reserved words that cannot be used as parameter names.
|
|
58
|
+
* When a path/query param name collides, suffix with underscore.
|
|
59
|
+
*/
|
|
60
|
+
const RUBY_RESERVED_WORDS = new Set([
|
|
61
|
+
'BEGIN',
|
|
62
|
+
'END',
|
|
63
|
+
'alias',
|
|
64
|
+
'and',
|
|
65
|
+
'begin',
|
|
66
|
+
'break',
|
|
67
|
+
'case',
|
|
68
|
+
'class',
|
|
69
|
+
'def',
|
|
70
|
+
'defined?',
|
|
71
|
+
'do',
|
|
72
|
+
'else',
|
|
73
|
+
'elsif',
|
|
74
|
+
'end',
|
|
75
|
+
'ensure',
|
|
76
|
+
'false',
|
|
77
|
+
'for',
|
|
78
|
+
'if',
|
|
79
|
+
'in',
|
|
80
|
+
'module',
|
|
81
|
+
'next',
|
|
82
|
+
'nil',
|
|
83
|
+
'not',
|
|
84
|
+
'or',
|
|
85
|
+
'redo',
|
|
86
|
+
'rescue',
|
|
87
|
+
'retry',
|
|
88
|
+
'return',
|
|
89
|
+
'self',
|
|
90
|
+
'super',
|
|
91
|
+
'then',
|
|
92
|
+
'true',
|
|
93
|
+
'undef',
|
|
94
|
+
'unless',
|
|
95
|
+
'until',
|
|
96
|
+
'when',
|
|
97
|
+
'while',
|
|
98
|
+
'yield',
|
|
99
|
+
// Common methods on Object/Kernel that shouldn't be shadowed
|
|
100
|
+
'hash',
|
|
101
|
+
'send',
|
|
102
|
+
'class',
|
|
103
|
+
'method',
|
|
104
|
+
'tap',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Safe parameter name for path/query params that avoids shadowing Ruby reserved words.
|
|
109
|
+
*/
|
|
110
|
+
export function safeParamName(name: string): string {
|
|
111
|
+
const snake = toSnakeCase(name);
|
|
112
|
+
return RUBY_RESERVED_WORDS.has(snake) ? `${snake}_` : snake;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** snake_case module/directory name. */
|
|
116
|
+
export function moduleName(name: string): string {
|
|
117
|
+
return toSnakeCase(name);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** snake_case property name for service accessors on the client. */
|
|
121
|
+
export function servicePropertyName(name: string): string {
|
|
122
|
+
return toSnakeCase(name);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the effective service name, using the overlay-resolved class name
|
|
127
|
+
* when available.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveServiceName(service: Service, ctx: EmitterContext): string {
|
|
130
|
+
return resolveClassName(service, ctx);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build a map from IR service name to resolved service name.
|
|
135
|
+
*/
|
|
136
|
+
export function buildServiceNameMap(services: Service[], ctx: EmitterContext): Map<string, string> {
|
|
137
|
+
const map = new Map<string, string>();
|
|
138
|
+
for (const service of services) {
|
|
139
|
+
map.set(service.name, resolveServiceName(service, ctx));
|
|
140
|
+
}
|
|
141
|
+
return map;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Resolve the SDK method name for an operation, using resolved operations first. */
|
|
145
|
+
export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
|
|
146
|
+
const lookup = buildResolvedLookup(ctx);
|
|
147
|
+
const resolved = lookupMethodName(op, lookup);
|
|
148
|
+
if (resolved) return resolved;
|
|
149
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
150
|
+
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
151
|
+
if (existing) return toSnakeCase(existing.methodName);
|
|
152
|
+
return toSnakeCase(op.name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Resolve the SDK class name for a service, using resolved operations' mountOn. */
|
|
156
|
+
export function resolveClassName(service: Service, ctx: EmitterContext): string {
|
|
157
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
158
|
+
if (r.service.name === service.name) return r.mountOn;
|
|
159
|
+
}
|
|
160
|
+
if (ctx.overlayLookup?.methodByOperation) {
|
|
161
|
+
for (const op of service.operations) {
|
|
162
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
163
|
+
const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
|
|
164
|
+
if (existing) return existing.className;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return toPascalCase(service.name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Resolve the type name for a model, checking overlay first. */
|
|
171
|
+
export function resolveTypeName(name: string, ctx: EmitterContext): string {
|
|
172
|
+
const existing = ctx.overlayLookup?.interfaceByName?.get(name);
|
|
173
|
+
if (existing) return existing;
|
|
174
|
+
return toPascalCase(stripUrnPrefix(name));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build a map from IR service name to mount-target directory name.
|
|
179
|
+
*/
|
|
180
|
+
export function buildMountDirMap(ctx: EmitterContext): Map<string, string> {
|
|
181
|
+
const map = new Map<string, string>();
|
|
182
|
+
for (const service of ctx.spec.services) {
|
|
183
|
+
const target = getMountTarget(service, ctx);
|
|
184
|
+
map.set(service.name, moduleName(target));
|
|
185
|
+
}
|
|
186
|
+
return map;
|
|
187
|
+
}
|