@workos/oagen-emitters 0.2.0 → 0.2.1

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/tests.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  fieldName,
6
6
  wireFieldName,
7
7
  fileName,
8
- serviceDirName,
8
+ resolveServiceDir,
9
9
  servicePropertyName,
10
10
  resolveMethodName,
11
11
  resolveInterfaceName,
@@ -28,7 +28,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
28
28
  // Generate fixture JSON files
29
29
  const fixtures = generateFixtures(spec, ctx);
30
30
  for (const f of fixtures) {
31
- files.push({ path: f.path, content: f.content, headerPlacement: 'skip' });
31
+ files.push({ path: f.path, content: f.content, headerPlacement: 'skip', integrateTarget: false });
32
32
  }
33
33
 
34
34
  // Build model lookup for response field assertions
@@ -61,7 +61,7 @@ function generateServiceTest(
61
61
  modelMap: Map<string, Model>,
62
62
  ): GeneratedFile {
63
63
  const resolvedName = resolveResourceClassName(service, ctx);
64
- const serviceDir = serviceDirName(resolvedName);
64
+ const serviceDir = resolveServiceDir(resolvedName);
65
65
  const serviceClass = resolvedName;
66
66
  const serviceProp = servicePropertyName(resolvedName);
67
67
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
@@ -510,8 +510,9 @@ function buildCallArgs(op: Operation, plan: any, modelMap: Map<string, Model>):
510
510
 
511
511
  if (isPaginated) return pathArgs || '';
512
512
  if (hasBody) {
513
- const fb = fallbackBodyArg(op, modelMap);
514
- return pathArgs ? `${pathArgs}, ${fb}` : fb;
513
+ const payload = buildTestPayload(op, modelMap);
514
+ const bodyArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
515
+ return pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg;
515
516
  }
516
517
  return pathArgs || '';
517
518
  }
@@ -626,10 +627,18 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
626
627
  }
627
628
 
628
629
  /**
629
- * Return a JS literal string for the expected fixture value of a primitive field.
630
- * Returns null for non-primitive or complex types (arrays, models, etc.).
630
+ * Return a JS literal string for the expected fixture value of a field.
631
+ * Returns null for types that cannot be deterministically generated.
632
+ * When a modelMap is provided, recursively builds object literals for nested model types.
633
+ * When wire is true, uses snake_case keys for nested model objects (wire format).
631
634
  */
632
- function fixtureValueForType(ref: TypeRef, name: string, modelName: string): string | null {
635
+ function fixtureValueForType(
636
+ ref: TypeRef,
637
+ name: string,
638
+ modelName: string,
639
+ modelMap?: Map<string, Model>,
640
+ wire?: boolean,
641
+ ): string | null {
633
642
  switch (ref.kind) {
634
643
  case 'primitive':
635
644
  return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
@@ -646,10 +655,24 @@ function fixtureValueForType(ref: TypeRef, name: string, modelName: string): str
646
655
  // For arrays of primitives/enums, generate a single-element array assertion.
647
656
  // For arrays of models/complex types, return null to skip the assertion —
648
657
  // the fixture will have populated items that we can't predict here.
649
- const itemValue = fixtureValueForType(ref.items, name, modelName);
658
+ const itemValue = fixtureValueForType(ref.items, name, modelName, modelMap, wire);
650
659
  if (itemValue !== null) return `[${itemValue}]`;
651
660
  return null;
652
661
  }
662
+ case 'model': {
663
+ if (!modelMap) return null;
664
+ const nested = modelMap.get(ref.name);
665
+ if (!nested) return null;
666
+ const requiredFields = nested.fields.filter((f) => f.required);
667
+ const entries: string[] = [];
668
+ for (const field of requiredFields) {
669
+ const value = fixtureValueForType(field.type, field.name, nested.name, modelMap, wire);
670
+ if (value === null) return null; // Can't build a complete object
671
+ const key = wire ? wireFieldName(field.name) : fieldName(field.name);
672
+ entries.push(`${key}: ${value}`);
673
+ }
674
+ return `{ ${entries.join(', ')} }`;
675
+ }
653
676
  default:
654
677
  return null;
655
678
  }
@@ -716,8 +739,8 @@ function buildTestPayload(
716
739
  if (!model) return null;
717
740
 
718
741
  const fields = model.fields.filter((f) => f.required);
719
- // Only use primitive/literal/enum/array fields that we can generate deterministic values for
720
- const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name) !== null);
742
+ // Only use fields that we can generate deterministic values for (primitives, enums, and nested models)
743
+ const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null);
721
744
 
722
745
  // Only generate a typed payload when ALL required fields have fixture values.
723
746
  // A partial payload missing required fields would fail TypeScript type checking.
