@workos/oagen-emitters 0.12.5 → 0.14.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/src/node/index.ts CHANGED
@@ -16,6 +16,8 @@ import { generateEnums as generateEnumFiles } from './enums.js';
16
16
  import { generateResources, resolveResourceClassName, resolveResourceDir } from './resources.js';
17
17
  import { generateClient } from './client.js';
18
18
  import { generateTests as generateTestFiles } from './tests.js';
19
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
20
+ import { planDiscriminatedModels, generateDiscriminatedFiles } from './discriminated-models.js';
19
21
  import { buildLiveSurface, emptyLiveSurface, setActiveLiveSurface, type LiveSurface } from './live-surface.js';
20
22
  import {
21
23
  setBaselineSerializedNames,
@@ -214,7 +216,10 @@ function computeOwnedServiceDirs(ctx: EmitterContext): Set<string> {
214
216
  const mountGroups = groupByMount(ctx);
215
217
  const services =
216
218
  mountGroups.size > 0
217
- ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
219
+ ? [...mountGroups].map(([name, group]) => ({
220
+ name,
221
+ operations: group.operations,
222
+ }))
218
223
  : ctx.spec.services;
219
224
  const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
220
225
 
@@ -233,7 +238,10 @@ function computeAdoptedServiceDirs(ctx: EmitterContext, surface: LiveSurface): S
233
238
  const mountGroups = groupByMount(ctx);
234
239
  const services =
235
240
  mountGroups.size > 0
236
- ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
241
+ ? [...mountGroups].map(([name, group]) => ({
242
+ name,
243
+ operations: group.operations,
244
+ }))
237
245
  : ctx.spec.services;
238
246
  const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
239
247
 
@@ -350,19 +358,58 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
350
358
  return out;
351
359
  }
352
360
 
361
+ /**
362
+ * Flatten oneOf / allOf+oneOf variant fields from the raw spec onto each
363
+ * model. `enrichModelsFromSpec` produces (a) extra optional fields on models
364
+ * whose schema is `allOf [base, oneOf [...]]`, and (b) synthetic models /
365
+ * enums for inline objects encountered inside variants (e.g. the inline
366
+ * `redirect_uris` item shape on `ConnectApplication`).
367
+ *
368
+ * Node, like Go / Kotlin / .NET, emits flat interfaces rather than a sum
369
+ * type, so on `enrichModelsFromSpec`-marked discriminated bases we restore
370
+ * the original IR fields — otherwise the base interface would be empty.
371
+ * A future change can emit a real TS discriminated union; for now the goal
372
+ * is parity with the other flat-emit languages so every variant field is
373
+ * at least reachable.
374
+ */
375
+ function enrichModelsForNode(models: Model[]): Model[] {
376
+ const enriched = enrichModelsFromSpec(models);
377
+ const originalByName = new Map(models.map((m) => [m.name, m]));
378
+ return enriched.map((m) => {
379
+ if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
380
+ const original = originalByName.get(m.name);
381
+ if (original && original.fields.length > 0) {
382
+ return { ...m, fields: original.fields };
383
+ }
384
+ }
385
+ return m;
386
+ });
387
+ }
388
+
353
389
  export const nodeEmitter: Emitter = {
354
390
  language: 'node',
355
391
 
356
392
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
357
393
  const nodeCtx = withNodeOperationOverrides(ctx);
358
394
  const surface = getSurface(nodeCtx);
359
- return applyLiveSurface(generateModelsAndSerializers(models, nodeCtx), nodeCtx, surface);
395
+ const enriched = enrichModelsForNode(models);
396
+ // Detect `allOf [base, oneOf [variant, …]]` schemas and hand them off
397
+ // to the discriminated-models module. Leave the model in the standard
398
+ // pipeline's input so its field-type deps stay reachable, but stash the
399
+ // name set on ctx so models.ts skips emitting an interface/serializer —
400
+ // the discriminated module owns those paths instead.
401
+ const discPlans = planDiscriminatedModels(enriched, nodeCtx);
402
+ (nodeCtx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames = new Set(discPlans.keys());
403
+ const standardFiles = generateModelsAndSerializers(enriched, nodeCtx);
404
+ const discFiles = generateDiscriminatedFiles(discPlans, nodeCtx);
405
+ return applyLiveSurface([...standardFiles, ...discFiles], nodeCtx, surface);
360
406
  },
361
407
 
362
408
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
363
409
  const nodeCtx = withNodeOperationOverrides(ctx);
364
410
  const surface = getSurface(nodeCtx);
365
- return applyLiveSurface(generateEnumFiles(enums, nodeCtx), nodeCtx, surface);
411
+ const syntheticEnums = getSyntheticEnums();
412
+ return applyLiveSurface(generateEnumFiles([...enums, ...syntheticEnums], nodeCtx), nodeCtx, surface);
366
413
  },
