@workos/oagen-emitters 0.14.4 → 0.15.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.
Files changed (43) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-BGVaMGqe.mjs → plugin-CO4RFgAW.mjs} +959 -251
  6. package/dist/plugin-CO4RFgAW.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +7 -7
  9. package/renovate.json +1 -61
  10. package/src/go/client.ts +1 -1
  11. package/src/go/enums.ts +77 -0
  12. package/src/kotlin/enums.ts +11 -4
  13. package/src/node/client.ts +119 -2
  14. package/src/node/discriminated-models.ts +8 -0
  15. package/src/node/field-plan.ts +64 -8
  16. package/src/node/index.ts +59 -3
  17. package/src/node/models.ts +73 -30
  18. package/src/node/naming.ts +14 -1
  19. package/src/node/node-overrides.ts +4 -37
  20. package/src/node/options.ts +29 -1
  21. package/src/node/resources.ts +533 -83
  22. package/src/node/tests.ts +108 -7
  23. package/src/php/fixtures.ts +4 -1
  24. package/src/php/models.ts +3 -1
  25. package/src/php/resources.ts +40 -11
  26. package/src/php/tests.ts +22 -12
  27. package/src/python/client.ts +0 -8
  28. package/src/python/enums.ts +41 -15
  29. package/src/python/fixtures.ts +23 -7
  30. package/src/python/models.ts +26 -5
  31. package/src/python/resources.ts +71 -3
  32. package/src/python/tests.ts +70 -12
  33. package/src/python/wrappers.ts +25 -4
  34. package/src/ruby/client.ts +0 -1
  35. package/src/rust/resources.ts +10 -7
  36. package/src/shared/non-spec-services.ts +0 -5
  37. package/test/go/enums.test.ts +24 -0
  38. package/test/node/resources.test.ts +11 -1
  39. package/test/node/tests.test.ts +3 -3
  40. package/test/php/client.test.ts +0 -1
  41. package/test/php/resources.test.ts +50 -0
  42. package/test/rust/resources.test.ts +9 -0
  43. package/dist/plugin-BGVaMGqe.mjs.map +0 -1
package/src/node/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  setBaselineSerializedNames,
24
24
  setBaselineInterfaceNames,
25
25
  setAdoptedModelNames,
26
+ setDiscriminatedModelNames,
26
27
  setStructurallyRenamedDomainNames,
27
28
  resolveInterfaceName,
28
29
  } from './naming.js';
@@ -341,14 +342,66 @@ function isOwnedPath(relPath: string, policy: LiveSurfacePolicy): boolean {
341
342
  return dir !== undefined && policy.ownedServiceDirs.has(dir);
342
343
  }
343
344
 