@@ -727,11 +750,12 @@ function buildTestPayload(
727
750
  const snakeEntries: string[] = [];
728
751
 
729
752
  for (const field of usableFields) {
730
- const value = fixtureValueForType(field.type, field.name, model.name)!;
753
+ const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap)!;
754
+ const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true)!;
731
755
  const camelKey = fieldName(field.name);
732
756
  const snakeKey = wireFieldName(field.name);
733
- camelEntries.push(`${camelKey}: ${value}`);
734
- snakeEntries.push(`${snakeKey}: ${value}`);
757
+ camelEntries.push(`${camelKey}: ${camelValue}`);
758
+ snakeEntries.push(`${snakeKey}: ${wireValue}`);
735
759
  }
736
760
 
737
761
  return {
@@ -775,7 +799,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
775
799
  serviceNameMap.set(service.name, resolveResourceClassName(service, ctx));
776
800
  }
777
801
  const resolveDir = (irService: string | undefined) =>
778
- irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
802
+ irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
779
803
 
780
804
  // Only generate round-trip tests for models with fields that have serializers generated.
781
805
  // Skip list metadata and list wrapper models since their serializers are not emitted.
package/src/node/utils.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Model, EmitterContext, Service, Operation, Field } from '@workos/oagen';
2
+ import { toPascalCase } from '@workos/oagen';
2
3
  export {
3
4
  collectModelRefs,
4
5
  collectEnumRefs,
@@ -7,7 +8,14 @@ export {
7
8
  collectRequestBodyModels,
8
9
  } from '@workos/oagen';
9
10
  import { mapTypeRef } from './type-map.js';
10
- import { resolveInterfaceName, fieldName, serviceDirName, buildServiceNameMap } from './naming.js';
11
+ import {
12
+ resolveInterfaceName,
13
+ fieldName,
14
+ resolveServiceDir,
15
+ resolveMethodName,
16
+ buildServiceNameMap,
17
+ SERVICE_COVERED_BY,
18
+ } from './naming.js';
11
19
  import { assignModelsToServices } from '@workos/oagen';
12
20
 
13
21
  /**
@@ -218,7 +226,7 @@ export function createServiceDirResolver(
218
226
  const modelToService = assignModelsToServices(models, services);
219
227
  const serviceNameMap = buildServiceNameMap(services, ctx);
220
228
  const resolveDir = (irService: string | undefined) =>
221
- irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
229
+ irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
222
230
  return { modelToService, serviceNameMap, resolveDir };
223
231
  }
224
232
 
@@ -384,6 +392,9 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
384
392
  * endpoints (e.g., `GET /connections`).
385
393
  */
386
394
  export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
395
+ // Explicit override: services known to be covered by existing hand-written classes
396
+ if (SERVICE_COVERED_BY[toPascalCase(service.name)]) return true;
397
+
387
398
  const overlay = ctx.overlayLookup?.methodByOperation;
388
399
  if (!overlay || overlay.size === 0) return false;
389
400
  if (service.operations.length === 0) return false;
@@ -405,6 +416,45 @@ export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext
405
416
  });
406
417
  }
407
418
 
419
+ /**
420
+ * Check whether a fully-covered service has operations whose overlay-mapped
421
+ * methods are missing from the baseline class. Returns true when at least
422
+ * one operation maps to a method name that the baseline class does not have,
423
+ * meaning the merger needs to add new methods (skipIfExists must be removed).
424
+ */
425
+ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterContext): boolean {
426
+ const baselineClasses = ctx.apiSurface?.classes;
427
+ if (!baselineClasses) return false;
428
+
429
+ // For services explicitly mapped to an existing class via SERVICE_COVERED_BY,
430
+ // check each operation's resolved method name against the target class directly.
431
+ // This avoids the overlay gap where new endpoints are silently skipped.
432
+ const targetClassName = SERVICE_COVERED_BY[toPascalCase(service.name)];
433
+ if (targetClassName) {
434
+ const cls = baselineClasses[targetClassName];
435
+ if (!cls) return true; // Target class missing from baseline — treat as absent
436
+ for (const op of service.operations) {
437
+ const method = resolveMethodName(op, service, ctx);
438
+ if (!cls.methods?.[method]) return true;
439
+ }
440
+ return false;
441
+ }
442
+
443
+ // Default overlay-based detection
444
+ const overlay = ctx.overlayLookup?.methodByOperation;
445
+ if (!overlay) return false;
446
+
447
+ for (const op of service.operations) {
448
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
449
+ const match = overlay.get(httpKey);
450
+ if (!match) continue;
451
+ const cls = baselineClasses[match.className];
452
+ if (!cls) continue;
453
+ if (!cls.methods?.[match.methodName]) return true;
454
+ }
455
+ return false;
456
+ }
457
+
408
458
  /**
409
459
  * Return operations in a service that are NOT covered by existing hand-written
410
460
  * service classes. For fully uncovered services, returns all operations.