@workos/oagen-emitters 0.18.3 → 0.19.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 +16 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-1ckLMpgo.mjs → plugin-BXDPA9pJ.mjs} +581 -172
- package/dist/plugin-BXDPA9pJ.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +2 -2
- package/package.json +5 -5
- package/src/dotnet/enums.ts +11 -5
- package/src/dotnet/fixtures.ts +5 -2
- package/src/dotnet/index.ts +2 -1
- package/src/dotnet/models.ts +41 -10
- package/src/dotnet/naming.ts +10 -0
- package/src/dotnet/resources.ts +3 -3
- package/src/dotnet/tests.ts +8 -4
- package/src/go/fixtures.ts +4 -2
- package/src/go/index.ts +4 -0
- package/src/go/models.ts +4 -2
- package/src/go/naming.ts +10 -0
- package/src/go/resources.ts +22 -9
- package/src/go/tests.ts +3 -3
- package/src/kotlin/enums.ts +21 -11
- package/src/kotlin/index.ts +2 -1
- package/src/kotlin/models.ts +24 -9
- package/src/kotlin/naming.ts +11 -0
- package/src/kotlin/resources.ts +2 -2
- package/src/kotlin/tests.ts +7 -3
- package/src/node/enums.ts +8 -5
- package/src/node/field-plan.ts +3 -3
- package/src/node/index.ts +2 -1
- package/src/node/models.ts +69 -22
- package/src/node/naming.ts +10 -0
- package/src/node/options.ts +45 -1
- package/src/node/resources.ts +67 -18
- package/src/node/tests.ts +302 -31
- package/src/php/enums.ts +18 -5
- package/src/php/index.ts +13 -4
- package/src/php/models.ts +22 -10
- package/src/php/naming.ts +10 -0
- package/src/php/resources.ts +6 -4
- package/src/php/tests.ts +17 -5
- package/src/python/enums.ts +39 -28
- package/src/python/fixtures.ts +4 -3
- package/src/python/index.ts +2 -1
- package/src/python/models.ts +39 -24
- package/src/python/naming.ts +10 -0
- package/src/python/resources.ts +3 -3
- package/src/python/tests.ts +14 -9
- package/src/ruby/enums.ts +28 -19
- package/src/ruby/index.ts +2 -1
- package/src/ruby/models.ts +33 -19
- package/src/ruby/naming.ts +10 -0
- package/src/ruby/rbi.ts +20 -7
- package/src/ruby/resources.ts +2 -2
- package/src/ruby/tests.ts +6 -3
- package/src/rust/enums.ts +9 -1
- package/src/rust/index.ts +2 -1
- package/src/rust/models.ts +100 -15
- package/src/rust/naming.ts +10 -0
- package/src/rust/resources.ts +14 -3
- package/src/rust/tests.ts +2 -2
- package/src/shared/file-header.ts +13 -0
- package/src/shared/resolved-ops.ts +47 -0
- package/test/rust/models.test.ts +49 -0
- package/test/shared/synthetic-enum-seed.test.ts +79 -0
- package/dist/plugin-1ckLMpgo.mjs.map +0 -1
package/src/ruby/models.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
|
|
2
2
|
import { walkTypeRef, assignModelsToServices } from '@workos/oagen';
|
|
3
|
-
import { className,
|
|
3
|
+
import { className, domainFieldName, fileName, buildMountDirMap } from './naming.js';
|
|
4
4
|
import {
|
|
5
5
|
isListWrapperModel,
|
|
6
6
|
isListMetadataModel,
|
|
7
7
|
collectNonPaginatedResponseModelNames,
|
|
8
8
|
} from '../shared/model-utils.js';
|
|
9
|
+
import { isModelInScope } from '../shared/resolved-ops.js';
|
|
9
10
|
|
|
10
11
|
/** Folder under lib/workos/ for models not owned by any service. */
|
|
11
12
|
export const SHARED_MODEL_DIR = 'shared';
|
|
@@ -119,19 +120,25 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
119
120
|
lines.push('module WorkOS');
|
|
120
121
|
lines.push(` ${cls} = ${canonCls}`);
|
|
121
122
|
lines.push('end');
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
// FR-1.4: write the per-model file only when in scope. Zeitwerk autoloads
|
|
124
|
+
// by path, so there is no barrel to keep in sync; out-of-scope alias files
|
|
125
|
+
// are left untouched on disk.
|
|
126
|
+
if (isModelInScope(model.name, ctx)) {
|
|
127
|
+
files.push({
|
|
128
|
+
path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
|
|
129
|
+
content: lines.join('\n'),
|
|
130
|
+
integrateTarget: true,
|
|
131
|
+
overwriteExisting: true,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
128
134
|
continue;
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
// Deduplicate field names that collide after snake_case.
|
|
132
138
|
const seenFieldNames = new Set<string>();
|
|
133
139
|
const fields = model.fields.filter((f) => {
|
|
134
|
-
|
|
140
|
+
// Dedup on the DOMAIN accessor name (honors fieldHints override).
|
|
141
|
+
const n = domainFieldName(f);
|
|
135
142
|
if (seenFieldNames.has(n)) return false;
|
|
136
143
|
seenFieldNames.add(n);
|
|
137
144
|
return true;
|
|
@@ -151,7 +158,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
151
158
|
lines.push(' HASH_ATTRS = {');
|
|
152
159
|
for (let i = 0; i < fields.length; i++) {
|
|
153
160
|
const field = fields[i];
|
|
154
|
-
|
|
161
|
+
// DOMAIN attr symbol (honors fieldHints); the key below is the WIRE name.
|
|
162
|
+
const fname = domainFieldName(field);
|
|
155
163
|
const sep = i === fields.length - 1 ? '' : ',';
|
|
156
164
|
lines.push(` ${rubyHashLiteralKey(field.name)} :${fname}${sep}`);
|
|
157
165
|
}
|
|
@@ -162,14 +170,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
162
170
|
if (deprecatedFields.length > 0) {
|
|
163
171
|
for (const f of deprecatedFields) {
|
|
164
172
|
const desc = f.description ? ` ${f.description.split('\n')[0].trim()}` : '';
|
|
165
|
-
lines.push(` # @!attribute ${
|
|
173
|
+
lines.push(` # @!attribute ${domainFieldName(f)}`);
|
|
166
174
|
lines.push(` # @deprecated${desc}`);
|
|
167
175
|
}
|
|
168
176
|
lines.push('');
|
|
169
177
|
}
|
|
170
178
|
|
|
171
179
|
if (accessorFields.length > 0) {
|
|
172
|
-
const attrs = accessorFields.map((f) => `:${
|
|
180
|
+
const attrs = accessorFields.map((f) => `:${domainFieldName(f)}`);
|
|
173
181
|
if (attrs.length === 1) {
|
|
174
182
|
lines.push(` attr_accessor ${attrs[0]}`);
|
|
175
183
|
} else {
|
|
@@ -184,7 +192,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
184
192
|
|
|
185
193
|
// Emit deprecated field accessors with runtime warnings.
|
|
186
194
|
for (const f of deprecatedFields) {
|
|
187
|
-
const fname =
|
|
195
|
+
const fname = domainFieldName(f);
|
|
188
196
|
lines.push(` def ${fname}`);
|
|
189
197
|
lines.push(
|
|
190
198
|
` warn "[DEPRECATION] \\\`${fname}\\\` is deprecated and will be removed in a future version.", uplevel: 1`,
|
|
@@ -202,7 +210,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
202
210
|
lines.push(' def initialize(json)');
|
|
203
211
|
lines.push(' hash = self.class.normalize(json)');
|
|
204
212
|
for (const field of fields) {
|
|
205
|
-
|
|
213
|
+
// DOMAIN ivar name (honors fieldHints); rawKey is the WIRE key read from the hash.
|
|
214
|
+
const fname = domainFieldName(field);
|
|
206
215
|
const rawKey = field.name;
|
|
207
216
|
lines.push(` ${deserializeAssignment(fname, rawKey, field.type, field.required, enumNames, modelNames)}`);
|
|
208
217
|
}
|
|
@@ -211,12 +220,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
211
220
|
lines.push(' end');
|
|
212
221
|
lines.push('end');
|
|
213
222
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
// FR-1.4: write the per-model file only when in scope. Zeitwerk autoloads by
|
|
224
|
+
// path, so there is no barrel to keep in sync; out-of-scope model files are
|
|
225
|
+
// left untouched on disk.
|
|
226
|
+
if (isModelInScope(model.name, ctx)) {
|
|
227
|
+
files.push({
|
|
228
|
+
path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
|
|
229
|
+
content: lines.join('\n'),
|
|
230
|
+
integrateTarget: true,
|
|
231
|
+
overwriteExisting: true,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
220
234
|
}
|
|
221
235
|
|
|
222
236
|
return files;
|
package/src/ruby/naming.ts
CHANGED
|
@@ -57,6 +57,16 @@ export function fieldName(name: string): string {
|
|
|
57
57
|
return toSnakeCase(name);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* snake_case domain field name for a model field, honoring a `domainName`
|
|
62
|
+
* override (set via the `fieldHints` config) so a wire field can surface under
|
|
63
|
+
* a friendlier name. The wire key (still derived from `field.name`) is what
|
|
64
|
+
* gets sent/received over the wire — only the domain attr/accessor name changes.
|
|
65
|
+
*/
|
|
66
|
+
export function domainFieldName(field: { name: string; domainName?: string }): string {
|
|
67
|
+
return toSnakeCase(field.domainName ?? field.name);
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/**
|
|
61
71
|
* Ruby reserved words that cannot be used as parameter names.
|
|
62
72
|
* When a path/query param name collides, suffix with underscore.
|
package/src/ruby/rbi.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
|
3
3
|
import {
|
|
4
4
|
className,
|
|
5
5
|
fieldName,
|
|
6
|
+
domainFieldName,
|
|
6
7
|
fileName,
|
|
7
8
|
safeParamName,
|
|
8
9
|
scopedGroupVariantClassName,
|
|
@@ -14,6 +15,8 @@ import {
|
|
|
14
15
|
import {
|
|
15
16
|
buildResolvedLookup,
|
|
16
17
|
groupByMount,
|
|
18
|
+
isMountInScope,
|
|
19
|
+
isModelInScope,
|
|
17
20
|
lookupResolved,
|
|
18
21
|
buildHiddenParams,
|
|
19
22
|
collectGroupedParamNames,
|
|
@@ -92,7 +95,8 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
92
95
|
// Field accessors
|
|
93
96
|
const seenFieldNames = new Set<string>();
|
|
94
97
|
for (const f of model.fields) {
|
|
95
|
-
|
|
98
|
+
// DOMAIN accessor name in the .rbi (honors fieldHints override).
|
|
99
|
+
const fname = domainFieldName(f);
|
|
96
100
|
if (seenFieldNames.has(fname)) continue;
|
|
97
101
|
seenFieldNames.add(fname);
|
|
98
102
|
const sorbetType = f.required ? mapSorbetType(f.type) : wrapNilable(mapSorbetType(f.type));
|
|
@@ -114,12 +118,17 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
114
118
|
lines.push(' end');
|
|
115
119
|
lines.push('end');
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// FR-1.4: write the per-model .rbi only when in scope. The client.rbi
|
|
122
|
+
// aggregate (section 3) stays on the full set so sigs for out-of-scope
|
|
123
|
+
// services whose .rb/.rbi still exist keep resolving.
|
|
124
|
+
if (isModelInScope(model.name, ctx)) {
|
|
125
|
+
files.push({
|
|
126
|
+
path: `rbi/workos/${fileName(model.name)}.rbi`,
|
|
127
|
+
content: lines.join('\n'),
|
|
128
|
+
integrateTarget: true,
|
|
129
|
+
overwriteExisting: true,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
// 2. Generate service RBI files
|
|
@@ -135,6 +144,10 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
135
144
|
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
136
145
|
|
|
137
146
|
for (const [mountTarget, group] of groups) {
|
|
147
|
+
// Scoped run: emit per-service .rbi only for selected mount targets. The
|
|
148
|
+
// client.rbi aggregate loop below intentionally stays on the FULL `groups`
|
|
149
|
+
// set so it keeps emitting sigs for every service whose .rb still exists.
|
|
150
|
+
if (!isMountInScope(mountTarget, ctx)) continue;
|
|
138
151
|
const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
|
|
139
152
|
const cls = className(resolvedTarget);
|
|
140
153
|
const lines: string[] = [];
|
package/src/ruby/resources.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { mapTypeRefForYard } from './type-map.js';
|
|
|
15
15
|
import {
|
|
16
16
|
buildResolvedLookup,
|
|
17
17
|
lookupResolved,
|
|
18
|
-
|
|
18
|
+
scopedMountGroups,
|
|
19
19
|
getOpDefaults,
|
|
20
20
|
getOpInferFromClient,
|
|
21
21
|
buildHiddenParams,
|
|
@@ -33,7 +33,7 @@ import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantCla
|
|
|
33
33
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
34
34
|
const files: GeneratedFile[] = [];
|
|
35
35
|
|
|
36
|
-
const groups =
|
|
36
|
+
const groups = scopedMountGroups(ctx);
|
|
37
37
|
const lookup = buildResolvedLookup(ctx);
|
|
38
38
|
const modelNames = new Set(ctx.spec.models.map((m) => m.name));
|
|
39
39
|
const enumNames = new Set(ctx.spec.enums.map((e) => e.name));
|
package/src/ruby/tests.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
className,
|
|
4
4
|
fileName,
|
|
5
5
|
fieldName,
|
|
6
|
+
domainFieldName,
|
|
6
7
|
safeParamName,
|
|
7
8
|
scopedGroupVariantClassName,
|
|
8
9
|
servicePropertyName,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
} from './naming.js';
|
|
13
14
|
import {
|
|
14
15
|
buildResolvedLookup,
|
|
15
|
-
|
|
16
|
+
scopedMountGroups,
|
|
16
17
|
lookupResolved,
|
|
17
18
|
buildHiddenParams,
|
|
18
19
|
collectBodyFieldTypes,
|
|
@@ -33,7 +34,7 @@ import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js'
|
|
|
33
34
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
34
35
|
const files: GeneratedFile[] = [];
|
|
35
36
|
|
|
36
|
-
const groups =
|
|
37
|
+
const groups = scopedMountGroups(ctx);
|
|
37
38
|
const models = spec.models as Model[];
|
|
38
39
|
const modelByName = new Map<string, Model>();
|
|
39
40
|
for (const m of models) modelByName.set(m.name, m);
|
|
@@ -235,7 +236,9 @@ function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
|
|
|
235
236
|
const dedupFields = new Set<string>();
|
|
236
237
|
for (const f of model.fields) {
|
|
237
238
|
const wireName = f.name;
|
|
238
|
-
|
|
239
|
+
// Dedup on the DOMAIN accessor name to mirror the model's field dedup
|
|
240
|
+
// (models.ts). The fixture/assertion keys below still use the WIRE name.
|
|
241
|
+
const rubyFieldName = domainFieldName(f);
|
|
239
242
|
if (dedupFields.has(rubyFieldName)) continue;
|
|
240
243
|
dedupFields.add(rubyFieldName);
|
|
241
244
|
const stub = roundTripStub(f.type, enumNames);
|
package/src/rust/enums.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { typeName, moduleName, variantName } from './naming.js';
|
|
3
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Generate one Rust source file per enum under `src/enums/`, plus a
|
|
@@ -17,7 +18,7 @@ import { typeName, moduleName, variantName } from './naming.js';
|
|
|
17
18
|
* variant and re-serialize as the canonical wire string.
|
|
18
19
|
* - `Display`, `FromStr`, and `AsRef<str>` are implemented for ergonomics.
|
|
19
20
|
*/
|
|
20
|
-
export function generateEnums(enums: Enum[],
|
|
21
|
+
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
21
22
|
const files: GeneratedFile[] = [];
|
|
22
23
|
const seen = new Set<string>();
|
|
23
24
|
const moduleNames: string[] = [];
|
|
@@ -27,8 +28,15 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
27
28
|
const mod = moduleName(e.name);
|
|
28
29
|
if (seen.has(mod)) continue;
|
|
29
30
|
seen.add(mod);
|
|
31
|
+
// The barrel (`src/enums/mod.rs`) must declare every enum's module so Rust
|
|
32
|
+
// compiles even in a scoped run — `moduleNames` is collected from the FULL
|
|
33
|
+
// enum set regardless of scope.
|
|
30
34
|
moduleNames.push(mod);
|
|
31
35
|
|
|
36
|
+
// Only the per-enum `.rs` FILE write is scoped (FR-1.4). In a scoped run we
|
|
37
|
+
// skip emitting files for out-of-scope enums, but the barrel above still
|
|
38
|
+
// declares their modules (their existing `.rs` files stay untouched on disk).
|
|
39
|
+
if (!isEnumInScope(e.name, ctx)) continue;
|
|
32
40
|
files.push({
|
|
33
41
|
path: `src/enums/${mod}.rs`,
|
|
34
42
|
content: renderEnum(e),
|
package/src/rust/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { generateTests } from './tests.js';
|
|
|
17
17
|
import { buildOperationsMap } from './manifest.js';
|
|
18
18
|
import { UnionRegistry } from './type-map.js';
|
|
19
19
|
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
20
|
+
import { AUTOGEN_NOTICE } from '../shared/file-header.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Shared per-emit registry that collects synthesised oneOf-style unions
|
|
@@ -98,7 +99,7 @@ export const rustEmitter: Emitter = {
|
|
|
98
99
|
},
|
|
99
100
|
|
|
100
101
|
fileHeader(): string {
|
|
101
|
-
return
|
|
102
|
+
return `// ${AUTOGEN_NOTICE}`;
|
|
102
103
|
},
|
|
103
104
|
|
|
104
105
|
formatCommand(_targetDir: string): FormatCommand | null {
|
package/src/rust/models.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { Model, EmitterContext, GeneratedFile, Field } from '@workos/oagen';
|
|
2
|
-
import { typeName,
|
|
1
|
+
import type { Model, EmitterContext, GeneratedFile, Field, TypeRef } from '@workos/oagen';
|
|
2
|
+
import { typeName, domainFieldName, moduleName } from './naming.js';
|
|
3
3
|
import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
|
|
4
4
|
import { applySecretRedaction } from './secret.js';
|
|
5
|
+
import { isModelInScope } from '../shared/resolved-ops.js';
|
|
5
6
|
|
|
6
7
|
const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
|
|
7
8
|
|
|
@@ -18,6 +19,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
|
|
|
18
19
|
const moduleNames: string[] = [];
|
|
19
20
|
const seen = new Set<string>();
|
|
20
21
|
|
|
22
|
+
// Map of variant-model name -> discriminator wire-property for every model
|
|
23
|
+
// that appears as an arm of an internally-tagged (`#[serde(tag = ...)]`)
|
|
24
|
+
// union. serde consumes that property as the enum tag and strips it from the
|
|
25
|
+
// variant body during deserialization, so a *required* field of the same
|
|
26
|
+
// name can never be satisfied ("missing field `type`"). See renderField.
|
|
27
|
+
const taggedVariantFields = collectTaggedVariantFields(models);
|
|
28
|
+
|
|
21
29
|
for (const model of models) {
|
|
22
30
|
// Empty-field, non-discriminator models still need to be emitted as an
|
|
23
31
|
// empty struct so request bodies that reference them (e.g. an empty
|
|
@@ -25,11 +33,27 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
|
|
|
25
33
|
const mod = moduleName(model.name);
|
|
26
34
|
if (seen.has(mod)) continue;
|
|
27
35
|
seen.add(mod);
|
|
36
|
+
// The barrel (`src/models/mod.rs`) must declare every model's module so
|
|
37
|
+
// Rust compiles even in a scoped run — `moduleNames` is collected from the
|
|
38
|
+
// FULL model set regardless of scope.
|
|
28
39
|
moduleNames.push(mod);
|
|
29
40
|
|
|
41
|
+
// renderModel registers inline unions into `registry` as a side effect, and
|
|
42
|
+
// `_unions.rs` is rendered later (in generateClient) from that registry — so
|
|
43
|
+
// it MUST run for every model, even out-of-scope ones, or scoped runs drop
|
|
44
|
+
// unions. Compute content unconditionally; only the per-model `.rs` FILE write
|
|
45
|
+
// is scoped (FR-1.4). The barrel above still declares every module, and an
|
|
46
|
+
// out-of-scope model's existing `.rs` file stays untouched on disk.
|
|
30
47
|
const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
|
|
31
48
|
const path = hintPath ?? `src/models/${mod}.rs`;
|
|
32
|
-
|
|
49
|
+
const content = renderModel(model, registry, taggedVariantFields.get(model.name));
|
|
50
|
+
if (isModelInScope(model.name, ctx)) {
|
|
51
|
+
files.push({
|
|
52
|
+
path,
|
|
53
|
+
content,
|
|
54
|
+
overwriteExisting: true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
33
57
|
}
|
|
34
58
|
|
|
35
59
|
// Always include the unions module in the barrel so downstream stages
|
|
@@ -47,7 +71,48 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
|
|
|
47
71
|
return files;
|
|
48
72
|
}
|
|
49
73
|
|
|
50
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Walk every model field and record which models are arms of an
|
|
76
|
+
* internally-tagged union, mapped to that union's discriminator property.
|
|
77
|
+
*/
|
|
78
|
+
function collectTaggedVariantFields(models: Model[]): Map<string, string> {
|
|
79
|
+
const out = new Map<string, string>();
|
|
80
|
+
const visit = (ref: TypeRef): void => {
|
|
81
|
+
switch (ref.kind) {
|
|
82
|
+
case 'union':
|
|
83
|
+
if (ref.discriminator?.property) {
|
|
84
|
+
for (const variant of ref.variants) {
|
|
85
|
+
const name = variantModelName(variant);
|
|
86
|
+
if (name) out.set(name, ref.discriminator.property);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
ref.variants.forEach(visit);
|
|
90
|
+
break;
|
|
91
|
+
case 'array':
|
|
92
|
+
visit(ref.items);
|
|
93
|
+
break;
|
|
94
|
+
case 'nullable':
|
|
95
|
+
visit(ref.inner);
|
|
96
|
+
break;
|
|
97
|
+
case 'map':
|
|
98
|
+
visit(ref.valueType);
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
for (const model of models) for (const field of model.fields) visit(field.type);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resolve the underlying model name of a union arm, unwrapping a nullable. */
|
|
109
|
+
function variantModelName(ref: TypeRef): string | null {
|
|
110
|
+
if (ref.kind === 'model') return ref.name;
|
|
111
|
+
if (ref.kind === 'nullable') return variantModelName(ref.inner);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderModel(model: Model, registry: UnionRegistry, tagField?: string): string {
|
|
51
116
|
const lines: string[] = [];
|
|
52
117
|
lines.push(HEADER_PLACEHOLDER);
|
|
53
118
|
// Match rustfmt's canonical grouping: keyword-rooted paths (`super`,
|
|
@@ -65,7 +130,7 @@ function renderModel(model: Model, registry: UnionRegistry): string {
|
|
|
65
130
|
lines.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
|
|
66
131
|
|
|
67
132
|
const resolvedNames = resolveFieldNames(model.fields);
|
|
68
|
-
const fieldLines = model.fields.map((f, i) => renderField(f, resolvedNames[i]!, model.name, registry));
|
|
133
|
+
const fieldLines = model.fields.map((f, i) => renderField(f, resolvedNames[i]!, model.name, registry, tagField));
|
|
69
134
|
|
|
70
135
|
// rustfmt collapses zero-field structs to `pub struct Foo {}` on a single
|
|
71
136
|
// line. Match that shape so `cargo fmt --check` passes.
|
|
@@ -80,17 +145,20 @@ function renderModel(model: Model, registry: UnionRegistry): string {
|
|
|
80
145
|
}
|
|
81
146
|
|
|
82
147
|
/**
|
|
83
|
-
* Resolve unique Rust identifiers for struct fields.
|
|
84
|
-
*
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
148
|
+
* Resolve unique Rust identifiers for struct fields. The domain identifier
|
|
149
|
+
* honors a `fieldHints` override (`domainName`, e.g. wire `connection_type` →
|
|
150
|
+
* domain `type`); the wire name (and the `#[serde(rename = ...)]` key emitted
|
|
151
|
+
* in `renderField`) still derives from `f.name`. Multiple names can collide
|
|
152
|
+
* after snake-casing (e.g. `integration_type` and `integrationType` both
|
|
153
|
+
* become `integration_type`). Subsequent collisions get a numeric suffix so
|
|
154
|
+
* the struct compiles; serde `rename` preserves the original wire name in every
|
|
155
|
+
* case.
|
|
88
156
|
*/
|
|
89
157
|
function resolveFieldNames(fields: Field[]): string[] {
|
|
90
158
|
const used = new Set<string>();
|
|
91
159
|
const out: string[] = [];
|
|
92
160
|
for (const f of fields) {
|
|
93
|
-
const base =
|
|
161
|
+
const base = domainFieldName(f);
|
|
94
162
|
let candidate = base;
|
|
95
163
|
let suffix = 2;
|
|
96
164
|
while (used.has(candidate)) {
|
|
@@ -103,7 +171,13 @@ function resolveFieldNames(fields: Field[]): string[] {
|
|
|
103
171
|
return out;
|
|
104
172
|
}
|
|
105
173
|
|
|
106
|
-
function renderField(
|
|
174
|
+
function renderField(
|
|
175
|
+
field: Field,
|
|
176
|
+
rustField: string,
|
|
177
|
+
modelName: string,
|
|
178
|
+
registry: UnionRegistry,
|
|
179
|
+
tagField?: string,
|
|
180
|
+
): string {
|
|
107
181
|
const lines: string[] = [];
|
|
108
182
|
const hasDescription = !!field.description;
|
|
109
183
|
if (hasDescription) {
|
|
@@ -128,9 +202,20 @@ function renderField(field: Field, rustField: string, modelName: string, registr
|
|
|
128
202
|
// the value is a credential or token. Wire format is unchanged.
|
|
129
203
|
baseType = applySecretRedaction(baseType, field.name);
|
|
130
204
|
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
205
|
+
if (tagField === field.name) {
|
|
206
|
+
// This field is the discriminator of an internally-tagged union it belongs
|
|
207
|
+
// to. serde reads it as the enum tag and strips it from the variant body,
|
|
208
|
+
// so `default` lets the struct deserialize without it; `skip_serializing`
|
|
209
|
+
// stops the struct from re-emitting it (serde injects the tag itself,
|
|
210
|
+
// which would otherwise produce a duplicate key). Standalone uses of the
|
|
211
|
+
// struct still deserialize the value normally because the key is present.
|
|
212
|
+
const args = rename ? `rename = "${rename}", default, skip_serializing` : 'default, skip_serializing';
|
|
213
|
+
lines.push(` #[serde(${args})]`);
|
|
214
|
+
} else {
|
|
215
|
+
if (rename) lines.push(` #[serde(rename = "${rename}")]`);
|
|
216
|
+
if (baseType.startsWith('Option<')) {
|
|
217
|
+
lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
|
|
218
|
+
}
|
|
134
219
|
}
|
|
135
220
|
if (field.deprecated) lines.push(' #[deprecated]');
|
|
136
221
|
lines.push(` pub ${rustField}: ${baseType},`);
|
package/src/rust/naming.ts
CHANGED
|
@@ -77,6 +77,16 @@ export function fieldName(name: string): string {
|
|
|
77
77
|
return escapeKeyword(toSnakeCase(name));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* snake_case domain field name for a model field, honoring a `domainName`
|
|
82
|
+
* override (set via the `fieldHints` config) so a wire field can surface under
|
|
83
|
+
* a friendlier identifier. The wire name (and thus the `#[serde(rename = ...)]`
|
|
84
|
+
* key) still derives from `field.name`.
|
|
85
|
+
*/
|
|
86
|
+
export function domainFieldName(field: { name: string; domainName?: string }): string {
|
|
87
|
+
return escapeKeyword(toSnakeCase(field.domainName ?? field.name));
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
/** PascalCase enum variant. */
|
|
81
91
|
export function variantName(value: string | number): string {
|
|
82
92
|
const s = String(value);
|
package/src/rust/resources.ts
CHANGED
|
@@ -10,11 +10,11 @@ import type {
|
|
|
10
10
|
TypeRef,
|
|
11
11
|
} from '@workos/oagen';
|
|
12
12
|
import { planOperation } from '@workos/oagen';
|
|
13
|
-
import { fieldName, methodName, typeName, moduleName, variantName } from './naming.js';
|
|
13
|
+
import { fieldName, domainFieldName, methodName, typeName, moduleName, variantName } from './naming.js';
|
|
14
14
|
import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
|
|
15
15
|
import { applySecretRedaction } from './secret.js';
|
|
16
16
|
import { parsePathTemplate } from '../shared/path-template.js';
|
|
17
|
-
import { groupByMount, buildResolvedLookup } from '../shared/resolved-ops.js';
|
|
17
|
+
import { groupByMount, buildResolvedLookup, isMountInScope } from '../shared/resolved-ops.js';
|
|
18
18
|
import { resolveWrapperParams, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -32,7 +32,14 @@ export function generateResources(_services: Service[], ctx: EmitterContext, reg
|
|
|
32
32
|
if (group.operations.length === 0) continue;
|
|
33
33
|
const basename = moduleName(mountName);
|
|
34
34
|
const struct = mountStructName(mountName);
|
|
35
|
+
// The barrel (`src/resources/mod.rs`) must list every mount's module so
|
|
36
|
+
// Rust compiles even in a scoped run — `exports` is collected from the
|
|
37
|
+
// FULL groupByMount set regardless of scope.
|
|
35
38
|
exports.push({ module: basename, struct });
|
|
39
|
+
// Only the per-service resource `.rs` FILE write is scoped. In a scoped
|
|
40
|
+
// run we skip emitting files for out-of-scope mounts, but the barrel above
|
|
41
|
+
// still references their modules (their existing `.rs` files stay on disk).
|
|
42
|
+
if (!isMountInScope(mountName, ctx)) continue;
|
|
36
43
|
files.push({
|
|
37
44
|
path: `src/resources/${basename}.rs`,
|
|
38
45
|
content: renderMountGroup(mountName, group.resolvedOps, ctx, registry, lookup),
|
|
@@ -617,7 +624,11 @@ function registerSyntheticBody(
|
|
|
617
624
|
if (!f.required && !rust.startsWith('Option<')) rust = makeOptional(rust);
|
|
618
625
|
rust = applySecretRedaction(rust, f.name);
|
|
619
626
|
return {
|
|
620
|
-
|
|
627
|
+
// Domain identifier honors a `fieldHints` override (e.g. wire
|
|
628
|
+
// `connection_type` → domain `type`); `wireName` keeps `f.name`, and
|
|
629
|
+
// the `#[serde(rename = wireName)]` emitted in GroupEmitter.render
|
|
630
|
+
// fires whenever the two differ.
|
|
631
|
+
rustName: domainFieldName(f),
|
|
621
632
|
wireName: f.name,
|
|
622
633
|
rustType: rust,
|
|
623
634
|
required: !!f.required && !rust.startsWith('Option<'),
|
package/src/rust/tests.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
TypeRef,
|
|
12
12
|
} from '@workos/oagen';
|
|
13
13
|
import { methodName, moduleName, typeName } from './naming.js';
|
|
14
|
-
import {
|
|
14
|
+
import { scopedMountGroups } from '../shared/resolved-ops.js';
|
|
15
15
|
import { exampleFor, generateFixtures } from './fixtures.js';
|
|
16
16
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
17
17
|
import { isInlineEnvelopeList } from './resources.js';
|
|
@@ -43,7 +43,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
43
43
|
overwriteExisting: true,
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
const groups =
|
|
46
|
+
const groups = scopedMountGroups(ctx);
|
|
47
47
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
48
48
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
49
49
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical "do not edit" banner text shared by every emitter's
|
|
3
|
+
* `fileHeader()`. The text is comment-syntax agnostic — each emitter prefixes
|
|
4
|
+
* it with the appropriate comment marker for its language (`//`, `#`, etc.).
|
|
5
|
+
*
|
|
6
|
+
* Keeping this in one place prevents the wording from drifting between
|
|
7
|
+
* languages (Rust previously emitted a different banner).
|
|
8
|
+
*
|
|
9
|
+
* Go intentionally does NOT use this constant: its header must match the
|
|
10
|
+
* standard `^// Code generated .* DO NOT EDIT\.$` marker that Go tooling
|
|
11
|
+
* relies on to recognize generated files. See `src/go/index.ts`.
|
|
12
|
+
*/
|
|
13
|
+
export const AUTOGEN_NOTICE = 'This file is auto-generated by oagen. Do not edit.';
|
|
@@ -94,6 +94,53 @@ export function groupByMount(ctx: EmitterContext): Map<string, MountGroup> {
|
|
|
94
94
|
return groups;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Like {@link groupByMount}, but for a scoped (`--services`) run returns ONLY the
|
|
99
|
+
* mount groups the run selected (`ctx.scopedServices`, POST-MOUNT names). When
|
|
100
|
+
* scoping is inactive the full set is returned unchanged.
|
|
101
|
+
*
|
|
102
|
+
* Use this for PER-SERVICE resource/test emission. Do NOT use it for
|
|
103
|
+
* aggregate/barrel files (Rust `mod.rs`, Ruby `client.rbi`, the root client) —
|
|
104
|
+
* those must continue to list every service, so they keep calling
|
|
105
|
+
* {@link groupByMount} over the full set; otherwise a scoped run would drop
|
|
106
|
+
* sibling modules and break the build/type-check.
|
|
107
|
+
*/
|
|
108
|
+
export function scopedMountGroups(ctx: EmitterContext): Map<string, MountGroup> {
|
|
109
|
+
const groups = groupByMount(ctx);
|
|
110
|
+
const scope = ctx.scopedServices;
|
|
111
|
+
if (!scope || scope.size === 0) return groups;
|
|
112
|
+
return new Map([...groups].filter(([mountName]) => scope.has(mountName)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True when a POST-MOUNT service name should be emitted in the current run.
|
|
117
|
+
* Inactive scoping (no `ctx.scopedServices`) ⇒ everything is in scope. Use this
|
|
118
|
+
* for inline per-service gates (e.g. manifest loops keyed by `getMountTarget`).
|
|
119
|
+
*/
|
|
120
|
+
export function isMountInScope(mountName: string, ctx: EmitterContext): boolean {
|
|
121
|
+
const scope = ctx.scopedServices;
|
|
122
|
+
return !scope || scope.size === 0 || scope.has(mountName);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* True when a MODEL's per-model FILE should be written in the current run (FR-1.4).
|
|
127
|
+
* A scoped run sets `ctx.scopedModelNames` to the models reachable from the
|
|
128
|
+
* selected services; out-of-scope models are left untouched on disk. Inactive
|
|
129
|
+
* scoping ⇒ everything is in scope. NOTE: gate only the per-model FILE write —
|
|
130
|
+
* the model must still appear in barrels/indexes (built from the full set) so the
|
|
131
|
+
* untouched on-disk file stays importable.
|
|
132
|
+
*/
|
|
133
|
+
export function isModelInScope(modelName: string, ctx: EmitterContext): boolean {
|
|
134
|
+
const scope = ctx.scopedModelNames;
|
|
135
|
+
return !scope || scope.has(modelName);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Like {@link isModelInScope} but for an ENUM's per-enum file (`ctx.scopedEnumNames`). */
|
|
139
|
+
export function isEnumInScope(enumName: string, ctx: EmitterContext): boolean {
|
|
140
|
+
const scope = ctx.scopedEnumNames;
|
|
141
|
+
return !scope || scope.has(enumName);
|
|
142
|
+
}
|
|
143
|
+
|
|
97
144
|
/**
|
|
98
145
|
* Get the mount target for an IR service.
|
|
99
146
|
* Checks the first resolved operation that belongs to this service.
|