345
+ function extractRelativeImportPaths(content: string, fromPath: string): string[] {
346
+ const dir = path.dirname(fromPath);
347
+ const paths: string[] = [];
348
+ const re = /from\s+['"](\.[^'"]+)['"]/g;
349
+ let match: RegExpExecArray | null;
350
+ while ((match = re.exec(content)) !== null) {
351
+ paths.push(path.normalize(path.join(dir, match[1])) + '.ts');
352
+ }
353
+ return paths;
354
+ }
355
+
344
356
  function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface: LiveSurface): GeneratedFile[] {
345
357
  const out: GeneratedFile[] = [];
346
358
  const policy = buildLiveSurfacePolicy(ctx, surface);
359
+ const filesByPath = new Map(files.map((f) => [f.path, f]));
360
+ const dependencyAllowedPaths = new Set<string>();
361
+ const queue: string[] = [];
362
+
363
+ for (const f of files) {
364
+ if (f.integrateTarget === false) continue;
365
+ if (!canCreateNewPath(f.path, policy)) continue;
366
+ for (const importPath of extractRelativeImportPaths(f.content, f.path)) {
367
+ if (
368
+ filesByPath.has(importPath) &&
369
+ !canCreateNewPath(importPath, policy) &&
370
+ !dependencyAllowedPaths.has(importPath)
371
+ ) {
372
+ dependencyAllowedPaths.add(importPath);
373
+ queue.push(importPath);
374
+ }
375
+ }
376
+ }
377
+
378
+ while (queue.length > 0) {
379
+ const relPath = queue.pop()!;
380
+ const file = filesByPath.get(relPath);
381
+ if (!file) continue;
382
+ for (const importPath of extractRelativeImportPaths(file.content, relPath)) {
383
+ if (
384
+ filesByPath.has(importPath) &&
385
+ !canCreateNewPath(importPath, policy) &&
386
+ !dependencyAllowedPaths.has(importPath)
387
+ ) {
388
+ dependencyAllowedPaths.add(importPath);
389
+ queue.push(importPath);
390
+ }
391
+ }
392
+ }
393
+
347
394
  for (const f of files) {
348
395
  const ownedPath = isOwnedPath(f.path, policy);
349
396
  if (f.integrateTarget === false) continue;
350
397
  if (surface.protectedFiles.has(f.path)) continue;
351
- if (policy.hasExistingSdk && !policy.managedPaths.has(f.path) && !canCreateNewPath(f.path, policy)) continue;
398
+ if (
399
+ policy.hasExistingSdk &&
400
+ !policy.managedPaths.has(f.path) &&
401
+ !canCreateNewPath(f.path, policy) &&
402
+ !dependencyAllowedPaths.has(f.path)
403
+ )
404
+ continue;
352
405
 
353
406
  // Hand-written files (on disk, no `auto-generated by oagen` header) →
354
407
  // drop. The engine would otherwise prepend the header on
@@ -372,7 +425,8 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
372
425
  const dir = topLevelDir(f.path);
373
426
  const isAdoptedDir = dir !== undefined && policy.adoptedServiceDirs.has(dir);
374
427
  const isManagedDir = ownedPath || isAdoptedDir;
375
- if (surface.files.has(f.path) && !surface.autogenFiles.has(f.path)) continue;
428
+ if (surface.files.has(f.path) && !surface.autogenFiles.has(f.path) && !(ownedPath && policy.regenerateOwnedTests))
429
+ continue;
376
430
  if (!isManagedDir && !surface.autogenFiles.has(f.path)) continue;
377
431
  if (isManagedDir && !policy.regenerateOwnedTests) continue;
378
432
  }
@@ -487,7 +541,9 @@ export const nodeEmitter: Emitter = {
487
541
  // name set on ctx so models.ts skips emitting an interface/serializer —
488
542
  // the discriminated module owns those paths instead.
489
543
  const discPlans = planDiscriminatedModels(enriched, nodeCtx);
490
- (nodeCtx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames = new Set(discPlans.keys());
544
+ const discriminatedNames = new Set(discPlans.keys());
545
+ (nodeCtx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames = discriminatedNames;
546
+ setDiscriminatedModelNames(discriminatedNames);
491
547
  const standardFiles = generateModelsAndSerializers(enriched, nodeCtx);
492
548
  const discFiles = generateDiscriminatedFiles(discPlans, nodeCtx);
493
549
  return applyLiveSurface([...standardFiles, ...discFiles], nodeCtx, surface);
@@ -223,6 +223,20 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
223
223
  // wrapper's interface importing from a file that was never written.
224
224
  const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
225
225
 
226
+ for (const originalModel of models) {
227
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
228
+ if (!reachableModels.has(model.name)) continue;
229
+ if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
230
+ const service = modelToService.get(model.name);
231
+ const isOwnedModel = isNodeOwnedService(ctx, service);
232
+ if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
233
+ const canonicalName = dedup.get(model.name);
234
+ if (canonicalName) {
235
+ forceGenerate.add(canonicalName);
236
+ if (interfaceEligibleModels) interfaceEligibleModels.add(canonicalName);
237
+ }
238
+ }
239
+
226
240
  for (const originalModel of models) {
227
241
  const model = projectedByName.get(originalModel.name) ?? originalModel;
228
242
  if (!reachableModels.has(model.name)) continue;
@@ -392,6 +406,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
392
406
  const depName = resolveInterfaceName(dep, ctx);
393
407
  const depService = modelToService.get(dep);
394
408
  const depDir = resolveDir(depService);
409
+ const depIsOwned = isNodeOwnedService(ctx, depService);
395
410
 
396
411
  // When the resolver maps the IR name to a different baseline interface
397
412
  // (via `overlayLookup.modelNameByIR` structural match), the import
@@ -400,7 +415,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
400
415
  // emitter never generates — the canonical baseline file is at a
401
416
  // different stem (e.g. `create-audit-log-event-options.interface`).
402
417
  const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
403
- const baselineSrc = (ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
418
+ const baselineSrc = depIsOwned
419
+ ? undefined
420
+ : (ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
404
421
 
405
422
  // Self-reference: the dependency lives in the file we're currently
406
423
  // emitting. Skip the import — it's already in scope.
@@ -697,6 +714,13 @@ export function generateSerializers(
697
714
  const responseReachableModels = resourceUsage
698
715
  ? expandModelRoots(resourceUsage.responseRoots, projectedByName)
699
716
  : undefined;
717
+ // Models reachable from any request — only these need a `serialize<X>`.
718
+ // A model used solely as a response body can safely be deserialize-only;
719
+ // emitting its serialize half is both unused and brittle when it contains
720
+ // legacy nested response models that intentionally have no serialize helper.
721
+ const requestReachableModels = resourceUsage
722
+ ? expandModelRoots(resourceUsage.requestRoots, projectedByName)
723
+ : undefined;
700
724
 
701
725
  const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
702
726
 
@@ -759,6 +783,20 @@ export function generateSerializers(
759
783
  // Mirror the interface-emission gate (see `generateModels`).
760
784
  const serializerListMetadataNeeded = collectReferencedListMetadataModels(models, serializerNonPaginatedRefs);
761
785
 
786
+ for (const originalModel of models) {
787
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
788
+ if (!serializerReachable.has(model.name)) continue;
789
+ if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
790
+ const service = modelToService.get(model.name);
791
+ const isOwnedModel = isNodeOwnedService(ctx, service);
792
+ if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
793
+ const canonicalName = dedup.get(model.name);
794
+ if (canonicalName) {
795
+ forceGenerateSerializer.add(canonicalName);
796
+ if (serializerEligibleModels) serializerEligibleModels.add(canonicalName);
797
+ }
798
+ }
799
+
762
800
  const eligibleModels: Model[] = [];
763
801
  for (const originalModel of models) {
764
802
  const model = projectedByName.get(originalModel.name) ?? originalModel;
@@ -781,14 +819,9 @@ export function generateSerializers(
781
819
  const responseName = wireInterfaceName(domainName);
782
820
  const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
783
821
  const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
784
- const shouldSkip = shouldSkipSerializeForModel(
785
- model,
786
- baselineResponse,
787
- baselineDomain,
788
- dedup,
789
- skippedSerializeModels,
790
- ctx,
791
- );
822
+ const shouldSkip =
823
+ (requestReachableModels !== undefined && !requestReachableModels.has(model.name)) ||
824
+ shouldSkipSerializeForModel(model, baselineResponse, baselineDomain, dedup, skippedSerializeModels, ctx);
792
825
  if (shouldSkip) {
793
826
  skippedSerializeModels.add(model.name);
794
827
  }
@@ -812,27 +845,37 @@ export function generateSerializers(
812
845
 
813
846
  if (serializerPath === canonSerializerPath) continue;
814
847
  if (domainName === canonDomainName) continue;
815
- const rel = relativeImport(serializerPath, canonSerializerPath);
816
- const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
817
- const canonSkipDeserialize =
818
- responseReachableModels !== undefined &&
819
- !responseReachableModels.has(canonicalName) &&
820
- !responseReachableModels.has(model.name);
821
- if (canonSkipSerialize && canonSkipDeserialize) continue;
822
- const parts: string[] = [];
823
- if (!canonSkipDeserialize) {
824
- parts.push(`deserialize${canonDomainName} as deserialize${domainName}`);
825
- }
826
- if (!canonSkipSerialize) {
827
- parts.push(`serialize${canonDomainName} as serialize${domainName}`);
848
+
849
+ const aliasNeedsDeserialize = responseReachableModels === undefined || responseReachableModels.has(model.name);
850
+ const canonicalHasDeserialize =
851
+ responseReachableModels === undefined || responseReachableModels.has(canonicalName);
852
+ const canAliasToCanonical = !aliasNeedsDeserialize || canonicalHasDeserialize;
853
+ if (canAliasToCanonical) {
854
+ const rel = relativeImport(serializerPath, canonSerializerPath);
855
+ const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
856
+ const canonSkipDeserialize =
857
+ responseReachableModels !== undefined &&
858
+ !responseReachableModels.has(canonicalName) &&
859
+ !responseReachableModels.has(model.name);
860
+ if (canonSkipSerialize && canonSkipDeserialize) continue;
861
+ const parts: string[] = [];
862
+ if (!canonSkipDeserialize) {
863
+ parts.push(`deserialize${canonDomainName} as deserialize${domainName}`);
864
+ }
865
+ if (!canonSkipSerialize) {
866
+ parts.push(`serialize${canonDomainName} as serialize${domainName}`);
867
+ }
868
+ const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
869
+ files.push({
870
+ path: serializerPath,
871
+ content: reexportContent,
872
+ overwriteExisting: true,
873
+ });
874
+ continue;
828
875
  }
829
- const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
830
- files.push({
831
- path: serializerPath,
832
- content: reexportContent,
833
- overwriteExisting: true,
834
- });
835
- continue;
876
+ // The alias is response-reachable, but the canonical model is
877
+ // request-only. Generate a local serializer instead of re-exporting a
878
+ // deserialize helper that the canonical serializer intentionally omits.
836
879
  }
837
880
 
838
881
  const dirName = resolveDir(service);
@@ -906,7 +949,7 @@ export function generateSerializers(
906
949
  }
907
950
  const liveRootForBarrel = ctx.outputDir ?? ctx.targetDir;
908
951
  for (const [dir, stems] of serializersByDir) {
909
- if (liveRootForBarrel) {
952
+ if (liveRootForBarrel && !isNodeOwnedService(ctx, dir)) {
910
953
  const serializersDir = path.join(liveRootForBarrel, 'src', dir, 'serializers');
911
954
  try {
912
955
  for (const entry of fs.readdirSync(serializersDir)) {
@@ -73,6 +73,16 @@ export function isAdoptedModelName(name: string): boolean {
73
73
  return adoptedModelNames.has(name);
74
74
  }
75
75
 
76
+ /**
77
+ * IR model names handled by the discriminated-models module. These must not
78
+ * be remapped by the structural matcher because that module emits files and
79
+ * helpers under the original IR names.
80
+ */
81
+ let discriminatedModelNames: Set<string> = new Set();
82
+ export function setDiscriminatedModelNames(names: Set<string>): void {
83
+ discriminatedModelNames = names;
84
+ }
85
+
76
86
  /**
77
87
  * Domain names that `resolveInterfaceName` reached via a structural rename
78
88
  * — the resolved name differs from the IR model's own name. `wireInterfaceName`
@@ -226,7 +236,10 @@ export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: {
226
236
  const existing = ctx.overlayLookup?.interfaceByName?.get(name);
227
237
  if (existing) return existing;
228
238
 
229
- let inferred = adoptedModelNames.has(name) ? undefined : ctx.overlayLookup?.modelNameByIR?.get(name);
239
+ let inferred =
240
+ adoptedModelNames.has(name) || discriminatedModelNames.has(name)
241
+ ? undefined
242
+ : ctx.overlayLookup?.modelNameByIR?.get(name);
230
243
  if (inferred) {
231
244
  if (inferred.startsWith('Serialized')) {
232
245
  const stripped = inferred.slice('Serialized'.length);
@@ -1,41 +1,6 @@
1
1
  import type { EmitterContext, Model, ResolvedOperation } from '@workos/oagen';
2
2
  import { enrichModelsFromSpec } from '../shared/model-utils.js';
3
-
4
- type OperationOverride = {
5
- methodName?: string;
6
- mountOn?: string;
7
- };
8
-
9
- const OPERATION_OVERRIDES: Record<string, OperationOverride> = {
10
- 'POST /organizations/{organizationId}/groups': {
11
- methodName: 'create_group',
12
- },
13
- 'GET /organizations/{organizationId}/groups': {
14
- methodName: 'list_groups',
15
- },
16
- 'GET /organizations/{organizationId}/groups/{groupId}': {
17
- methodName: 'get_group',
18
- },
19
- 'PATCH /organizations/{organizationId}/groups/{groupId}': {
20
- methodName: 'update_group',
21
- },
22
- 'DELETE /organizations/{organizationId}/groups/{groupId}': {
23
- methodName: 'delete_group',
24
- },
25
- 'POST /organizations/{organizationId}/groups/{groupId}/organization-memberships': {
26
- methodName: 'add_organization_membership',
27
- },
28
- 'GET /organizations/{organizationId}/groups/{groupId}/organization-memberships': {
29
- methodName: 'list_organization_memberships',
30
- },
31
- 'DELETE /organizations/{organizationId}/groups/{groupId}/organization-memberships/{omId}': {
32
- methodName: 'remove_organization_membership',
33
- },
34
- 'GET /user_management/organization_memberships/{omId}/groups': {
35
- methodName: 'list_groups_for_organization_membership',
36
- mountOn: 'UserManagement',
37
- },
38
- };
3
+ import { nodeOptions } from './options.js';
39
4
 
40
5
  const contextCache = new WeakMap<EmitterContext, EmitterContext>();
41
6
 
@@ -88,9 +53,11 @@ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext
88
53
  return next;
89
54
  }
90
55
 
56
+ const configOverrides = nodeOptions(ctx).operationOverrides ?? {};
57
+
91
58
  let opsChanged = false;
92
59
  const nextResolved = resolvedOperations.map((resolved) => {
93
- const override = OPERATION_OVERRIDES[operationKey(resolved)];
60
+ const override = configOverrides[operationKey(resolved)];
94
61
  if (!override) return resolved;
95
62
 
96
63
  const methodName = override.methodName ?? resolved.methodName;
@@ -1,5 +1,16 @@
1
1
  import type { EmitterContext } from '@workos/oagen';
2
2
 
3
+ export interface OperationOverride {
4
+ methodName?: string;
5
+ mountOn?: string;
6
+ optionsType?: string;
7
+ bodyFieldMap?: Record<string, string>;
8
+ returnType?: string;
9
+ returnDataProperty?: string;
10
+ returnTypeImports?: string[];
11
+ returnExpression?: string;
12
+ }
13
+
3
14
  export interface NodeEmitterOptions {
4
15
  /**
5
16
  * Existing SDK mode normally drops brand-new paths to avoid large accidental
@@ -21,6 +32,12 @@ export interface NodeEmitterOptions {
21
32
  * and fixtures remain hand-owned.
22
33
  */
23
34
  regenerateOwnedTests?: boolean;
35
+ /**
36
+ * Node-specific operation overrides keyed by "METHOD /path".
37
+ * Allows renaming methods or remounting operations for the Node SDK
38
+ * without affecting other languages.
39
+ */
40
+ operationOverrides?: Record<string, OperationOverride>;
24
41
  }
25
42
 
26
43
  export function nodeOptions(ctx: EmitterContext): NodeEmitterOptions {
@@ -32,10 +49,21 @@ function normalizeServiceName(name: string): string {
32
49
  return name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
33
50
  }
34
51
 
52
+ function ownedLookupNames(name: string): string[] {
53
+ const names = [name];
54
+ const baselineDirPrefix = '__baseline_dir__:';
55
+ if (name.startsWith(baselineDirPrefix)) {
56
+ names.push(name.slice(baselineDirPrefix.length));
57
+ }
58
+ return names;
59
+ }
60
+
35
61
  export function isNodeOwnedService(ctx: EmitterContext, ...names: Array<string | undefined>): boolean {
36
62
  const configured = nodeOptions(ctx).ownedServices ?? [];
37
63
  if (configured.length === 0) return false;
38
64
 
39
65
  const owned = new Set(configured.map(normalizeServiceName));
40
- return names.some((name) => name !== undefined && owned.has(normalizeServiceName(name)));
66
+ return names.some((name) =>
67
+ name !== undefined ? ownedLookupNames(name).some((candidate) => owned.has(normalizeServiceName(candidate))) : false,
68
+ );
41
69
  }