367
414
 
368
415
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
@@ -374,7 +421,11 @@ export const nodeEmitter: Emitter = {
374
421
  generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
375
422
  const nodeCtx = withNodeOperationOverrides(ctx);
376
423
  const surface = getSurface(nodeCtx);
377
- return applyLiveSurface(generateClient(spec, nodeCtx), nodeCtx, surface);
424
+ // `nodeCtx.spec` has the synthetic models that `enrichModelsFromSpec`
425
+ // produced (e.g. inline-object item types like `ConnectApplicationRedirectUri`).
426
+ // The `spec` param is the engine's pre-enrichment spec, so the barrel
427
+ // generator would miss those synthetic interfaces. Use the enriched one.
428
+ return applyLiveSurface(generateClient(nodeCtx.spec, nodeCtx), nodeCtx, surface);
378
429
  },
379
430
 
380
431
  // workos-node ships its own exception hierarchy under src/common/exceptions/.
@@ -1,6 +1,5 @@
1
1
  import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
2
- import { resolveMethodName, servicePropertyName } from './naming.js';
3
- import { resolveResourceClassName } from './resources.js';
2
+ import { resolveMethodName, servicePropertyName, resolveServiceName } from './naming.js';
4
3
  import { buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
5
4
 
6
5
  export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
@@ -8,7 +7,10 @@ export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): Operatio
8
7
  const resolvedLookup = buildResolvedLookup(ctx);
9
8
 
10
9
  for (const service of spec.services) {
11
- const serviceProp = servicePropertyName(resolveResourceClassName(service, ctx));
10
+ // Accessor name reflects the un-suffixed service mount target so the
11
+ // manifest matches `client.organizationMembership` (not the suffixed
12
+ // class name used to dodge model collisions).
13
+ const serviceProp = servicePropertyName(resolveServiceName(service, ctx));
12
14
  for (const op of service.operations) {
13
15
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
14
16
  const method = resolveMethodName(op, service, ctx);
@@ -130,6 +130,13 @@ function isSupportedFieldType(
130
130
  // silently dropped on first emission because the target interface
131
131
  // (`UserObject` under the adopted `connect/` dir) hasn't landed yet.
132
132
  if (isAdoptedModelName(ref.name)) return true;
133
+ // Synthetic models produced by `enrichModelsFromSpec` (e.g. the
134
+ // inline-object item type for `ConnectApplication.redirect_uris`)
135
+ // are added to the models list passed into this generation pass —
136
+ // and hence into `shared.modelToService` — but won't yet exist on
137
+ // disk or in `apiSurface`. Accept them so their parent field
138
+ // survives field-projection.
139
+ if (shared.modelToService.has(ref.name)) return true;
133
140
  const relPath = `src/${shared.resolveDir(shared.modelToService.get(ref.name))}/interfaces/${fileName(ref.name)}.interface.ts`;
134
141
  return liveSurfaceHasManagedFile(relPath);
135
142
  }
@@ -201,12 +208,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
201
208
  }
202
209
  }
203
210
 
211
+ const discriminatedSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
204
212
  for (const originalModel of models) {
205
213
  const model = projectedByName.get(originalModel.name) ?? originalModel;
206
214
  if (!reachableModels.has(model.name)) continue;
207
215
  if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
208
216
  if (isListMetadataModel(model)) continue;
209
217
  if (isListWrapperModel(model)) continue;
218
+ if (discriminatedSkip?.has(model.name)) continue;
210
219
  const service = modelToService.get(model.name);
211
220
  const isOwnedModel = isNodeOwnedService(ctx, service);
212
221
  if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
@@ -723,6 +732,7 @@ export function generateSerializers(
723
732
  }
724
733
  }
