@workos/oagen-emitters 0.6.5 → 0.6.7

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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BV_wDWDO.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-Bk0xWTQC.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -103,7 +103,14 @@ export const dotnetEmitter: Emitter = {
103
103
  return m;
104
104
  });
105
105
 
106
- const discCtx: DiscriminatorContext = { discriminatorBases, variantToBase };
106
+ // Build a map of base model name → discriminator wire-property name so the
107
+ // model emitter can mark the discriminator field as internal-set.
108
+ const discriminatorProperties = new Map<string, string>();
109
+ for (const [baseName, disc] of modelDiscriminators) {
110
+ discriminatorProperties.set(baseName, disc.property);
111
+ }
112
+
113
+ const discCtx: DiscriminatorContext = { discriminatorBases, variantToBase, discriminatorProperties };
107
114
  const files = generateModels(dotnetModels, c, discCtx);
108
115
 
109
116
  // Generate discriminator converters for oneOf unions with discriminator
@@ -161,8 +168,10 @@ export const dotnetEmitter: Emitter = {
161
168
 
162
169
  // Generate converters for discriminated base models (model-level
163
170
  // discriminators detected by enrichModelsFromSpec, e.g. EventSchema).
164
- // Uses Populate to avoid infinite recursion with the [JsonConverter]
165
- // attribute applied to the base class.
171
+ // ReadJson uses Populate (not Deserialize) to avoid infinite recursion
172
+ // with the [JsonConverter] attribute applied to the base class.
173
+ // CanWrite is false so serialization uses the default path and never
174
+ // re-enters WriteJson.
166
175
  for (const [baseName, disc] of modelDiscriminators) {
167
176
  const baseClass = modelClassName(baseName);
168
177
  const converterName = `${baseClass}DiscriminatorConverter`;
@@ -179,6 +188,8 @@ export const dotnetEmitter: Emitter = {
179
188
  lines.push(` /// </summary>`);
180
189
  lines.push(` public class ${converterName} : Newtonsoft.Json.JsonConverter`);
181
190
  lines.push(' {');
191
+ lines.push(' public override bool CanWrite => false;');
192
+ lines.push('');
182
193
  lines.push(
183
194
  ` public override bool CanConvert(Type objectType) => typeof(${baseClass}).IsAssignableFrom(objectType);`,
184
195
  );
@@ -208,7 +219,9 @@ export const dotnetEmitter: Emitter = {
208
219
  ' public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer)',
209
220
  );
210
221
  lines.push(' {');
211
- lines.push(' serializer.Serialize(writer, value);');
222
+ lines.push(
223
+ ' throw new NotImplementedException("Serialization is handled by the default serializer.");',
224
+ );
212
225
  lines.push(' }');
213
226
  lines.push(' }');
214
227
  lines.push('}');
@@ -31,6 +31,8 @@ export interface DiscriminatorContext {
31
31
  discriminatorBases: Set<string>;
32
32
  /** Maps variant model name → base model name. */
33
33
  variantToBase: Map<string, string>;
34
+ /** Maps base model name → wire name of the discriminator property. */
35
+ discriminatorProperties?: Map<string, string>;
34
36
  }
35
37
 
36
38
  /**
@@ -161,6 +163,12 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
161
163
  let initializer = '';
162
164
  let setterModifier = '';
163
165
 
166
+ // On a discriminated union base, the discriminator property (e.g. "event")
167
+ // should be non-public-settable even though it lacks a single const value
168
+ // (each variant has a different value). Consumers must never mutate it.
169
+ const discProp = isDiscBase ? discCtx?.discriminatorProperties?.get(model.name) : undefined;
170
+ const isDiscriminatorField = discProp !== undefined && field.name === discProp;
171
+
164
172
  if (constInit !== null && !isOptional) {
165
173
  // Discriminator-style single-value enum/literal: emit with a const
166
174
  // initializer and a non-public setter so callers can't drift the
@@ -170,6 +178,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
170
178
  csType = baseType;
171
179
  initializer = ` = ${constInit};`;
172
180
  setterModifier = 'internal ';
181
+ } else if (isDiscriminatorField) {
182
+ // Discriminator property on the base class: varies per variant but
183
+ // should still be non-public-settable so consumers can't change it.
184
+ csType = baseType;
185
+ if (!isAlreadyNullable && !isValueTypeRef(field.type)) {
186
+ initializer = ' = default!;';
187
+ }
188
+ setterModifier = 'internal ';
173
189
  } else if (isOptional) {
174
190
  if (isAlreadyNullable) {
175
191
  csType = baseType;
@@ -219,6 +235,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
219
235
  lines.push(` /// <paramref name="key"/> coerced to <typeparamref name="T"/>, or the default`);
220
236
  lines.push(` /// value when the key is missing or the value is not convertible.`);
221
237
  lines.push(` /// </summary>`);
238
+ if (isDiscBase) {
239
+ lines.push(` /// <remarks>`);
240
+ lines.push(` /// Variant subclasses provide strongly-typed <c>${dict.csName}</c> properties that`);
241
+ lines.push(` /// shadow this dictionary. This accessor is intended for forward-compatible handling`);
242
+ lines.push(` /// of types not yet known to this SDK version. For recognized types, cast to the`);
243
+ lines.push(` /// specific subclass and access its typed <c>${dict.csName}</c> property directly.`);
244
+ lines.push(` /// </remarks>`);
245
+ }
222
246
  lines.push(` /// <typeparam name="T">Expected value type.</typeparam>`);
223
247
  lines.push(` /// <param name="key">The key to look up.</param>`);
224
248
  lines.push(` public T? Get${dict.csName}Attribute<T>(string key)`);
@@ -24,12 +24,11 @@ export const ID_PREFIXES: Record<string, string> = {
24
24
  /**
25
25
  * Generate JSON fixture files for test data.
26
26
  */
27
- export function generateFixtures(spec: {
28
- models: Model[];
29
- enums: Enum[];
30
- services: any[];
31
- }): { path: string; content: string }[] {
32
- if (spec.models.length === 0) return [];
27
+ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; services: any[] }): {
28
+ files: { path: string; content: string }[];
29
+ pathRewrites: Map<string, string>;
30
+ } {
31
+ if (spec.models.length === 0) return { files: [], pathRewrites: new Map() };
33
32
 
34
33
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
35
34
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
@@ -96,7 +95,7 @@ export function generateFixtures(spec: {
96
95
  // Remove duplicate files (they'll reference the canonical)
97
96
  const deduped = files.filter((f) => !pathRewrites.has(f.path));
98
97
 
99
- return deduped;
98
+ return { files: deduped, pathRewrites };
100
99
  }
101
100
 
102
101
  function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
package/src/go/models.ts CHANGED
@@ -166,10 +166,27 @@ function makeOptional(goType: string): string {
166
166
  }
167
167
 
168
168
  function structuralHash(model: Model): string {
169
- return model.fields
169
+ const fieldHash = model.fields
170
170
  .map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`)
171
171
  .sort()
172
172
  .join('|');
173
+ // Include entity domain for CRUD-prefixed models to prevent cross-domain
174
+ // aliasing (e.g. UpdateGroup vs UpdateAuthorizationPermission have identical
175
+ // fields but belong to different API domains and should stay separate types).
176
+ const domain = crudEntityDomain(model.name);
177
+ return domain ? `${domain}::${fieldHash}` : fieldHash;
178
+ }
179
+
180
+ const CRUD_PREFIXES = ['Create', 'Update', 'Delete', 'Get', 'List'];
181
+
182
+ /** Strip CRUD verb prefix to get the entity name, or null if no prefix matches. */
183
+ function crudEntityDomain(name: string): string | null {
184
+ for (const prefix of CRUD_PREFIXES) {
185
+ if (name.startsWith(prefix) && name.length > prefix.length) {
186
+ return name.slice(prefix.length);
187
+ }
188
+ }
189
+ return null;
173
190
  }
174
191
 
175
192
  /** Known acronyms to preserve as single tokens during humanization. */
package/src/go/tests.ts CHANGED
@@ -72,7 +72,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
72
72
  });
73
73
 
74
74
  // Generate fixture JSON files
75
- const fixtures = generateFixtures(spec);
75
+ const { files: fixtures, pathRewrites: fixtureRewrites } = generateFixtures(spec);
76
76
  for (const fixture of fixtures) {
77
77
  files.push({
78
78
  path: fixture.path,
@@ -97,7 +97,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
97
97
  for (const { name: mountName, operations } of testEntries) {
98
98
  if (operations.length === 0) continue;
99
99
  const mergedService: Service = { name: mountName, operations };
100
- const testFile = generateServiceTest(mergedService, spec, ctx, accessPaths);
100
+ const testFile = generateServiceTest(mergedService, spec, ctx, accessPaths, fixtureRewrites);
101
101
  if (testFile) files.push(testFile);
102
102
  }
103
103
 
@@ -109,6 +109,7 @@ function generateServiceTest(
109
109
  spec: ApiSpec,
110
110
  ctx: EmitterContext,
111
111
  _accessPaths: Map<string, string>,
112
+ fixtureRewrites: Map<string, string>,
112
113
  ): GeneratedFile | null {
113
114
  if (service.operations.length === 0) return null;
114
115
 
@@ -213,6 +214,10 @@ function generateServiceTest(
213
214
  }
214
215
  }
215
216
  fixturePath = `testdata/list_${fileName(resolved.name)}.json`;
217
+ // If this fixture was deduplicated, use the canonical path
218
+ if (fixtureRewrites.has(fixturePath)) {
219
+ fixturePath = fixtureRewrites.get(fixturePath)!;
220
+ }
216
221
  }
217
222
  }
218
223
 
@@ -302,7 +307,10 @@ function generateServiceTest(
302
307
  // Success test
303
308
  const respModel = plan.responseModelName;
304
309
  const isArrayResponse = !isPaginated && op.response?.kind === 'array';
305
- const fixturePath = `testdata/${fileName(respModel)}.json`;
310
+ let fixturePath = `testdata/${fileName(respModel)}.json`;
311
+ if (fixtureRewrites.has(fixturePath)) {
312
+ fixturePath = fixtureRewrites.get(fixturePath)!;
313
+ }
306
314
  const expectedPath = buildExpectedPath(op);
307
315
 
308
316
  const httpMethodUpper = op.httpMethod.toUpperCase();
@@ -406,7 +414,10 @@ function generateServiceTest(
406
414
  const wrapperParamsStruct = paramsStructName(resolvedName, wrapperMethod);
407
415
  const responseType = wrapper.responseModelName;
408
416
  const testName = `Test${accessorName}_${wrapperMethod}`;
409
- const fixturePath = responseType ? `testdata/${fileName(responseType)}.json` : null;
417
+ let fixturePath = responseType ? `testdata/${fileName(responseType)}.json` : null;
418
+ if (fixturePath && fixtureRewrites.has(fixturePath)) {
419
+ fixturePath = fixtureRewrites.get(fixturePath)!;
420
+ }
410
421
 
411
422
  const wrapperCallArgs: string[] = ['context.Background()'];
412
423
  for (const p of sortPathParamsByTemplateOrder(op)) {
@@ -163,15 +163,17 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
163
163
  lines.push(importLine);
164
164
  }
165
165
  }
166
- // Non-spec service imports — wrapped in ignore markers so the merger
167
- // matches them positionally and doesn't displace them.
168
- lines.push('');
169
- lines.push('# @oagen-ignore-start non-spec service imports (hand-maintained)');
166
+ // Non-spec service imports — emitted as plain imports (not wrapped in
167
+ // ignore markers). The overwriteWithPreservedRegions() machinery in oagen
168
+ // relocates top-level ignore blocks to EOF because they have no containing
169
+ // class, stripping the markers from the imports while spliceExtraImports()
170
+ // preserves the bare import lines. Emitting them directly avoids the
171
+ // displacement entirely; spliceExtraImports() will preserve any additional
172
+ // hand-added imports from the existing file on subsequent regenerations.
170
173
  for (const s of NON_SPEC_SERVICES) {
171
174
  const w = PYTHON_NON_SPEC_WIRING[s.id];
172
175
  if (w) lines.push(w.importLine);
173
176
  }
174
- lines.push('# @oagen-ignore-end');
175
177
  lines.push('');
176
178
  lines.push('');
177
179
 
@@ -255,4 +255,81 @@ describe('dotnet/models', () => {
255
255
  expect(orgFile).toBeDefined();
256
256
  expect(orgFile.content).toContain('List<OrganizationDomain>');
257
257
  });
258
+
259
+ it('emits internal set on discriminator property of base class', () => {
260
+ const models: Model[] = [
261
+ {
262
+ name: 'EventSchema',
263
+ fields: [
264
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
265
+ { name: 'event', type: { kind: 'primitive', type: 'string' }, required: true },
266
+ {
267
+ name: 'data',
268
+ type: { kind: 'map', valueType: { kind: 'primitive', type: 'unknown' } },
269
+ required: true,
270
+ },
271
+ ],
272
+ },
273
+ {
274
+ name: 'UserCreated',
275
+ fields: [
276
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
277
+ { name: 'event', type: { kind: 'literal', value: 'user.created' }, required: true },
278
+ { name: 'data', type: { kind: 'model', name: 'UserCreatedData' }, required: true },
279
+ ],
280
+ },
281
+ {
282
+ name: 'UserCreatedData',
283
+ fields: [{ name: 'user_id', type: { kind: 'primitive', type: 'string' }, required: true }],
284
+ },
285
+ ];
286
+
287
+ primeEnumAliases([]);
288
+ const discCtx = {
289
+ discriminatorBases: new Set(['EventSchema']),
290
+ variantToBase: new Map([['UserCreated', 'EventSchema']]),
291
+ discriminatorProperties: new Map([['EventSchema', 'event']]),
292
+ };
293
+ const files = generateModels(models, { ...ctx, spec: { ...emptySpec, models } }, discCtx);
294
+
295
+ const baseFile = files.find((f) => f.path.includes('EventSchema.cs'))!;
296
+ expect(baseFile).toBeDefined();
297
+
298
+ // The discriminator property "event" should have internal set
299
+ expect(baseFile.content).toContain('Event { get; internal set; }');
300
+ // Non-discriminator required fields should NOT have internal set
301
+ expect(baseFile.content).toContain('Id { get; set; }');
302
+ });
303
+
304
+ it('adds remarks to dictionary accessors on discriminator base class', () => {
305
+ const models: Model[] = [
306
+ {
307
+ name: 'EventSchema',
308
+ fields: [
309
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
310
+ { name: 'event', type: { kind: 'primitive', type: 'string' }, required: true },
311
+ {
312
+ name: 'data',
313
+ type: { kind: 'map', valueType: { kind: 'primitive', type: 'unknown' } },
314
+ required: true,
315
+ },
316
+ ],
317
+ },
318
+ ];
319
+
320
+ primeEnumAliases([]);
321
+ const discCtx = {
322
+ discriminatorBases: new Set(['EventSchema']),
323
+ variantToBase: new Map<string, string>(),
324
+ discriminatorProperties: new Map([['EventSchema', 'event']]),
325
+ };
326
+ const files = generateModels(models, { ...ctx, spec: { ...emptySpec, models } }, discCtx);
327
+
328
+ const baseFile = files.find((f) => f.path.includes('EventSchema.cs'))!;
329
+ expect(baseFile).toBeDefined();
330
+
331
+ // Dictionary accessors on discriminator bases should have a remarks note
332
+ expect(baseFile.content).toContain('/// <remarks>');
333
+ expect(baseFile.content).toContain('forward-compatible');
334
+ });
258
335
  });