@workos/oagen-emitters 0.18.4 → 0.19.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.
Files changed (58) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-Cciic50q.mjs → plugin-DXIciTnN.mjs} +668 -164
  6. package/dist/plugin-DXIciTnN.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +4 -4
  9. package/src/dotnet/enums.ts +11 -5
  10. package/src/dotnet/fixtures.ts +28 -7
  11. package/src/dotnet/index.ts +42 -1
  12. package/src/dotnet/models.ts +11 -5
  13. package/src/dotnet/resources.ts +3 -3
  14. package/src/dotnet/tests.ts +4 -4
  15. package/src/go/enums.ts +91 -18
  16. package/src/go/fixtures.ts +25 -3
  17. package/src/go/flat-merge.ts +253 -0
  18. package/src/go/models.ts +85 -20
  19. package/src/go/resources.ts +3 -3
  20. package/src/go/tests.ts +7 -5
  21. package/src/kotlin/enums.ts +21 -11
  22. package/src/kotlin/models.ts +53 -11
  23. package/src/kotlin/resources.ts +2 -2
  24. package/src/kotlin/tests.ts +38 -3
  25. package/src/node/enums.ts +8 -5
  26. package/src/node/models.ts +29 -21
  27. package/src/node/resources.ts +12 -1
  28. package/src/node/tests.ts +7 -2
  29. package/src/php/enums.ts +18 -5
  30. package/src/php/index.ts +11 -3
  31. package/src/php/models.ts +11 -5
  32. package/src/php/resources.ts +6 -4
  33. package/src/php/tests.ts +6 -3
  34. package/src/python/enums.ts +39 -28
  35. package/src/python/fixtures.ts +34 -6
  36. package/src/python/models.ts +138 -45
  37. package/src/python/resources.ts +3 -3
  38. package/src/python/tests.ts +31 -12
  39. package/src/ruby/enums.ts +28 -19
  40. package/src/ruby/models.ts +23 -12
  41. package/src/ruby/rbi.ts +17 -6
  42. package/src/ruby/resources.ts +2 -2
  43. package/src/ruby/tests.ts +37 -4
  44. package/src/rust/enums.ts +29 -7
  45. package/src/rust/fixtures.ts +12 -3
  46. package/src/rust/models.ts +37 -6
  47. package/src/rust/resources.ts +8 -1
  48. package/src/rust/tests.ts +3 -3
  49. package/src/shared/resolved-ops.ts +104 -0
  50. package/test/dotnet/scoped-aggregates.test.ts +247 -0
  51. package/test/go/scoping.test.ts +324 -0
  52. package/test/kotlin/models.test.ts +74 -0
  53. package/test/kotlin/tests.test.ts +33 -0
  54. package/test/python/scoped-aggregates.test.ts +205 -0
  55. package/test/ruby/tests.test.ts +130 -0
  56. package/test/rust/fixtures.test.ts +13 -7
  57. package/test/shared/synthetic-enum-seed.test.ts +79 -0
  58. package/dist/plugin-Cciic50q.mjs.map +0 -1
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Service, Model, ResolvedOperation } from '@workos/oagen';
3
+ import { defaultSdkBehavior, toSnakeCase, toPascalCase, assignModelsToServices } from '@workos/oagen';
4
+ import { generateTests } from '../../src/ruby/tests.js';
5
+ import { fileName, buildMountDirMap } from '../../src/ruby/naming.js';
6
+ import { classifyUnassignedModel } from '../../src/ruby/models.js';
7
+
8
+ function makeSpec(services: Service[], models: Model[]): ApiSpec {
9
+ return {
10
+ name: 'Test',
11
+ version: '1.0.0',
12
+ baseUrl: 'https://api.workos.com',
13
+ services,
14
+ models,
15
+ enums: [],
16
+ sdk: defaultSdkBehavior(),
17
+ };
18
+ }
19
+
20
+ function buildResolvedOps(services: Service[]): ResolvedOperation[] {
21
+ const ops: ResolvedOperation[] = [];
22
+ for (const service of services) {
23
+ const mountOn = toPascalCase(service.name);
24
+ for (const op of service.operations) {
25
+ ops.push({
26
+ operation: op,
27
+ service,
28
+ methodName: toSnakeCase(op.name),
29
+ mountOn,
30
+ defaults: {},
31
+ inferFromClient: [],
32
+ urlBuilder: false,
33
+ });
34
+ }
35
+ }
36
+ return ops;
37
+ }
38
+
39
+ /** Compute the per-model `.rb` path exactly as ruby/models.ts (and tests.ts) does. */
40
+ function modelFilePath(modelName: string, spec: ApiSpec, ctx: EmitterContext): string {
41
+ const modelToService = assignModelsToServices(spec.models as Model[], spec.services, ctx.modelHints);
42
+ const mountDirMap = buildMountDirMap(ctx);
43
+ const service = modelToService.get(modelName);
44
+ const dir = service
45
+ ? (mountDirMap.get(service) ?? classifyUnassignedModel(modelName))
46
+ : classifyUnassignedModel(modelName);
47
+ return `lib/workos/${dir}/${fileName(modelName)}.rb`;
48
+ }
49
+
50
+ const models: Model[] = [
51
+ // In-scope: selected service's model.
52
+ { name: 'Organization', fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }] },
53
+ // On-disk: out-of-scope this run, but its file is recorded in the prior manifest.
54
+ { name: 'Connection', fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }] },
55
+ // Brand-new out-of-scope: out-of-scope AND not in the prior manifest.
56
+ { name: 'Directory', fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }] },
57
+ ];
58
+
59
+ const services: Service[] = [
60
+ {
61
+ name: 'Organizations',
62
+ operations: [
63
+ {
64
+ name: 'listOrganizations',
65
+ httpMethod: 'get',
66
+ path: '/organizations',
67
+ pathParams: [],
68
+ queryParams: [],
69
+ headerParams: [],
70
+ response: { kind: 'model', name: 'Organization' },
71
+ errors: [],
72
+ injectIdempotencyKey: false,
73
+ },
74
+ ],
75
+ },
76
+ ];
77
+
78
+ const spec = makeSpec(services, models);
79
+
80
+ function findRoundTripFile(files: { path: string; content: string }[]): { path: string; content: string } {
81
+ const file = files.find((f) => f.path === 'test/workos/test_model_round_trip.rb');
82
+ if (!file) throw new Error('round-trip test file not emitted');
83
+ return file;
84
+ }
85
+
86
+ describe('ruby/tests model round-trip aggregate scoping', () => {
87
+ it('full run: round-trips every model', () => {
88
+ const ctx: EmitterContext = {
89
+ namespace: 'workos',
90
+ namespacePascal: 'WorkOS',
91
+ spec,
92
+ resolvedOperations: buildResolvedOps(services),
93
+ };
94
+ const content = findRoundTripFile(generateTests(spec, ctx)).content;
95
+ expect(content).toContain('WorkOS::Organization.new');
96
+ expect(content).toContain('WorkOS::Connection.new');
97
+ expect(content).toContain('WorkOS::Directory.new');
98
+ });
99
+
100
+ it('scoped run: keeps in-scope + on-disk models, drops brand-new out-of-scope models', () => {
101
+ const onDiskPath = modelFilePath('Connection', spec, {
102
+ namespace: 'workos',
103
+ namespacePascal: 'WorkOS',
104
+ spec,
105
+ resolvedOperations: buildResolvedOps(services),
106
+ });
107
+
108
+ const ctx: EmitterContext = {
109
+ namespace: 'workos',
110
+ namespacePascal: 'WorkOS',
111
+ spec,
112
+ resolvedOperations: buildResolvedOps(services),
113
+ // Scoped to the Organizations service / Organization model only.
114
+ scopedServices: new Set(['Organizations']),
115
+ scopedModelNames: new Set(['Organization']),
116
+ // Connection's per-model file already exists on disk from a prior run.
117
+ priorTargetManifestPaths: new Set([onDiskPath]),
118
+ };
119
+
120
+ const content = findRoundTripFile(generateTests(spec, ctx)).content;
121
+
122
+ // In-scope model: kept.
123
+ expect(content).toContain('WorkOS::Organization.new');
124
+ // On-disk (prior manifest) model: retained even though out-of-scope.
125
+ expect(content).toContain('WorkOS::Connection.new');
126
+ // Brand-new out-of-scope model: NO round-trip test (would NameError).
127
+ expect(content).not.toContain('WorkOS::Directory.new');
128
+ expect(content).not.toContain('def test_directory_round_trip');
129
+ });
130
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import type { ApiSpec, Enum, Model } from '@workos/oagen';
2
+ import type { ApiSpec, Enum, Model, EmitterContext } from '@workos/oagen';
3
3
  import { defaultSdkBehavior } from '@workos/oagen';
4
4
  import { exampleFromSpec, generateFixtures, generateModelFixture } from '../../src/rust/fixtures.js';
5
5
 
@@ -15,6 +15,12 @@ function spec(models: Model[], enums: Enum[] = []): ApiSpec {
15
15
  };
16
16
  }
17
17
 
18
+ // Minimal full-run context (no `scopedServices`): scoping is inert, so every
19
+ // fixture is emitted — matching these tests' intent of asserting content.
20
+ function ctx(s: ApiSpec): EmitterContext {
21
+ return { namespace: 'test', namespacePascal: 'Test', spec: s };
22
+ }
23
+
18
24
  describe('rust/fixtures', () => {
19
25
  it('prefers a spec `example` over the generated placeholder for a primitive field', () => {
20
26
  const models: Model[] = [
@@ -36,7 +42,7 @@ describe('rust/fixtures', () => {
36
42
  ],
37
43
  },
38
44
  ];
39
- const files = generateFixtures(spec(models));
45
+ const files = generateFixtures(spec(models), ctx(spec(models)));
40
46
  const file = files.find((f) => f.path === 'tests/fixtures/event.json')!;
41
47
  expect(file).toBeDefined();
42
48
  const parsed = JSON.parse(file.content);
@@ -59,7 +65,7 @@ describe('rust/fixtures', () => {
59
65
  ],
60
66
  },
61
67
  ];
62
- const files = generateFixtures(spec(models));
68
+ const files = generateFixtures(spec(models), ctx(spec(models)));
63
69
  const file = files.find((f) => f.path === 'tests/fixtures/wrong.json')!;
64
70
  const parsed = JSON.parse(file.content);
65
71
  expect(parsed.count).toBe(0); // placeholder fallback
@@ -82,7 +88,7 @@ describe('rust/fixtures', () => {
82
88
  ],
83
89
  },
84
90
  ];
85
- const files = generateFixtures(spec(models));
91
+ const files = generateFixtures(spec(models), ctx(spec(models)));
86
92
  const file = files.find((f) => f.path === 'tests/fixtures/org.json')!;
87
93
  const parsed = JSON.parse(file.content);
88
94
  expect(parsed.domains).toEqual(['example.com', 'foo.com']);
@@ -120,7 +126,7 @@ describe('rust/fixtures', () => {
120
126
  ],
121
127
  },
122
128
  ];
123
- const files = generateFixtures(spec(models));
129
+ const files = generateFixtures(spec(models), ctx(spec(models)));
124
130
  const file = files.find((f) => f.path === 'tests/fixtures/outer.json')!;
125
131
  const parsed = JSON.parse(file.content);
126
132
  // The nested model is regenerated from its own fields' examples, not from
@@ -162,7 +168,7 @@ describe('rust/fixtures', () => {
162
168
  ],
163
169
  },
164
170
  ];
165
- const files = generateFixtures(spec(models, enums));
171
+ const files = generateFixtures(spec(models, enums), ctx(spec(models, enums)));
166
172
  const good = JSON.parse(files.find((f) => f.path === 'tests/fixtures/good_ex.json')!.content);
167
173
  const bad = JSON.parse(files.find((f) => f.path === 'tests/fixtures/bad_ex.json')!.content);
168
174
  expect(good.status).toBe('pending'); // valid example wins
@@ -183,7 +189,7 @@ describe('rust/fixtures', () => {
183
189
  ],
184
190
  },
185
191
  ];