725
734
 
735
+ const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
726
736
  const eligibleModels: Model[] = [];
727
737
  for (const originalModel of models) {
728
738
  const model = projectedByName.get(originalModel.name) ?? originalModel;
@@ -730,6 +740,7 @@ export function generateSerializers(
730
740
  if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
731
741
  if (isListMetadataModel(model)) continue;
732
742
  if (isListWrapperModel(model)) continue;
743
+ if (discriminatedSerializerSkip?.has(model.name)) continue;
733
744
  const service = modelToService.get(model.name);
734
745
  const isOwnedModel = isNodeOwnedService(ctx, service);
735
746
  if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
@@ -928,7 +939,10 @@ function buildGeneratedResourceModelUsage(
928
939
  const mountGroups = groupByMount(ctx);
929
940
  const services: Service[] =
930
941
  mountGroups.size > 0
931
- ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
942
+ ? [...mountGroups].map(([name, group]) => ({
943
+ name,
944
+ operations: group.operations,
945
+ }))
932
946
  : ctx.spec.services;
933
947
 
934
948
  for (const service of services) {
@@ -2,6 +2,10 @@ import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix } from '../shared/naming-utils.js';
5
+ import {
6
+ buildExportedClassNameSet as buildExportedClassNameSetShared,
7
+ resolveServiceTarget as resolveServiceTargetShared,
8
+ } from '../shared/service-name-collision.js';
5
9
 
6
10
  /** Strip spec-noise suffixes (e.g., "Dto") from an IR name. */
7
11
  export function stripNoiseSuffixes(name: string): string {
@@ -117,6 +121,26 @@ export function resolveServiceName(service: Service, ctx: EmitterContext): strin
117
121
  return resolveClassName(service, ctx);
118
122
  }
119
123
 
124
+ /**
125
+ * Build the set of model + enum class names exported by the SDK. Used to
126
+ * detect collisions with operation-client class names — a colliding service
127
+ * gets a `Service` suffix appended.
128
+ */
129
+ export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
130
+ return buildExportedClassNameSetShared(ctx, className);
131
+ }
132
+
133
+ /**
134
+ * Resolve a service's mount-target identifier, appending `Service` on
135
+ * collision with an exported model/enum class. The result feeds `className`
136
+ * and `fileName` so both the `export class` declaration and its file name
137
+ * stay aligned (e.g. `OrganizationMembershipService` /
138
+ * `organization-membership-service.ts`).
139
+ */
140
+ export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
141
+ return resolveServiceTargetShared(target, exportedClasses, className);
142
+ }
143
+
120
144
  /**
121
145
  * Build a map from IR service name -> resolved service name.
122
146
  */
@@ -1,4 +1,5 @@
1
- import type { EmitterContext, ResolvedOperation } from '@workos/oagen';
1
+ import type { EmitterContext, Model, ResolvedOperation } from '@workos/oagen';
2
+ import { enrichModelsFromSpec } from '../shared/model-utils.js';
2
3
 
3
4
  type OperationOverride = {
4
5
  methodName?: string;
@@ -42,17 +43,52 @@ function operationKey(resolved: ResolvedOperation): string {
42
43
  return `${resolved.operation.httpMethod.toUpperCase()} ${resolved.operation.path}`;
43
44
  }
44
45
 
46
+ /**
47
+ * Apply oneOf / allOf+oneOf enrichment (flattening variant fields onto the
48
+ * parent model, plus synthetic models/enums for inline shapes) so the rest
49
+ * of the Node emitter sees a richer `spec.models`.
50
+ *
51
+ * Without this, `ConnectApplication` (and any other `allOf [base, oneOf [...]]`
52
+ * schema whose first variant is itself wrapped in `allOf`) loses every
53
+ * non-M2M field — the IR parser's discriminator detection silently skips
54
+ * variants whose properties live behind another `allOf`. Mirrors what the
55
+ * Go / Kotlin / .NET emitters already do.
56
+ *
57
+ * Discriminated bases produced by `enrichModelsFromSpec` get their original
58
+ * fields restored — Node emits flat interfaces today, not TS sum types, so
59
+ * an empty base would otherwise drop the common fields.
60
+ */
61
+ function enrichSpecModels(models: readonly Model[]): Model[] {
62
+ const enriched = enrichModelsFromSpec(models as Model[]);
63
+ const originalByName = new Map(models.map((m) => [m.name, m]));
64
+ return enriched.map((m) => {
65
+ if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
66
+ const original = originalByName.get(m.name);
67
+ if (original && original.fields.length > 0) {
68
+ return { ...m, fields: original.fields };
69
+ }
70
+ }
71
+ return m;
72
+ });
73
+ }
74
+
45
75
  export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext {
46
76
  const cached = contextCache.get(ctx);
47
77
  if (cached) return cached;
48
78
 
79
+ const enrichedModels = enrichSpecModels(ctx.spec.models);
80
+ const specChanged =
81
+ enrichedModels.length !== ctx.spec.models.length || enrichedModels.some((m, i) => m !== ctx.spec.models[i]);
82
+ const enrichedSpec = specChanged ? { ...ctx.spec, models: enrichedModels } : ctx.spec;
83
+
49
84
  const resolvedOperations = ctx.resolvedOperations;
50
85
  if (!resolvedOperations?.length) {
51
- contextCache.set(ctx, ctx);
52
- return ctx;
86
+ const next = specChanged ? { ...ctx, spec: enrichedSpec } : ctx;
87
+ contextCache.set(ctx, next);
88
+ return next;
53
89
  }
54
90
 
55
- let changed = false;
91
+ let opsChanged = false;
56
92
  const nextResolved = resolvedOperations.map((resolved) => {
57
93
  const override = OPERATION_OVERRIDES[operationKey(resolved)];
58
94
  if (!override) return resolved;
@@ -63,7 +99,7 @@ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext
63
99
  return resolved;
64
100
  }
65
101
 
66
- changed = true;
102
+ opsChanged = true;
67
103
  return {
68
104
  ...resolved,
69
105
  methodName,
@@ -71,7 +107,14 @@ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext
71
107
  };
72
108
  });
73
109
 
74
- const next = changed ? { ...ctx, resolvedOperations: nextResolved } : ctx;
110
+ const next =
111
+ opsChanged || specChanged
112
+ ? {
113
+ ...ctx,
114
+ ...(opsChanged ? { resolvedOperations: nextResolved } : {}),
115
+ ...(specChanged ? { spec: enrichedSpec } : {}),
116
+ }
117
+ : ctx;
75
118
  contextCache.set(ctx, next);
76
119
  return next;
77
120
  }
