@workos/oagen-emitters 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +28 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{plugin-H0KhxbN7.mjs → plugin-C408Wh-o.mjs} +2632 -717
- package/dist/plugin-C408Wh-o.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +323 -0
- package/package.json +2 -2
- package/src/go/models.ts +48 -3
- package/src/index.ts +1 -0
- package/src/php/models.ts +27 -3
- package/src/php/resources.ts +16 -16
- package/src/plugin.ts +2 -1
- package/src/python/enums.ts +11 -54
- package/src/python/models.ts +204 -219
- package/src/python/path-expression.ts +75 -26
- package/src/python/resources.ts +19 -44
- package/src/python/shared-schemas.ts +488 -0
- package/src/python/tests.ts +9 -7
- package/src/ruby/resources.ts +13 -1
- package/src/rust/client.ts +62 -0
- package/src/rust/enums.ts +201 -0
- package/src/rust/fixtures.ts +110 -0
- package/src/rust/index.ts +95 -0
- package/src/rust/manifest.ts +31 -0
- package/src/rust/models.ts +150 -0
- package/src/rust/naming.ts +131 -0
- package/src/rust/resources.ts +689 -0
- package/src/rust/secret.ts +59 -0
- package/src/rust/tests.ts +298 -0
- package/src/rust/type-map.ts +225 -0
- package/test/entrypoint.test.ts +1 -0
- package/test/go/models.test.ts +116 -1
- package/test/go/resources.test.ts +70 -0
- package/test/php/models.test.ts +77 -0
- package/test/php/resources.test.ts +95 -0
- package/test/plugin.test.ts +2 -1
- package/test/python/enums.test.ts +91 -0
- package/test/python/models.test.ts +225 -0
- package/test/python/resources.test.ts +47 -2
- package/test/ruby/resources.test.ts +58 -0
- package/test/rust/client.test.ts +62 -0
- package/test/rust/enums.test.ts +117 -0
- package/test/rust/manifest.test.ts +73 -0
- package/test/rust/models.test.ts +139 -0
- package/test/rust/resources.test.ts +245 -0
- package/test/rust/type-map.test.ts +83 -0
- package/dist/plugin-H0KhxbN7.mjs.map +0 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import type { ApiSpec, EmitterContext, Enum, Model, Service } from '@workos/oagen';
|
|
2
|
+
import { assignModelsToServices, collectFieldDependencies, planOperation, walkTypeRef } from '@workos/oagen';
|
|
3
|
+
import { fileName } from './naming.js';
|
|
4
|
+
import { detectDiscriminators } from '../shared/model-utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Walk every operation across all services and tally, per schema, the set of
|
|
8
|
+
* services that transitively reference it. Schemas referenced by more than one
|
|
9
|
+
* service are "shared" — they should be emitted under common/ rather than
|
|
10
|
+
* the first alphabetical service that happens to use them.
|
|
11
|
+
*
|
|
12
|
+
* Transitive walk for models follows model->model field references AND
|
|
13
|
+
* discriminator variant mappings to a fixed point; enums are leaves.
|
|
14
|
+
*/
|
|
15
|
+
export function findSharedSchemas(spec: ApiSpec): { models: Set<string>; enums: Set<string> } {
|
|
16
|
+
const modelsByName = new Map(spec.models.map((m) => [m.name, m]));
|
|
17
|
+
const modelToServices = new Map<string, Set<string>>();
|
|
18
|
+
const enumToServices = new Map<string, Set<string>>();
|
|
19
|
+
|
|
20
|
+
const note = (map: Map<string, Set<string>>, name: string, service: string): void => {
|
|
21
|
+
let bucket = map.get(name);
|
|
22
|
+
if (!bucket) {
|
|
23
|
+
bucket = new Set();
|
|
24
|
+
map.set(name, bucket);
|
|
25
|
+
}
|
|
26
|
+
bucket.add(service);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (const service of spec.services) {
|
|
30
|
+
const directModels = new Set<string>();
|
|
31
|
+
const directEnums = new Set<string>();
|
|
32
|
+
const collect = (ref: unknown): void => {
|
|
33
|
+
walkTypeRef(ref as never, {
|
|
34
|
+
model: (r) => directModels.add(r.name),
|
|
35
|
+
enum: (r) => directEnums.add(r.name),
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
for (const op of service.operations) {
|
|
40
|
+
if (op.requestBody) collect(op.requestBody);
|
|
41
|
+
collect(op.response);
|
|
42
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
43
|
+
collect(p.type);
|
|
44
|
+
}
|
|
45
|
+
if (op.pagination) collect(op.pagination.itemType);
|
|
46
|
+
for (const err of op.errors) {
|
|
47
|
+
if (err.type) collect(err.type);
|
|
48
|
+
}
|
|
49
|
+
for (const sr of op.successResponses ?? []) {
|
|
50
|
+
collect(sr.type);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Transitively expand model references via field types AND discriminator
|
|
55
|
+
// variant mappings (dispatchers route to variants without listing them as
|
|
56
|
+
// fields, so plain field-walking misses them).
|
|
57
|
+
const queue = [...directModels];
|
|
58
|
+
while (queue.length > 0) {
|
|
59
|
+
const name = queue.pop()!;
|
|
60
|
+
const model = modelsByName.get(name);
|
|
61
|
+
if (!model) continue;
|
|
62
|
+
for (const field of model.fields) {
|
|
63
|
+
walkTypeRef(field.type as never, {
|
|
64
|
+
model: (r) => {
|
|
65
|
+
if (!directModels.has(r.name)) {
|
|
66
|
+
directModels.add(r.name);
|
|
67
|
+
queue.push(r.name);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
enum: (r) => directEnums.add(r.name),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const disc = (model as { discriminator?: { property: string; mapping: Record<string, string> } }).discriminator;
|
|
74
|
+
if (disc?.mapping) {
|
|
75
|
+
for (const variantName of Object.values(disc.mapping)) {
|
|
76
|
+
if (!directModels.has(variantName)) {
|
|
77
|
+
directModels.add(variantName);
|
|
78
|
+
queue.push(variantName);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const name of directModels) note(modelToServices, name, service.name);
|
|
85
|
+
for (const name of directEnums) note(enumToServices, name, service.name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sharedModels = new Set<string>();
|
|
89
|
+
for (const [name, services] of modelToServices) {
|
|
90
|
+
if (services.size >= 2) sharedModels.add(name);
|
|
91
|
+
}
|
|
92
|
+
const sharedEnums = new Set<string>();
|
|
93
|
+
for (const [name, services] of enumToServices) {
|
|
94
|
+
if (services.size >= 2) sharedEnums.add(name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { models: sharedModels, enums: sharedEnums };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Final placement decisions for every model and enum in the spec. Computed
|
|
102
|
+
* once and consumed by every Python emitter pass (models, enums, resources,
|
|
103
|
+
* tests) so they all agree on which symbols live in `common/` and which live
|
|
104
|
+
* in a service directory.
|
|
105
|
+
*/
|
|
106
|
+
export interface SchemaPlacement {
|
|
107
|
+
/** Model -> service. Models in common/ are absent. */
|
|
108
|
+
modelToService: Map<string, string>;
|
|
109
|
+
/** Enum -> service. Enums in common/ are absent. */
|
|
110
|
+
enumToService: Map<string, string>;
|
|
111
|
+
/** Pre-relocation model -> service. Used to attach BC re-exports to the natural service barrel. */
|
|
112
|
+
originalModelToService: Map<string, string>;
|
|
113
|
+
/** Pre-relocation enum -> service. */
|
|
114
|
+
originalEnumToService: Map<string, string>;
|
|
115
|
+
/** Models relocated to common/ (the union of initial sharing + closure expansion). */
|
|
116
|
+
relocatedModels: Set<string>;
|
|
117
|
+
/** Enums relocated to common/. */
|
|
118
|
+
relocatedEnums: Set<string>;
|
|
119
|
+
/** Model alias name -> canonical model name (Python-only structural dedup). */
|
|
120
|
+
modelAliases: Map<string, string>;
|
|
121
|
+
/** Enum alias name -> canonical enum name. */
|
|
122
|
+
enumAliases: Map<string, string>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function computeSchemaPlacement(spec: ApiSpec, ctx: EmitterContext): SchemaPlacement {
|
|
126
|
+
// Annotate models with implicit discriminators so the closure can follow
|
|
127
|
+
// dispatcher → variant edges. detectDiscriminators is idempotent.
|
|
128
|
+
const annotatedModels = detectDiscriminators(spec.models);
|
|
129
|
+
if (annotatedModels !== spec.models) {
|
|
130
|
+
spec = { ...spec, models: annotatedModels };
|
|
131
|
+
}
|
|
132
|
+
const modelsByName = new Map(spec.models.map((m) => [m.name, m]));
|
|
133
|
+
const hintedModels = new Set(Object.keys(ctx.modelHints ?? {}));
|
|
134
|
+
|
|
135
|
+
const originalModelToService = assignModelsToServices(spec.models, spec.services, ctx.modelHints);
|
|
136
|
+
const originalEnumToService = assignEnumsToServicesNatural(spec.enums, spec.services);
|
|
137
|
+
|
|
138
|
+
// Precompute Python-specific structural alias maps so the closure can
|
|
139
|
+
// promote a canonical when its alias is shared.
|
|
140
|
+
const modelAliases = computeModelAliases(spec);
|
|
141
|
+
const enumAliases = computeEnumAliases(spec.enums);
|
|
142
|
+
|
|
143
|
+
const initial = findSharedSchemas(spec);
|
|
144
|
+
|
|
145
|
+
// Ensure aliases imply their canonical: if the alias is shared, the canonical
|
|
146
|
+
// must follow it into common/, otherwise the alias file would import from a
|
|
147
|
+
// service directory.
|
|
148
|
+
for (const [aliasName, canonicalName] of modelAliases) {
|
|
149
|
+
if (initial.models.has(aliasName)) initial.models.add(canonicalName);
|
|
150
|
+
}
|
|
151
|
+
for (const [aliasName, canonicalName] of enumAliases) {
|
|
152
|
+
if (initial.enums.has(aliasName)) initial.enums.add(canonicalName);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Initial common/-bound set: everything findSharedSchemas flagged (minus
|
|
156
|
+
// hinted models — direct shares respect explicit pins) plus everything that
|
|
157
|
+
// is unassigned from the natural placement and not pinned.
|
|
158
|
+
const sharedModels = new Set<string>();
|
|
159
|
+
for (const name of initial.models) {
|
|
160
|
+
if (!hintedModels.has(name)) sharedModels.add(name);
|
|
161
|
+
}
|
|
162
|
+
for (const model of spec.models) {
|
|
163
|
+
if (!originalModelToService.has(model.name) && !hintedModels.has(model.name)) {
|
|
164
|
+
sharedModels.add(model.name);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const sharedEnums = new Set(initial.enums);
|
|
168
|
+
for (const enumDef of spec.enums) {
|
|
169
|
+
if (!originalEnumToService.has(enumDef.name)) sharedEnums.add(enumDef.name);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Closure: any model/enum referenced by a model that ends up in common/
|
|
173
|
+
// must also be in common/, otherwise the emitted common/ file would reach
|
|
174
|
+
// back into a service package and create a circular-import hazard. Hints
|
|
175
|
+
// are *not* a stop signal here — the closure is structural. If a hinted
|
|
176
|
+
// model is reachable from common/, leaving it pinned to a service would
|
|
177
|
+
// re-introduce the back-edge. BC for the pinned import path is preserved
|
|
178
|
+
// via re-exports from the natural service barrel.
|
|
179
|
+
let changed = true;
|
|
180
|
+
while (changed) {
|
|
181
|
+
changed = false;
|
|
182
|
+
for (const name of sharedModels) {
|
|
183
|
+
const model = modelsByName.get(name);
|
|
184
|
+
if (!model) continue;
|
|
185
|
+
const deps = collectEmittedDependencies(model, modelAliases);
|
|
186
|
+
for (const dep of deps.models) {
|
|
187
|
+
if (sharedModels.has(dep)) continue;
|
|
188
|
+
if (!modelsByName.has(dep)) continue;
|
|
189
|
+
sharedModels.add(dep);
|
|
190
|
+
changed = true;
|
|
191
|
+
}
|
|
192
|
+
for (const dep of deps.enums) {
|
|
193
|
+
if (sharedEnums.has(dep)) continue;
|
|
194
|
+
sharedEnums.add(dep);
|
|
195
|
+
changed = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Build final assignment maps by relocating shared symbols to common/
|
|
201
|
+
// (i.e. removing them from the per-service assignment).
|
|
202
|
+
const modelToService = new Map(originalModelToService);
|
|
203
|
+
for (const name of sharedModels) {
|
|
204
|
+
modelToService.delete(name);
|
|
205
|
+
}
|
|
206
|
+
const enumToService = new Map(originalEnumToService);
|
|
207
|
+
for (const name of sharedEnums) {
|
|
208
|
+
enumToService.delete(name);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// relocatedModels = models with a natural service that ended up in common/.
|
|
212
|
+
// Models without a natural service were never in a service barrel, so they
|
|
213
|
+
// don't need a BC re-export. Hinted models DO get re-exported from the
|
|
214
|
+
// hinted service so existing imports keep resolving.
|
|
215
|
+
const relocatedModels = new Set<string>();
|
|
216
|
+
for (const name of sharedModels) {
|
|
217
|
+
if (!originalModelToService.has(name)) continue;
|
|
218
|
+
relocatedModels.add(name);
|
|
219
|
+
}
|
|
220
|
+
const relocatedEnums = new Set<string>();
|
|
221
|
+
for (const name of sharedEnums) {
|
|
222
|
+
if (!originalEnumToService.has(name)) continue;
|
|
223
|
+
relocatedEnums.add(name);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
modelToService,
|
|
228
|
+
enumToService,
|
|
229
|
+
originalModelToService,
|
|
230
|
+
originalEnumToService,
|
|
231
|
+
relocatedModels,
|
|
232
|
+
relocatedEnums,
|
|
233
|
+
modelAliases,
|
|
234
|
+
enumAliases,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Dependencies the emitter will materialize for a model's generated file.
|
|
240
|
+
* Captures alias canonicals, discriminator variants, and field-level model+enum
|
|
241
|
+
* references so the placement closure can decide whether the dependency must
|
|
242
|
+
* also live in `common/`.
|
|
243
|
+
*/
|
|
244
|
+
function collectEmittedDependencies(
|
|
245
|
+
model: Model,
|
|
246
|
+
modelAliases: Map<string, string>,
|
|
247
|
+
): { models: Set<string>; enums: Set<string> } {
|
|
248
|
+
const models = new Set<string>();
|
|
249
|
+
const enums = new Set<string>();
|
|
250
|
+
|
|
251
|
+
const canonical = modelAliases.get(model.name);
|
|
252
|
+
if (canonical) {
|
|
253
|
+
models.add(canonical);
|
|
254
|
+
return { models, enums };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const disc = (model as { discriminator?: { property: string; mapping: Record<string, string> } }).discriminator;
|
|
258
|
+
if (disc?.mapping) {
|
|
259
|
+
for (const variant of Object.values(disc.mapping)) models.add(variant);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const fieldDeps = collectFieldDependencies(model);
|
|
263
|
+
for (const m of fieldDeps.models) models.add(m);
|
|
264
|
+
for (const e of fieldDeps.enums) enums.add(e);
|
|
265
|
+
|
|
266
|
+
return { models, enums };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface ModelUsage {
|
|
270
|
+
requestOnly: Set<string>;
|
|
271
|
+
response: Set<string>;
|
|
272
|
+
mixed: Set<string>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function collectModelUsage(spec: ApiSpec): ModelUsage {
|
|
276
|
+
const request = new Set<string>();
|
|
277
|
+
const response = new Set<string>();
|
|
278
|
+
|
|
279
|
+
for (const service of spec.services) {
|
|
280
|
+
for (const op of service.operations) {
|
|
281
|
+
const plan = planOperation(op);
|
|
282
|
+
if (plan.responseModelName) response.add(plan.responseModelName);
|
|
283
|
+
if (op.pagination?.itemType.kind === 'model') response.add(op.pagination.itemType.name);
|
|
284
|
+
if (op.requestBody?.kind === 'model') request.add(op.requestBody.name);
|
|
285
|
+
if (op.requestBody?.kind === 'union') {
|
|
286
|
+
for (const variant of op.requestBody.variants ?? []) {
|
|
287
|
+
if (variant.kind === 'model') request.add(variant.name);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const mixed = new Set<string>();
|
|
294
|
+
for (const name of request) if (response.has(name)) mixed.add(name);
|
|
295
|
+
const requestOnly = new Set([...request].filter((name) => !mixed.has(name)));
|
|
296
|
+
const responseOnly = new Set([...response].filter((name) => !mixed.has(name)));
|
|
297
|
+
|
|
298
|
+
return { requestOnly, response: responseOnly, mixed };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function compareAliasPriority(left: string, right: string, usage: ModelUsage): number {
|
|
302
|
+
const score = (name: string): number => {
|
|
303
|
+
if (usage.response.has(name)) return 0;
|
|
304
|
+
if (usage.mixed.has(name)) return 1;
|
|
305
|
+
if (usage.requestOnly.has(name)) return 2;
|
|
306
|
+
return 3;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const diff = score(left) - score(right);
|
|
310
|
+
if (diff !== 0) return diff;
|
|
311
|
+
return left.localeCompare(right);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function canAliasModels(canonical: string, alias: string, usage: ModelUsage): boolean {
|
|
315
|
+
// Aliases that snake_case-collide with their canonical would self-import.
|
|
316
|
+
if (fileName(canonical) === fileName(alias)) return false;
|
|
317
|
+
// Don't alias across the request/response boundary — they may evolve apart.
|
|
318
|
+
if (
|
|
319
|
+
(usage.response.has(canonical) && usage.requestOnly.has(alias)) ||
|
|
320
|
+
(usage.response.has(alias) && usage.requestOnly.has(canonical))
|
|
321
|
+
) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Compute the Python emitter's structural model dedup map: alias -> canonical.
|
|
329
|
+
* Mirrors the logic in models.ts so the placement closure can promote
|
|
330
|
+
* canonicals when their aliases are shared.
|
|
331
|
+
*/
|
|
332
|
+
export function computeModelAliases(spec: ApiSpec): Map<string, string> {
|
|
333
|
+
const recursiveHashes = buildRecursiveHashMap(spec.models, spec.enums);
|
|
334
|
+
const usage = collectModelUsage(spec);
|
|
335
|
+
|
|
336
|
+
const hashGroups = new Map<string, string[]>();
|
|
337
|
+
for (const model of spec.models) {
|
|
338
|
+
if (model.fields.length === 0 && !(model as { discriminator?: unknown }).discriminator) {
|
|
339
|
+
// skipped by emit anyway when listmeta/wrapper, but we rely on emit-side filter.
|
|
340
|
+
}
|
|
341
|
+
const hash = recursiveHashes.get(model.name) ?? '';
|
|
342
|
+
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
343
|
+
hashGroups.get(hash)!.push(model.name);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const aliasOf = new Map<string, string>();
|
|
347
|
+
for (const [, names] of hashGroups) {
|
|
348
|
+
if (names.length <= 1) continue;
|
|
349
|
+
const sorted = [...names].sort((a, b) => compareAliasPriority(a, b, usage));
|
|
350
|
+
const canonical = sorted[0];
|
|
351
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
352
|
+
if (canAliasModels(canonical, sorted[i], usage)) {
|
|
353
|
+
aliasOf.set(sorted[i], canonical);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return aliasOf;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Compute the Python emitter's structural enum dedup map: alias -> canonical.
|
|
362
|
+
* Mirrors the logic in enums.ts.
|
|
363
|
+
*/
|
|
364
|
+
export function computeEnumAliases(enums: Enum[]): Map<string, string> {
|
|
365
|
+
const hashGroups = new Map<string, string[]>();
|
|
366
|
+
for (const enumDef of enums) {
|
|
367
|
+
const hash = [...enumDef.values]
|
|
368
|
+
.map((v) => String(v.value))
|
|
369
|
+
.sort()
|
|
370
|
+
.join('|');
|
|
371
|
+
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
372
|
+
hashGroups.get(hash)!.push(enumDef.name);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const aliasOf = new Map<string, string>();
|
|
376
|
+
for (const [, names] of hashGroups) {
|
|
377
|
+
if (names.length <= 1) continue;
|
|
378
|
+
const sorted = [...names].sort();
|
|
379
|
+
const canonical = sorted[0];
|
|
380
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
381
|
+
aliasOf.set(sorted[i], canonical);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return aliasOf;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Recursive structural hashing for models so dedup runs against deeply-equal
|
|
389
|
+
* shapes, not just same-named ones. Cycles fall back to the model name.
|
|
390
|
+
*/
|
|
391
|
+
function buildRecursiveHashMap(models: Model[], enums: Enum[]): Map<string, string> {
|
|
392
|
+
const modelByName = new Map(models.map((m) => [m.name, m]));
|
|
393
|
+
const hashCache = new Map<string, string>();
|
|
394
|
+
const visiting = new Set<string>();
|
|
395
|
+
|
|
396
|
+
const enumVH = new Map<string, string>();
|
|
397
|
+
for (const e of enums) {
|
|
398
|
+
enumVH.set(
|
|
399
|
+
e.name,
|
|
400
|
+
[...e.values]
|
|
401
|
+
.map((v) => String(v.value))
|
|
402
|
+
.sort()
|
|
403
|
+
.join('|'),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function modelHash(name: string): string {
|
|
408
|
+
const cached = hashCache.get(name);
|
|
409
|
+
if (cached != null) return cached;
|
|
410
|
+
if (visiting.has(name)) return `m:${name}`;
|
|
411
|
+
visiting.add(name);
|
|
412
|
+
|
|
413
|
+
const model = modelByName.get(name);
|
|
414
|
+
if (!model) {
|
|
415
|
+
visiting.delete(name);
|
|
416
|
+
return `m:${name}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const hash = [...model.fields]
|
|
420
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
421
|
+
.map((f) => `${f.name}:${deepTypeHash(f.type)}:${f.required}`)
|
|
422
|
+
.join('|');
|
|
423
|
+
|
|
424
|
+
visiting.delete(name);
|
|
425
|
+
hashCache.set(name, hash);
|
|
426
|
+
return hash;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function deepTypeHash(ref: any): string {
|
|
430
|
+
switch (ref.kind) {
|
|
431
|
+
case 'primitive':
|
|
432
|
+
return `p:${ref.type}${ref.format ? `:${ref.format}` : ''}`;
|
|
433
|
+
case 'model':
|
|
434
|
+
return `m:{${modelHash(ref.name)}}`;
|
|
435
|
+
case 'enum': {
|
|
436
|
+
const vh = enumVH.get(ref.name);
|
|
437
|
+
return vh != null ? `e:{${vh}}` : `e:${ref.name}`;
|
|
438
|
+
}
|
|
439
|
+
case 'array':
|
|
440
|
+
return `a:${deepTypeHash(ref.items)}`;
|
|
441
|
+
case 'nullable':
|
|
442
|
+
return `n:${deepTypeHash(ref.inner)}`;
|
|
443
|
+
case 'union':
|
|
444
|
+
return `u:${(ref.variants ?? [])
|
|
445
|
+
.map((v: any) => deepTypeHash(v))
|
|
446
|
+
.sort()
|
|
447
|
+
.join(',')}`;
|
|
448
|
+
case 'map':
|
|
449
|
+
return `d:${deepTypeHash(ref.valueType)}`;
|
|
450
|
+
case 'literal':
|
|
451
|
+
return `l:${String(ref.value)}`;
|
|
452
|
+
default:
|
|
453
|
+
return 'unknown';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const model of models) modelHash(model.name);
|
|
458
|
+
return hashCache;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Natural enum-to-service assignment without sharing logic — the first service
|
|
463
|
+
* (alphabetically by spec order) to reference an enum wins.
|
|
464
|
+
*/
|
|
465
|
+
function assignEnumsToServicesNatural(enums: Enum[], services: Service[]): Map<string, string> {
|
|
466
|
+
const enumNames = new Set(enums.map((e) => e.name));
|
|
467
|
+
const enumToService = new Map<string, string>();
|
|
468
|
+
|
|
469
|
+
for (const service of services) {
|
|
470
|
+
const refs = new Set<string>();
|
|
471
|
+
const collect = (ref: any): void => {
|
|
472
|
+
walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
|
|
473
|
+
};
|
|
474
|
+
for (const op of service.operations) {
|
|
475
|
+
if (op.requestBody) collect(op.requestBody);
|
|
476
|
+
collect(op.response);
|
|
477
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
478
|
+
collect(p.type);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const name of refs) {
|
|
482
|
+
if (!enumNames.has(name)) continue;
|
|
483
|
+
if (!enumToService.has(name)) enumToService.set(name, service.name);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return enumToService;
|
|
488
|
+
}
|
package/src/python/tests.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
Model,
|
|
9
9
|
ResolvedOperation,
|
|
10
10
|
} from '@workos/oagen';
|
|
11
|
-
import { planOperation, toSnakeCase
|
|
11
|
+
import { planOperation, toSnakeCase } from '@workos/oagen';
|
|
12
12
|
import {
|
|
13
13
|
className,
|
|
14
14
|
fileName,
|
|
@@ -22,7 +22,6 @@ import { resolveResourceClassName, bodyParamName } from './resources.js';
|
|
|
22
22
|
import { buildServiceAccessPaths } from './client.js';
|
|
23
23
|
import { generateFixtures, generateModelFixture } from './fixtures.js';
|
|
24
24
|
import { isListWrapperModel, isListMetadataModel } from './models.js';
|
|
25
|
-
import { assignEnumsToServices } from './enums.js';
|
|
26
25
|
import {
|
|
27
26
|
groupByMount,
|
|
28
27
|
buildResolvedLookup,
|
|
@@ -32,6 +31,7 @@ import {
|
|
|
32
31
|
} from '../shared/resolved-ops.js';
|
|
33
32
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
34
33
|
import { pythonLiteral } from './wrappers.js';
|
|
34
|
+
import { computeSchemaPlacement } from './shared-schemas.js';
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Resolve the Python class name to use for isinstance checks on paginated items.
|
|
@@ -227,15 +227,14 @@ function generateServiceTest(
|
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
// Group imports by their actual service directory (models may live in different services)
|
|
230
|
-
const
|
|
230
|
+
const placement = computeSchemaPlacement(spec, ctx);
|
|
231
|
+
const modelToServiceMap = placement.modelToService;
|
|
232
|
+
const enumToServiceMap = placement.enumToService;
|
|
231
233
|
const mountDirMap = buildMountDirMap(ctx);
|
|
232
234
|
const resolveModelDir = (modelName: string) => {
|
|
233
235
|
const svc = modelToServiceMap.get(modelName);
|
|
234
236
|
return svc ? (mountDirMap.get(svc) ?? 'common') : 'common';
|
|
235
237
|
};
|
|
236
|
-
|
|
237
|
-
// Group enum imports by service directory
|
|
238
|
-
const enumToServiceMap = assignEnumsToServices(spec.enums, spec.services);
|
|
239
238
|
const resolveEnumDir = (enumName: string) => {
|
|
240
239
|
const svc = enumToServiceMap.get(enumName);
|
|
241
240
|
return svc ? (mountDirMap.get(svc) ?? 'common') : 'common';
|
|
@@ -1402,7 +1401,10 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
|
|
|
1402
1401
|
);
|
|
1403
1402
|
if (models.length === 0) return null;
|
|
1404
1403
|
|
|
1405
|
-
|
|
1404
|
+
// The round-trip test imports models from their *natural* (pre-relocation)
|
|
1405
|
+
// service so existing callers keep working — those imports resolve via the
|
|
1406
|
+
// BC re-exports that the model emitter writes into each service barrel.
|
|
1407
|
+
const modelToService = computeSchemaPlacement(spec, ctx).originalModelToService;
|
|
1406
1408
|
const roundTripDirMap = buildMountDirMap(ctx);
|
|
1407
1409
|
const resolveDir = (irService: string | undefined) =>
|
|
1408
1410
|
irService ? (roundTripDirMap.get(irService) ?? 'common') : 'common';
|
package/src/ruby/resources.ts
CHANGED
|
@@ -293,7 +293,10 @@ function emitMethod(args: {
|
|
|
293
293
|
const n = safeParamName(q.name);
|
|
294
294
|
if (seenParamNames.has(n)) continue;
|
|
295
295
|
seenParamNames.add(n);
|
|
296
|
-
|
|
296
|
+
// Spec is the source of truth for defaults. If the OpenAPI parameter has
|
|
297
|
+
// a `default`, surface it in the Ruby keyword arg signature; otherwise
|
|
298
|
+
// default to nil so the param is omitted from the request unless set.
|
|
299
|
+
const defaultVal = q.default != null ? rubyDefaultLiteral(q.default) : 'nil';
|
|
297
300
|
sigParts.push(`${n}: ${defaultVal}`);
|
|
298
301
|
}
|
|
299
302
|
|
|
@@ -883,6 +886,15 @@ function rubyStringLit(s: string): string {
|
|
|
883
886
|
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
884
887
|
}
|
|
885
888
|
|
|
889
|
+
/** Render an arbitrary spec-default value as a Ruby literal for a method-arg default. */
|
|
890
|
+
function rubyDefaultLiteral(value: unknown): string {
|
|
891
|
+
if (typeof value === 'string') return rubyStringLit(value);
|
|
892
|
+
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'nil';
|
|
893
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
894
|
+
if (value === null) return 'nil';
|
|
895
|
+
return 'nil';
|
|
896
|
+
}
|
|
897
|
+
|
|
886
898
|
/**
|
|
887
899
|
* Build a Ruby double-quoted string expression for the `else raise ArgumentError`
|
|
888
900
|
* arm of a parameter-group dispatcher. Lists the expected variant classes and
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import type { UnionRegistry } from './type-map.js';
|
|
3
|
+
import { moduleName } from './naming.js';
|
|
4
|
+
import { groupByMount } from '../shared/resolved-ops.js';
|
|
5
|
+
import { mountStructName } from './resources.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Rust emitter only generates spec-derived endpoint logic. The HTTP
|
|
9
|
+
* client (`src/client.rs`), crate root (`src/lib.rs`), error types
|
|
10
|
+
* (`src/error.rs`), pagination helpers (`src/pagination.rs`), and
|
|
11
|
+
* `Cargo.toml` are hand-maintained in the live SDK — analogous to a
|
|
12
|
+
* `Gemfile` in Ruby.
|
|
13
|
+
*
|
|
14
|
+
* This pass runs last (after `generateModels` and `generateResources`) so
|
|
15
|
+
* the shared {@link UnionRegistry} has collected every synthesised oneOf
|
|
16
|
+
* union before being rendered into `src/models/_unions.rs`.
|
|
17
|
+
*
|
|
18
|
+
* It also emits `src/resources_api.rs`, an auxiliary `impl Client { ... }`
|
|
19
|
+
* block that exposes one accessor method per mount target. Keeping the
|
|
20
|
+
* accessors in a generated file lets `src/client.rs` remain hand-maintained
|
|
21
|
+
* without drifting as services mount/unmount.
|
|
22
|
+
*/
|
|
23
|
+
export function generateClient(_spec: ApiSpec, ctx: EmitterContext, registry: UnionRegistry): GeneratedFile[] {
|
|
24
|
+
const files: GeneratedFile[] = [];
|
|
25
|
+
|
|
26
|
+
// _unions.rs — emitted unconditionally so the models barrel reference
|
|
27
|
+
// keeps the same shape across runs.
|
|
28
|
+
const unionsContent = registry.size() > 0 ? registry.render() : '// No oneOf-style unions registered.\n';
|
|
29
|
+
files.push({ path: 'src/models/_unions.rs', content: unionsContent, overwriteExisting: true });
|
|
30
|
+
|
|
31
|
+
// resources_api.rs — `impl Client { fn user_management() -> ... }`.
|
|
32
|
+
files.push({ path: 'src/resources_api.rs', content: renderResourcesApi(ctx), overwriteExisting: true });
|
|
33
|
+
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderResourcesApi(ctx: EmitterContext): string {
|
|
38
|
+
const groups = groupByMount(ctx);
|
|
39
|
+
const targets: { accessor: string; struct: string }[] = [];
|
|
40
|
+
for (const [mountName, group] of groups) {
|
|
41
|
+
if (group.operations.length === 0) continue;
|
|
42
|
+
targets.push({ accessor: moduleName(mountName), struct: mountStructName(mountName) });
|
|
43
|
+
}
|
|
44
|
+
targets.sort((a, b) => a.accessor.localeCompare(b.accessor));
|
|
45
|
+
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
lines.push('use crate::client::Client;');
|
|
48
|
+
for (const { struct } of targets) {
|
|
49
|
+
lines.push(`use crate::resources::${struct};`);
|
|
50
|
+
}
|
|
51
|
+
lines.push('');
|
|
52
|
+
lines.push('impl Client {');
|
|
53
|
+
targets.forEach(({ accessor, struct }, i) => {
|
|
54
|
+
lines.push(` /// Access the \`${accessor}\` resource.`);
|
|
55
|
+
lines.push(` pub fn ${accessor}(&self) -> ${struct}<'_> {`);
|
|
56
|
+
lines.push(` ${struct} { client: self }`);
|
|
57
|
+
lines.push(' }');
|
|
58
|
+
if (i < targets.length - 1) lines.push('');
|
|
59
|
+
});
|
|
60
|
+
lines.push('}');
|
|
61
|
+
return lines.join('\n') + '\n';
|
|
62
|
+
}
|