@workos/oagen-emitters 0.10.0 → 0.11.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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-H0KhxbN7.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-DW3cnedr.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.18.0"
57
+ "@workos/oagen": "^0.18.1"
58
58
  }
59
59
  }
package/src/go/models.ts CHANGED
@@ -198,7 +198,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
198
198
  lines.push('');
199
199
  }
200
200
 
201
- // Emit shared PaginationParams struct for list operations to embed
201
+ // Emit shared PaginationParams struct for list operations to embed.
202
+ //
203
+ // The Order field's type is derived from the spec rather than hardcoded:
204
+ // when every paginated `order` query parameter $refs the same top-level
205
+ // enum (typically `PaginationOrder` in the WorkOS spec), we emit the typed
206
+ // enum so callers get compile-time validation. Otherwise we fall back to
207
+ // *string. The fallback handles older specs that don't lift the enum into a
208
+ // named component schema.
209
+ const orderEnumType = detectSharedOrderEnum(ctx.spec.services);
210
+ const orderGoType = orderEnumType ? `*${className(orderEnumType)}` : '*string';
202
211
  lines.push('// PaginationParams contains common pagination parameters for list operations.');
203
212
  lines.push('type PaginationParams struct {');
204
213
  lines.push('\t// Before is a cursor for reverse pagination.');
@@ -207,8 +216,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
207
216
  lines.push('\tAfter *string `url:"after,omitempty" json:"-"`');
208
217
  lines.push('\t// Limit is the maximum number of items to return per page.');
209
218
  lines.push('\tLimit *int `url:"limit,omitempty" json:"-"`');
210
- lines.push('\t// Order is the sort order for results (asc or desc).');
211
- lines.push('\tOrder *string `url:"order,omitempty" json:"-"`');
219
+ lines.push('\t// Order is the sort order for results.');
220
+ lines.push(`\tOrder ${orderGoType} \`url:"order,omitempty" json:"-"\``);
212
221
  lines.push('}');
213
222
  lines.push('');
214
223
 
@@ -311,6 +320,42 @@ function lowerFirst(s: string): string {
311
320
  return lowerFirstForDoc(s);
312
321
  }
313
322
 