@@ -53,6 +53,8 @@ import {
53
53
  resolveInterfaceName,
54
54
  resolveServiceName,
55
55
  wireInterfaceName,
56
+ buildExportedClassNameSet,
57
+ resolveServiceTarget,
56
58
  } from './naming.js';
57
59
  import {
58
60
  docComment,
@@ -102,21 +104,31 @@ export function hasCompatibleConstructor(className: string, ctx: EmitterContext)
102
104
  */
103
105
  export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
104
106
  const overlayName = resolveServiceName(service, ctx);
107
+ let base: string;
105
108
  if (hasCompatibleConstructor(overlayName, ctx)) {
106
- return overlayName;
107
- }
108
- // Incompatible constructor — fall back to IR name
109
- const irName = toPascalCase(service.name);
110
- if (irName === overlayName) {
111
- return irName + 'Endpoints';
112
- }
113
- return irName;
109
+ base = overlayName;
110
+ } else {
111
+ // Incompatible constructor — fall back to IR name, with `Endpoints` suffix
112
+ // if it would collide with the overlay name.
113
+ const irName = toPascalCase(service.name);
114
+ base = irName === overlayName ? `${irName}Endpoints` : irName;
115
+ }
116
+ // Cross-language `Service` suffix when the chosen class name would shadow
117
+ // an exported model/enum (e.g. tag `OrganizationMembership` + schema
118
+ // `OrganizationMembership`).
119
+ return resolveServiceTarget(base, buildExportedClassNameSet(ctx));
114
120
  }
115
121
 