186
- const files = generateFixtures(spec(models));
192
+ const files = generateFixtures(spec(models), ctx(spec(models)));
187
193
  const parsed = JSON.parse(files.find((f) => f.path === 'tests/fixtures/nullish.json')!.content);
188
194
  expect(parsed.name).toBe('test_name');
189
195
  });
@@ -0,0 +1,79 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { Enum, Model } from '@workos/oagen';
6
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../../src/shared/model-utils.js';
7
+
8
+ // Regression: an inline oneOf enum whose synthetic name (`Parent_field`)
9
+ // snake-collapses onto an existing IR enum must NOT spawn a duplicate
10
+ // synthetic. `DataIntegrationAccessTokenResponse.error` (-> synthetic
11
+ // `DataIntegrationAccessTokenResponse_error`) and the parser-emitted IR enum
12
+ // `DataIntegrationAccessTokenResponseError` both snake to
13
+ // `data_integration_access_token_response_error`. When both exist, every
14
+ // PascalCase-normalizing emitter (PHP, Ruby, Go, ...) collapses them onto the
15
+ // SAME file path, and which one wins is decided by array order — which differs
16
+ // between a full and a scoped (`--services`) generation, yielding a
17
+ // non-deterministic enum-case order. Seeding `enrichModelsFromSpec` with the
18
+ // IR enum names suppresses the duplicate so the real enum always wins.
19
+ const SPEC = {
20
+ openapi: '3.0.0',
21
+ info: { title: 'fixture', version: '1.0.0' },
22
+ paths: {},
23
+ components: {
24
+ schemas: {
25
+ DataIntegrationAccessTokenResponse: {
26
+ oneOf: [
27
+ {
28
+ type: 'object',
29
+ properties: {
30
+ error: {
31
+ type: 'string',
32
+ enum: ['not_installed', 'needs_reauthorization'],
33
+ },
34
+ },
35
+ },
36
+ ],
37
+ },
38
+ },
39
+ },
40
+ };
41
+
42
+ const SYNTHETIC_NAME = 'DataIntegrationAccessTokenResponse_error';
43
+ const models: Model[] = [{ name: 'DataIntegrationAccessTokenResponse', fields: [] }];
44
+ const collidingEnum: Enum = {
45
+ name: 'DataIntegrationAccessTokenResponseError',
46
+ values: [
47
+ { name: 'NOT_INSTALLED', value: 'not_installed' },
48
+ { name: 'NEEDS_REAUTHORIZATION', value: 'needs_reauthorization' },
49
+ ],
50
+ };
51
+
52
+ let dir: string;
53
+
54
+ beforeAll(() => {
55
+ dir = mkdtempSync(join(tmpdir(), 'synthetic-enum-seed-'));
56
+ const specPath = join(dir, 'spec.yaml');
57
+ // `loadRawSpec` accepts JSON too (js-yaml parses JSON), so write JSON.
58
+ writeFileSync(specPath, JSON.stringify(SPEC));
59
+ process.env.OPENAPI_SPEC_PATH = specPath;
60
+ });
61
+
62
+ afterAll(() => {
63
+ delete process.env.OPENAPI_SPEC_PATH;
64
+ if (dir) rmSync(dir, { recursive: true, force: true });
65
+ });
66
+
67
+ describe('enrichModelsFromSpec — synthetic enum seed', () => {
68
+ it('emits the inline synthetic enum when no IR enum names are seeded', () => {
69
+ enrichModelsFromSpec(models);
70
+ const names = getSyntheticEnums().map((e) => e.name);
71
+ expect(names).toContain(SYNTHETIC_NAME);
72
+ });
73
+
74
+ it('suppresses the duplicate synthetic when the colliding IR enum is seeded', () => {
75
+ enrichModelsFromSpec(models, [collidingEnum]);
76
+ const names = getSyntheticEnums().map((e) => e.name);
77
+ expect(names).not.toContain(SYNTHETIC_NAME);
78
+ });
79
+ });