323
+ /**
324
+ * If every paginated list operation's `order` query parameter $refs the same
325
+ * top-level enum, return that enum's IR name. Otherwise return null. When
326
+ * the spec is consistent this lifts `PaginationParams.Order` from `*string`
327
+ * to `*PaginationOrder` (or whatever the spec calls it), giving callers
328
+ * compile-time validation.
329
+ *
330
+ * We require strict consistency: if any operation uses a primitive string for
331
+ * `order`, or two operations reference different enums, we conservatively
332
+ * stay on `*string` so the shared struct doesn't lie about its accepted
333
+ * values.
334
+ */
335
+ function detectSharedOrderEnum(services: Service[]): string | null {
336
+ let candidate: string | null = null;
337
+ let sawAny = false;
338
+ for (const service of services) {
339
+ for (const op of service.operations) {
340
+ if (!op.pagination) continue;
341
+ const orderParam = op.queryParams.find((p) => p.name === 'order');
342
+ if (!orderParam) continue;
343
+ sawAny = true;
344
+ const enumName = unwrapEnumName(orderParam.type);
345
+ if (!enumName) return null;
346
+ if (candidate === null) candidate = enumName;
347
+ else if (candidate !== enumName) return null;
348
+ }
349
+ }
350
+ return sawAny ? candidate : null;
351
+ }
352
+
353
+ function unwrapEnumName(ref: TypeRef): string | null {
354
+ if (ref.kind === 'enum') return ref.name;
355
+ if (ref.kind === 'nullable') return unwrapEnumName(ref.inner);
356
+ return null;
357
+ }
358
+
314
359
  /**
315
360
  * Extract a deprecation reason from a field description.
316
361
  * Looks for patterns like "Use X instead", "Replaced by Y", etc.
package/src/php/models.ts CHANGED
@@ -221,15 +221,19 @@ function generateFromArrayValue(ref: TypeRef, accessor: string): string {
221
221
  return generateFromArrayValue(ref.inner, accessor);
222
222
  case 'union': {
223
223
  // Discriminated union: dispatch via match() on the discriminator
224
- // property to call the matching variant's fromArray. Unknown values
225
- // pass through as raw arrays so callers can introspect.
224
+ // property to call the matching variant's fromArray. An unknown
225
+ // discriminator value would otherwise assign a raw array to a typed
226
+ // property and crash later with a confusing TypeError, so throw
227
+ // immediately with the offending value.
226
228
  if (ref.discriminator && ref.discriminator.mapping) {
227
229
  const entries = Object.entries(ref.discriminator.mapping);
228
230
  if (entries.length > 0) {
229
231
  const arms = entries
230
232
  .map(([value, modelName]) => `'${value}' => ${className(modelName)}::fromArray(${accessor})`)
231
233
  .join(', ');
232
- return `match (${accessor}['${ref.discriminator.property}'] ?? null) { ${arms}, default => ${accessor} }`;
234
+ const discProp = ref.discriminator.property;
235
+ const throwArm = `default => throw new \\UnexpectedValueException(sprintf('Unknown ${discProp}: %s', json_encode(${accessor}['${discProp}'] ?? null)))`;
236
+ return `match (${accessor}['${discProp}'] ?? null) { ${arms}, ${throwArm} }`;
233
237
  }
234
238
  }
235
239
  const resolved = resolveDegenerateUnion(ref);
@@ -313,6 +317,26 @@ function generateToArrayValue(ref: TypeRef, accessor: string, nullable = false):
313
317
  case 'union': {
314
318
  const resolved = resolveDegenerateUnion(ref);
315
319
  if (resolved) return generateToArrayValue(resolved, accessor, nullable);
320
+ // Polymorphic union of model variants: PHP dispatches to the concrete
321
+ // instance's toArray() at runtime, so a single ->toArray() call serializes
322
+ // any branch correctly without a match here.
323
+ if (ref.variants.every((v) => v.kind === 'model')) {
324
+ return `${accessor}${ns}->toArray()`;
325
+ }
326
+ // Heterogeneous unions involving models or enums have no uniform
327
+ // serialization strategy (->toArray() vs ->value vs raw scalar), so fail
328
+ // at codegen time rather than silently emitting a raw object that breaks
329
+ // the toArray contract. Pure scalar unions (e.g. string|int) fall
330
+ // through to the bare accessor below — that is correct.
331
+ if (ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum')) {
332
+ const summary = ref.variants
333
+ .map((v) => (v.kind === 'model' ? `model:${v.name}` : v.kind === 'enum' ? `enum:${v.name}` : v.kind))
334
+ .join(' | ');
335
+ throw new Error(
336
+ `[php emitter] Cannot generate toArray for heterogeneous union: ${summary}. ` +
337
+ `Unions must be all-model or all-scalar; mixed and all-enum unions are not yet supported.`,
338
+ );
339
+ }
316
340
  return accessor;
317
341
  }
318
342
  case 'literal':
@@ -301,9 +301,10 @@ function generateMethod(
301
301
  const phpName = fieldName(q.name);
302
302
  if (seenDocParams.has(phpName)) continue;
303
303
  seenDocParams.add(phpName);
304
- // order params with enum defaults are non-nullable (they default to Desc, not null)
305
- const isNonNullableOrder = q.name === 'order' && q.type.kind === 'enum';
306
- const nullSuffix = !q.required && !isNonNullableOrder && !docType.endsWith('|null') ? '|null' : '';
304
+ // Spec-defaulted enum params are non-nullable (the signature default is the
305
+ // enum case, never null). Without a spec default, the param is nullable.
306
+ const hasEnumDefault = q.default != null && q.type.kind === 'enum';
307
+ const nullSuffix = !q.required && !hasEnumDefault && !docType.endsWith('|null') ? '|null' : '';
307
308
  const prefix = q.deprecated ? '(deprecated) ' : '';
308
309
  let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
309
310
  if (q.default != null) desc += ` Defaults to ${JSON.stringify(q.default)}.`;
@@ -682,16 +683,14 @@ function buildMethodParams(
682
683
  usedNames.add(phpName);
683
684
  if (q.required) {
684
685
  required.push(`${phpType} $${phpName}`);
685
- } else if (q.name === 'order') {
686
- // Hardcode order default to desc for pagination consistency
687
- if (q.type.kind === 'enum') {
688
- const enumType = mapTypeRef(q.type, { qualified: true });
689
- const caseName = toPascalCase('desc');
690
- optional.push(`${enumType} $${phpName} = ${enumType}::${caseName}`);
691
- } else {
692
- const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
693
- optional.push(`${nullableType} $${phpName} = 'desc'`);
694
- }
686
+ } else if (q.default != null && q.type.kind === 'enum') {
687
+ // Spec-provided default for an enum-typed param: emit a non-nullable
688
+ // typed default (e.g. PaginationOrder $order = PaginationOrder::Desc).
689
+ // Only enums are safe to default this way — primitives stay nullable so
690
+ // callers can distinguish "unset" from "explicit value".
691
+ const enumType = mapTypeRef(q.type, { qualified: true });
692
+ const caseName = toPascalCase(String(q.default));
693
+ optional.push(`${enumType} $${phpName} = ${enumType}::${caseName}`);
695
694
  } else {
696
695
  const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
697
696
  optional.push(`${nullableType} $${phpName} = null`);
@@ -756,9 +755,10 @@ function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
756
755
  .map((q) => {
757
756
  const phpName = fieldName(q.name);
758
757
  if (isEnumType(q.type)) {
759
- // order params with enum defaults are non-nullable (default to Desc, not null)
760
- const isNonNullableOrder = q.name === 'order' && q.type.kind === 'enum';
761
- const nullsafe = q.required || isNonNullableOrder ? '' : '?';
758
+ // Mirrors the signature: enum params with a spec default are
759
+ // non-nullable, so we can dereference ->value without the nullsafe op.
760
+ const hasEnumDefault = q.default != null && q.type.kind === 'enum';
761
+ const nullsafe = q.required || hasEnumDefault ? '' : '?';
762
762
  return `'${q.name}' => $${phpName}${nullsafe}->value,`;
763
763
  }
764
764
  return `'${q.name}' => $${phpName},`;
@@ -1,6 +1,7 @@
1
- import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
- import { toUpperSnakeCase, walkTypeRef } from '@workos/oagen';
1
+ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { toUpperSnakeCase } from '@workos/oagen';
3
3
  import { className, fileName, buildMountDirMap, dirToModule } from './naming.js';
4
+ import { computeSchemaPlacement } from './shared-schemas.js';
4
5
 
5
6
  /**
6
7
  * Convert a PascalCase class name to a human-readable lowercase string,
@@ -21,14 +22,18 @@ function humanizeClassName(name: string): string {
21
22
  export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
22
23
  if (enums.length === 0) return [];
23
24
 
24
- const enumToService = assignEnumsToServices(enums, ctx.spec.services);
25
+ // Tests sometimes pass enums that aren't in ctx.spec.enums, so synthesize a
26
+ // spec view with the passed-in enums to keep the placement logic accurate.
27
+ const placementSpec = enums === ctx.spec.enums ? ctx.spec : { ...ctx.spec, enums };
28
+ const placement = computeSchemaPlacement(placementSpec, ctx);
29
+ const enumToService = placement.enumToService;
25
30
  const mountDirMap = buildMountDirMap(ctx);
26
31
  const resolveDir = (irService: string | undefined) =>
27
32
  irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
28
33
  const files: GeneratedFile[] = [];
29
34
  const compatAliases = collectCompatEnumAliases(enums, ctx);
30
35
 
31
- const aliasOf = collectEnumAliasOf(enums);
36
+ const aliasOf = placement.enumAliases;
32
37
 
33
38
  for (const enumDef of enums) {
34
39
  const service = enumToService.get(enumDef.name);
@@ -260,31 +265,9 @@ export function collectCompatEnumAliases(enums: Enum[], ctx: EmitterContext): Ma
260
265
  return aliases;
261
266
  }
262
267
 
263
- function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
264
- const hashGroups = new Map<string, string[]>();
265
- for (const enumDef of enums) {
266
- const hash = [...enumDef.values]
267
- .map((v) => String(v.value))
268
- .sort()
269
- .join('|');
270
- if (!hashGroups.has(hash)) hashGroups.set(hash, []);
271
- hashGroups.get(hash)!.push(enumDef.name);
272
- }
273
-
274
- const aliasOf = new Map<string, string>();
275
- for (const [, names] of hashGroups) {
276
- if (names.length <= 1) continue;
277
- const sorted = [...names].sort();
278
- const canonical = sorted[0];
279
- for (let i = 1; i < sorted.length; i++) {
280
- aliasOf.set(sorted[i], canonical);
281
- }
282
- }
283
- return aliasOf;
284
- }
285
-
286
268
  export function collectGeneratedEnumSymbolsByDir(enums: Enum[], ctx: EmitterContext): Map<string, string[]> {
287
- const enumToService = assignEnumsToServices(enums, ctx.spec.services);
269
+ const placementSpec = enums === ctx.spec.enums ? ctx.spec : { ...ctx.spec, enums };
270
+ const enumToService = computeSchemaPlacement(placementSpec, ctx).enumToService;
288
271
  const mountDirMap = buildMountDirMap(ctx);
289
272
  const resolveDir = (irService: string | undefined) =>
290
273
  irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
@@ -310,29 +293,3 @@ function enumValueHash(enumDef: Enum): string {
310
293
  .sort()
311
294
  .join('|');
312
295
  }
313
-
314
- export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
315
- const enumToService = new Map<string, string>();
316
- const enumNames = new Set(enums.map((e) => e.name));
317
-
318
- for (const service of services) {
319
- for (const op of service.operations) {
320
- const refs = new Set<string>();
321
- const collect = (ref: any) => {
322
- walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
323
- };
324
- if (op.requestBody) collect(op.requestBody);
325
- collect(op.response);
326
- for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
327
- collect(p.type);
328
- }
329
- for (const name of refs) {
330
- if (enumNames.has(name) && !enumToService.has(name)) {
331
- enumToService.set(name, service.name);
332
- }
333
- }
334
- }
335
- }
336
-
337
- return enumToService;
338
- }