116
122
  export function resolveResourceDir(service: Service, ctx: EmitterContext): string {
117
123
  const resolvedName = resolveResourceClassName(service, ctx);
118
124
  if (resolvedName === 'WebhooksEndpoints') return 'webhooks';
119
- return resolveServiceDir(resolvedName);
125
+ // Drop the collision-`Service` suffix when picking the directory so the
126
+ // resource and its model-interfaces share a folder (e.g.
127
+ // `organization-membership/` houses both `OrganizationMembershipService`
128
+ // and `OrganizationMembership`'s interface files).
129
+ const overlayName = resolveServiceName(service, ctx);
130
+ const dirBase = resolvedName === `${overlayName}Service` ? overlayName : resolvedName;
131
+ return resolveServiceDir(dirBase);
120
132
  }
121
133
 
122
134
  /** Standard pagination query params handled by PaginationOptions — not imported individually. */
package/src/node/tests.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  servicePropertyName,
12
12
  resolveMethodName,
13
13
  resolveInterfaceName,
14
+ resolveServiceName,
14
15
  wireInterfaceName,
15
16
  } from './naming.js';
16
17
  import { generateFixtures } from './fixtures.js';
@@ -142,7 +143,9 @@ function generateServiceTest(
142
143
  const resolvedName = resolveResourceClassName(service, ctx);
143
144
  const serviceDir = resolveResourceDir(service, ctx);
144
145
  const serviceClass = resolvedName;
145
- const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
146
+ // Accessor stays un-suffixed so `client.organizationMembership` resolves even
147
+ // when the class was renamed to dodge a model-name collision.
148
+ const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolveServiceName(service, ctx));
146
149
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
147
150
 
148
151
  const plans = service.operations.map((op) => ({
package/src/php/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ApiSpec, Service, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase } from '@workos/oagen';
3
- import { className, servicePropertyName } from './naming.js';
3
+ import { className, servicePropertyName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
4
4
  import { getMountTarget } from '../shared/resolved-ops.js';
5
5
  import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
6
6
 
@@ -61,11 +61,12 @@ export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext
61
61
 
62
62
  function deduplicateByMount(services: Service[], ctx: EmitterContext): { name: string; propName: string }[] {
63
63
  const seen = new Map<string, { name: string; propName: string }>();
64
+ const exportedClasses = buildExportedClassNameSet(ctx);
64
65
  for (const service of services) {
65
66
  const target = getMountTarget(service, ctx);
66
67
  if (!seen.has(target)) {
67
68
  seen.set(target, {
68
- name: className(target),
69
+ name: className(resolveServiceTarget(target, exportedClasses)),
69
70
  propName: servicePropertyName(target),
70
71
  });
71
72
  }
package/src/php/index.ts CHANGED
@@ -18,6 +18,7 @@ import { generateClient } from './client.js';
18
18
  import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { initializeEnumDedup } from './naming.js';
21
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
22
 
22
23
  /** Initialize enum deduplication from spec data. */
23
24
  function ensureNamingInitialized(ctx: EmitterContext): void {
@@ -34,17 +35,39 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
34
35
  return files;
35
36
  }
36
37
 
38
+ /**
39
+ * Flatten oneOf / allOf+oneOf variant fields onto each base model and pull
40
+ * in synthetic models / enums for inline variant shapes. PHP emits flat
41
+ * classes (no sum types), so a discriminated base whose IR fields the
42
+ * parser stripped (post-allOf-aware detection) gets its original fields
43
+ * restored to avoid silently dropping variant data.
44
+ */
45
+ function enrichModelsForPhp(models: Model[]): Model[] {
46
+ const enriched = enrichModelsFromSpec(models);
47
+ const originalByName = new Map(models.map((m) => [m.name, m]));
48
+ return enriched.map((m) => {
49
+ if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
50
+ const original = originalByName.get(m.name);
51
+ if (original && original.fields.length > 0) {
52
+ return { ...m, fields: original.fields };
53
+ }
54
+ }
55
+ return m;
56
+ });
57
+ }
58
+
37
59
  export const phpEmitter: Emitter = {
38
60
  language: 'php',
39
61
 
40
62
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
41
63
  ensureNamingInitialized(ctx);
42
- return ensureTrailingNewlines(generateModels(models, ctx));
64
+ return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models), ctx));
43
65
  },
44
66
 
45
67
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
46
68
  ensureNamingInitialized(ctx);
47
- return ensureTrailingNewlines(generateEnums(enums, ctx));
69
+ const syntheticEnums = getSyntheticEnums();
70
+ return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
48
71
  },
49
72
 
50
73
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
package/src/php/naming.ts CHANGED
@@ -2,6 +2,10 @@ import type { Service, Operation, EmitterContext, Enum } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
5
+ import {
6
+ buildExportedClassNameSet as buildExportedClassNameSetShared,
7
+ resolveServiceTarget as resolveServiceTargetShared,
8
+ } from '../shared/service-name-collision.js';
5
9
 
6
10
  /** Namespace grouping result (shared with client.ts). */
7
11
  export interface NamespaceGroup {
@@ -90,6 +94,24 @@ export function className(name: string): string {
90
94
  return result;
91
95
  }
92
96
 
97
+ /**
98
+ * Build the set of model + enum class names exported by the SDK. Used to
99
+ * detect collisions with operation-client class names — a colliding service
100
+ * gets a `Service` suffix appended.
101
+ */
102
+ export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
103
+ return buildExportedClassNameSetShared(ctx, className);
104
+ }
105
+
106
+ /**
107
+ * Resolve a service's mount-target identifier, appending `Service` on
108
+ * collision with an exported model/enum class. Used in `\Service\…` files
109
+ * to avoid `use WorkOS\Resource\X; class X` PHP fatal errors.
110
+ */
111
+ export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
112
+ return resolveServiceTargetShared(target, exportedClasses, className);
113
+ }
114
+
93
115
  /** PascalCase file name (without extension) — PSR-4 convention. */
94
116
  export function fileName(name: string): string {
95
117
  return className(name);
@@ -1,7 +1,7 @@
1
1
  import type { Service, Operation, Model, EmitterContext, GeneratedFile, ResolvedOperation } from '@workos/oagen';
2
2
  import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
3
3
  import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
4
- import { className, fieldName, resolveMethodName } from './naming.js';
4
+ import { className, fieldName, resolveMethodName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
5
5
  import { isListWrapperModel } from './models.js';
6
6
  import {
7
7
  groupByMount,
@@ -43,9 +43,10 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
43
43
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
44
44
  : services.map((s) => ({ name: className(s.name), operations: s.operations }));
45
45
 
46
+ const exportedClasses = buildExportedClassNameSet(ctx);
46
47
  for (const { name: mountName, operations } of entries) {
47
48
  if (operations.length === 0) continue;
48
- const resourceName = className(mountName);
49
+ const resourceName = className(resolveServiceTarget(mountName, exportedClasses));
49
50
  const mergedService: Service = { name: mountName, operations };
50
51
  const lines: string[] = [];
51
52
 
@@ -1,6 +1,14 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
2
  import { toPascalCase } from '@workos/oagen';
3
- import { className, resolveServiceDir, servicePropertyName, buildMountDirMap, dirToModule } from './naming.js';
3
+ import {
4
+ className,
5
+ resolveServiceDir,
6
+ servicePropertyName,
7
+ buildMountDirMap,
8
+ dirToModule,
9
+ buildExportedClassNameSet,
10
+ resolveServiceTarget,
11
+ } from './naming.js';
4
12
  import { resolveResourceClassName, collectParameterGroupClassNames } from './resources.js';
5
13
  import { getMountTarget, groupByMount } from '../shared/resolved-ops.js';
6
14
  import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
@@ -149,9 +157,10 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
149
157
 
150
158
  // Import resource classes (both sync and async)
151
159
  const serviceDirMap = buildMountDirMap(ctx);
160
+ const exportedClasses = buildExportedClassNameSet(ctx);
152
161
  for (const service of topLevelServices) {
153
162
  const resolvedName = resolveResourceClassName(service, ctx);
154
- const clsName = className(resolvedName);
163
+ const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
155
164
  const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
156
165
  const importLine = `from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`;
157
166
  if (importLine.length > 88) {
@@ -185,7 +194,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
185
194
  const generatedProps = new Set<string>();
186
195
  for (const service of topLevelServices) {
187
196
  const resolvedName = resolveResourceClassName(service, ctx);
188
- const clsName = className(resolvedName);
197
+ const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
189
198
  const prop = servicePropertyName(resolvedName);
190
199
  const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
191
200
  lines.push('');
@@ -208,7 +217,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
208
217
  const asyncGeneratedProps = new Set<string>();
209
218
  for (const service of topLevelServices) {
210
219
  const resolvedName = resolveResourceClassName(service, ctx);
211
- const clsName = className(resolvedName);
220
+ const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
212
221
  const prop = servicePropertyName(resolvedName);
213
222
  const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
214
223
  lines.push('');
@@ -255,6 +264,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
255
264
  const files: GeneratedFile[] = [];
256
265
  const topLevel = deduplicateByMount(spec.services, ctx);
257
266
  const serviceDirMap = buildMountDirMap(ctx);
267
+ const exportedClasses = buildExportedClassNameSet(ctx);
258
268
 
259
269
  // Build a map from mount target -> operations so we can discover parameter
260
270
  // group dataclasses that need re-exporting from __init__.py.
@@ -262,6 +272,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
262
272
 
263
273
  for (const service of topLevel) {
264
274
  const resolvedName = resolveResourceClassName(service, ctx);
275
+ const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
265
276
  const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
266
277
  const lines: string[] = [];
267
278
 
@@ -274,7 +285,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
274
285
  // public re-exports. Otherwise consumers importing
275
286
  // `from workos.user_management import RoleSingle` get a private-import
276
287
  // warning under strict mode. Models barrel uses the same convention.
277
- const resourceImports = [resolvedName, `Async${resolvedName}`, ...groupClassNames];
288
+ const resourceImports = [clsName, `Async${clsName}`, ...groupClassNames];
278
289
  const aliasedImports = resourceImports.map((n) => `${n} as ${n}`);
279
290
  lines.push(`from ._resource import ${aliasedImports.join(', ')}`);
280
291
  lines.push('from .models import *');
@@ -2,6 +2,10 @@ import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
5
+ import {
6
+ buildExportedClassNameSet as buildExportedClassNameSetShared,
7
+ resolveServiceTarget as resolveServiceTargetShared,
8
+ } from '../shared/service-name-collision.js';
5
9
 
6
10
  /**
7
11
  * Python class names that collide with builtins or typing imports.
@@ -136,6 +140,27 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
136
140
  return toSnakeCase(op.name);
137
141
  }
138
142
 
143
+ /**
144
+ * Build the set of model + enum class names exported by the SDK. Used to
145
+ * detect collisions with operation-client class names — a colliding service
146
+ * gets a `Service` suffix appended.
147
+ */
148
+ export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
149
+ return buildExportedClassNameSetShared(ctx, className);
150
+ }
151
+
152
+ /**
153
+ * Resolve a service's mount-target identifier, appending `Service` on
154
+ * collision with an exported model/enum class. Feeds `className`/`fileName`
155
+ * so the class declaration, file, and any qualified references stay aligned.
156
+ *
157
+ * Accessor names (`servicePropertyName`) intentionally use the RAW target —
158
+ * `client.organization_membership` reads better than the suffixed form.
159
+ */
160
+ export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
161
+ return resolveServiceTargetShared(target, exportedClasses, className);
162
+ }
163
+
139
164
  /** Resolve the SDK class name for a service, using resolved operations' mountOn. */
140
165
  export function resolveClassName(service: Service, ctx: EmitterContext): string {
141
166
  // Use resolved ops mountOn as canonical class name (flat pattern like PHP)
@@ -23,6 +23,8 @@ import {
23
23
  buildMountDirMap,
24
24
  dirToModule,
25
25
  relativeImportPrefix,
26
+ buildExportedClassNameSet,
27
+ resolveServiceTarget,
26
28
  } from './naming.js';
27
29
  import {
28
30
  buildResolvedLookup,
@@ -1012,10 +1014,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1012
1014
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
1013
1015
  : services.map((s) => ({ name: resolveClassName(s, ctx), operations: s.operations }));
1014
1016
 
1017
+ const exportedClasses = buildExportedClassNameSet(ctx);
1015
1018
  for (const { name: mountName, operations: allOperations } of entries) {
1016
1019
  if (allOperations.length === 0) continue;
1017
1020
  const dirName = moduleName(mountName);
1018
- const resourceClassName = className(mountName);
1021
+ const resourceClassName = className(resolveServiceTarget(mountName, exportedClasses));
1019
1022
  const importPrefix = relativeImportPrefix(dirName);
1020
1023
 
1021
1024
  const lines: string[] = [];