@workos/oagen-emitters 0.12.0 → 0.12.2
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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint-pr-title.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-C408Wh-o.mjs → plugin-eCuvoL1T.mjs} +3914 -2121
- package/dist/plugin-eCuvoL1T.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/package.json +10 -10
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +345 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +540 -351
- package/src/node/naming.ts +119 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/resources.ts +455 -46
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +108 -83
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/src/rust/fixtures.ts +87 -1
- package/src/rust/models.ts +17 -2
- package/src/rust/resources.ts +697 -62
- package/src/rust/tests.ts +540 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +376 -2036
- package/test/node/tests.test.ts +119 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/test/rust/fixtures.test.ts +227 -0
- package/test/rust/models.test.ts +38 -0
- package/test/rust/resources.test.ts +505 -2
- package/test/rust/tests.test.ts +504 -0
- package/dist/plugin-C408Wh-o.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
|
|
4
7
|
import { defaultSdkBehavior } from '@workos/oagen';
|
|
8
|
+
import { nodeEmitter } from '../../src/node/index.js';
|
|
9
|
+
import {
|
|
10
|
+
generateResources,
|
|
11
|
+
resolveResourceClassName,
|
|
12
|
+
resolveResourceDir,
|
|
13
|
+
hasCompatibleConstructor,
|
|
14
|
+
} from '../../src/node/resources.js';
|
|
5
15
|
|
|
6
16
|
const emptySpec: ApiSpec = {
|
|
7
17
|
name: 'Test',
|
|
@@ -19,12 +29,50 @@ const ctx: EmitterContext = {
|
|
|
19
29
|
spec: emptySpec,
|
|
20
30
|
};
|
|
21
31
|
|
|
32
|
+
function createTrackedSdkRoot(): string {
|
|
33
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-adopt-surface-'));
|
|
34
|
+
fs.mkdirSync(path.join(tmpRoot, 'src'), { recursive: true });
|
|
35
|
+
fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), '// @oagen-ignore-file\nexport class WorkOS {}\n');
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
path.join(tmpRoot, 'src', 'index.ts'),
|
|
38
|
+
'// @oagen-ignore-file\nexport { WorkOS } from "./workos";\n',
|
|
39
|
+
);
|
|
40
|
+
execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
41
|
+
execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
42
|
+
return tmpRoot;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const connectService: Service = {
|
|
46
|
+
name: 'Connect',
|
|
47
|
+
operations: [
|
|
48
|
+
{
|
|
49
|
+
name: 'getConnect',
|
|
50
|
+
httpMethod: 'get',
|
|
51
|
+
path: '/connect',
|
|
52
|
+
pathParams: [],
|
|
53
|
+
queryParams: [],
|
|
54
|
+
headerParams: [],
|
|
55
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
56
|
+
errors: [],
|
|
57
|
+
injectIdempotencyKey: false,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
22
62
|
describe('generateResources', () => {
|
|
23
63
|
it('returns empty for no services', () => {
|
|
24
64
|
expect(generateResources([], ctx)).toEqual([]);
|
|
25
65
|
});
|
|
26
66
|
|
|
27
67
|
it('generates a resource class with GET method', () => {
|
|
68
|
+
const orgModel: Model = {
|
|
69
|
+
name: 'Organization',
|
|
70
|
+
fields: [
|
|
71
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
72
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
28
76
|
const services: Service[] = [
|
|
29
77
|
{
|
|
30
78
|
name: 'Organizations',
|
|
@@ -33,13 +81,7 @@ describe('generateResources', () => {
|
|
|
33
81
|
name: 'getOrganization',
|
|
34
82
|
httpMethod: 'get',
|
|
35
83
|
path: '/organizations/{id}',
|
|
36
|
-
pathParams: [
|
|
37
|
-
{
|
|
38
|
-
name: 'id',
|
|
39
|
-
type: { kind: 'primitive', type: 'string' },
|
|
40
|
-
required: true,
|
|
41
|
-
},
|
|
42
|
-
],
|
|
84
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
43
85
|
queryParams: [],
|
|
44
86
|
headerParams: [],
|
|
45
87
|
response: { kind: 'model', name: 'Organization' },
|
|
@@ -50,213 +92,191 @@ describe('generateResources', () => {
|
|
|
50
92
|
},
|
|
51
93
|
];
|
|
52
94
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
95
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [orgModel] };
|
|
96
|
+
const ctxWithSpec: EmitterContext = { ...ctx, spec };
|
|
97
|
+
const result = generateResources(services, ctxWithSpec);
|
|
56
98
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
expect(
|
|
60
|
-
expect(content).toContain('
|
|
61
|
-
expect(content).toContain('
|
|
99
|
+
expect(result.length).toBeGreaterThan(0);
|
|
100
|
+
const resourceFile = result.find((f) => f.path.includes('organizations.ts'));
|
|
101
|
+
expect(resourceFile).toBeDefined();
|
|
102
|
+
expect(resourceFile!.content).toContain('export class Organizations');
|
|
103
|
+
expect(resourceFile!.content).toContain('constructor(private readonly workos: WorkOS)');
|
|
104
|
+
expect(resourceFile!.content).toContain('async getOrganization(id: string): Promise<Organization>');
|
|
105
|
+
expect(resourceFile!.content).toContain('deserializeOrganization(data)');
|
|
62
106
|
});
|
|
63
107
|
|
|
64
|
-
it('generates
|
|
108
|
+
it('generates DELETE method returning void', () => {
|
|
65
109
|
const services: Service[] = [
|
|
66
110
|
{
|
|
67
111
|
name: 'Organizations',
|
|
68
112
|
operations: [
|
|
69
113
|
{
|
|
70
|
-
name: '
|
|
71
|
-
httpMethod: '
|
|
72
|
-
path: '/organizations',
|
|
73
|
-
pathParams: [],
|
|
74
|
-
queryParams: [
|
|
75
|
-
{
|
|
76
|
-
name: 'domains',
|
|
77
|
-
type: {
|
|
78
|
-
kind: 'array',
|
|
79
|
-
items: { kind: 'primitive', type: 'string' },
|
|
80
|
-
},
|
|
81
|
-
required: false,
|
|
82
|
-
},
|
|
83
|
-
],
|
|
114
|
+
name: 'deleteOrganization',
|
|
115
|
+
httpMethod: 'delete',
|
|
116
|
+
path: '/organizations/{id}',
|
|
117
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
118
|
+
queryParams: [],
|
|
84
119
|
headerParams: [],
|
|
85
|
-
response: { kind: '
|
|
120
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
86
121
|
errors: [],
|
|
87
|
-
pagination: {
|
|
88
|
-
strategy: 'cursor',
|
|
89
|
-
param: 'after',
|
|
90
|
-
dataPath: 'data',
|
|
91
|
-
itemType: { kind: 'model', name: 'Organization' },
|
|
92
|
-
},
|
|
93
122
|
injectIdempotencyKey: false,
|
|
94
123
|
},
|
|
95
124
|
],
|
|
96
125
|
},
|
|
97
126
|
];
|
|
98
127
|
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
// Should have AutoPaginatable value import and fetchAndDeserialize import
|
|
103
|
-
expect(content).toContain("import { AutoPaginatable } from '../common/utils/pagination'");
|
|
104
|
-
expect(content).toContain("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'");
|
|
105
|
-
// Options interface lives in its own file under interfaces/ so the
|
|
106
|
-
// per-service barrel picks it up.
|
|
107
|
-
expect(content).toContain(
|
|
108
|
-
"import type { ListOrganizationsOptions } from './interfaces/list-organizations-options.interface';",
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
// The options interface file is emitted separately.
|
|
112
|
-
const optionsFile = files.find(
|
|
113
|
-
(f) => f.path === 'src/organizations/interfaces/list-organizations-options.interface.ts',
|
|
114
|
-
);
|
|
115
|
-
expect(optionsFile).toBeDefined();
|
|
116
|
-
expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
|
|
117
|
-
expect(optionsFile!.content).toContain('domains?: string[];');
|
|
118
|
-
|
|
119
|
-
// Should return AutoPaginatable
|
|
120
|
-
expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
|
|
128
|
+
const spec: ApiSpec = { ...emptySpec, services };
|
|
129
|
+
const ctxWithSpec: EmitterContext = { ...ctx, spec };
|
|
130
|
+
const result = generateResources(services, ctxWithSpec);
|
|
121
131
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
expect(content).
|
|
125
|
-
expect(content).toContain('new AutoPaginatable(');
|
|
126
|
-
expect(content).toContain('fetchAndDeserialize<OrganizationResponse, Organization>');
|
|
127
|
-
expect(content).toContain('options,');
|
|
132
|
+
const resourceFile = result.find((f) => f.path.includes('organizations.ts'));
|
|
133
|
+
expect(resourceFile).toBeDefined();
|
|
134
|
+
expect(resourceFile!.content).toContain('Promise<void>');
|
|
128
135
|
});
|
|
129
136
|
|
|
130
|
-
it('
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
},
|
|
163
|
-
],
|
|
164
|
-
},
|
|
165
|
-
];
|
|
166
|
-
|
|
167
|
-
const files = generateResources(services, ctx);
|
|
168
|
-
const content = files[0].content;
|
|
169
|
-
|
|
170
|
-
// Options interface uses camelCase (user-facing) and lives in its own file.
|
|
171
|
-
const optionsFile = files.find(
|
|
172
|
-
(f) => f.path === 'src/applications/interfaces/list-applications-options.interface.ts',
|
|
173
|
-
);
|
|
174
|
-
expect(optionsFile).toBeDefined();
|
|
175
|
-
expect(optionsFile!.content).toContain('export interface ListApplicationsOptions extends PaginationOptions {');
|
|
176
|
-
expect(optionsFile!.content).toContain('organizationId?: string;');
|
|
177
|
-
|
|
178
|
-
// Resource class imports the options type from the interface file.
|
|
179
|
-
expect(content).toContain(
|
|
180
|
-
"import type { ListApplicationsOptions } from './interfaces/list-applications-options.interface';",
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
// Wire-options serializer emits snake_case key for the extension field
|
|
184
|
-
// and leaves standard pagination fields unchanged.
|
|
185
|
-
expect(content).toContain(
|
|
186
|
-
'const serializeListApplicationsOptions = (options: ListApplicationsOptions): PaginationOptions => {',
|
|
187
|
-
);
|
|
188
|
-
expect(content).toContain('wire.organization_id = options.organizationId');
|
|
189
|
-
expect(content).not.toContain('wire.organizationId');
|
|
190
|
-
|
|
191
|
-
// fetchAndDeserialize is invoked with the serialized options.
|
|
192
|
-
expect(content).toContain('serializeListApplicationsOptions(options)');
|
|
193
|
-
expect(content).toContain('new AutoPaginatable(');
|
|
194
|
-
});
|
|
137
|
+
it('applies Node-only operation overrides without global operation hints', () => {
|
|
138
|
+
const operation = {
|
|
139
|
+
name: 'listOrganizationMembershipGroups',
|
|
140
|
+
httpMethod: 'get' as const,
|
|
141
|
+
path: '/user_management/organization_memberships/{omId}/groups',
|
|
142
|
+
pathParams: [{ name: 'omId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true }],
|
|
143
|
+
queryParams: [],
|
|
144
|
+
headerParams: [],
|
|
145
|
+
response: { kind: 'primitive' as const, type: 'unknown' as const },
|
|
146
|
+
errors: [],
|
|
147
|
+
injectIdempotencyKey: false,
|
|
148
|
+
};
|
|
149
|
+
const service: Service = {
|
|
150
|
+
name: 'UserManagementOrganizationMembershipGroups',
|
|
151
|
+
operations: [operation],
|
|
152
|
+
};
|
|
153
|
+
const spec: ApiSpec = { ...emptySpec, services: [service] };
|
|
154
|
+
const ctxWithResolved: EmitterContext = {
|
|
155
|
+
...ctx,
|
|
156
|
+
spec,
|
|
157
|
+
resolvedOperations: [
|
|
158
|
+
{
|
|
159
|
+
operation,
|
|
160
|
+
service,
|
|
161
|
+
methodName: 'list_organization_membership_groups',
|
|
162
|
+
mountOn: 'UserManagementOrganizationMembershipGroups',
|
|
163
|
+
defaults: {},
|
|
164
|
+
inferFromClient: [],
|
|
165
|
+
urlBuilder: false,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
195
169
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
170
|
+
const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
|
|
171
|
+
|
|
172
|
+
expect(result.some((f) => f.path.includes('user-management-organization-membership-groups'))).toBe(false);
|
|
173
|
+
const resourceFile = result.find((f) => f.path === 'src/user-management/user-management.ts');
|
|
174
|
+
expect(resourceFile).toBeDefined();
|
|
175
|
+
expect(resourceFile!.content).toContain('export class UserManagement');
|
|
176
|
+
expect(resourceFile!.content).toContain('async listGroupsForOrganizationMembership');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('drops brand-new service paths in an existing SDK by default', () => {
|
|
180
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
181
|
+
try {
|
|
182
|
+
const spec: ApiSpec = { ...emptySpec, services: [connectService] };
|
|
183
|
+
const result = nodeEmitter.generateResources(spec.services, { ...ctx, spec, outputDir: tmpRoot });
|
|
184
|
+
|
|
185
|
+
expect(result.some((f) => f.path === 'src/connect/connect.ts')).toBe(false);
|
|
186
|
+
} finally {
|
|
187
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('adopts brand-new service paths when configured', () => {
|
|
192
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
193
|
+
try {
|
|
194
|
+
const spec: ApiSpec = { ...emptySpec, services: [connectService] };
|
|
195
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
196
|
+
...ctx,
|
|
197
|
+
spec,
|
|
198
|
+
outputDir: tmpRoot,
|
|
199
|
+
emitterOptions: { adoptMissingServices: true },
|
|
200
|
+
} as EmitterContext);
|
|
201
|
+
|
|
202
|
+
const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
|
|
203
|
+
expect(resourceFile).toBeDefined();
|
|
204
|
+
expect(resourceFile!.content).toContain('export class Connect');
|
|
205
|
+
} finally {
|
|
206
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('overwrites tracked files for explicitly owned services', () => {
|
|
211
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
212
|
+
try {
|
|
213
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'groups'), { recursive: true });
|
|
214
|
+
fs.writeFileSync(
|
|
215
|
+
path.join(tmpRoot, 'src', 'groups', 'groups.ts'),
|
|
216
|
+
'export class Groups { async createGroup() {} }\n',
|
|
217
|
+
);
|
|
218
|
+
execFileSync('git', ['add', 'src/groups/groups.ts'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
219
|
+
|
|
220
|
+
const groupService: Service = {
|
|
221
|
+
name: 'Groups',
|
|
222
|
+
operations: [
|
|
223
|
+
{
|
|
224
|
+
name: 'createGroup',
|
|
225
|
+
httpMethod: 'post',
|
|
226
|
+
path: '/organizations/{organizationId}/groups',
|
|
227
|
+
pathParams: [{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
209
228
|
queryParams: [],
|
|
210
229
|
headerParams: [],
|
|
211
|
-
response: { kind: '
|
|
230
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
212
231
|
errors: [],
|
|
213
|
-
pagination: {
|
|
214
|
-
strategy: 'cursor',
|
|
215
|
-
param: 'after',
|
|
216
|
-
dataPath: 'data',
|
|
217
|
-
itemType: { kind: 'model', name: 'Connection' },
|
|
218
|
-
},
|
|
219
232
|
injectIdempotencyKey: false,
|
|
220
233
|
},
|
|
221
234
|
],
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
235
|
+
};
|
|
236
|
+
const spec: ApiSpec = { ...emptySpec, services: [groupService] };
|
|
237
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
238
|
+
...ctx,
|
|
239
|
+
spec,
|
|
240
|
+
outputDir: tmpRoot,
|
|
241
|
+
emitterOptions: { ownedServices: ['Groups'] },
|
|
242
|
+
apiSurface: {
|
|
243
|
+
classes: {
|
|
244
|
+
Groups: {
|
|
245
|
+
methods: {
|
|
246
|
+
createGroup: [{ name: 'createGroup', params: [], returnType: 'Promise<void>', async: true }],
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
} as any,
|
|
251
|
+
} as EmitterContext);
|
|
238
252
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
253
|
+
const resourceFile = result.find((f) => f.path === 'src/groups/groups.ts');
|
|
254
|
+
expect(resourceFile).toBeDefined();
|
|
255
|
+
expect(resourceFile!.overwriteExisting).toBe(true);
|
|
256
|
+
expect(resourceFile!.skipIfExists).toBe(false);
|
|
257
|
+
} finally {
|
|
258
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
259
|
+
}
|
|
242
260
|
});
|
|
243
261
|
|
|
244
|
-
it('
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
262
|
+
it('does not overwrite protected files for explicitly owned services', () => {
|
|
263
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
264
|
+
try {
|
|
265
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'groups'), { recursive: true });
|
|
266
|
+
fs.writeFileSync(
|
|
267
|
+
path.join(tmpRoot, 'src', 'groups', 'groups.ts'),
|
|
268
|
+
'// @oagen-ignore-file\nexport class Groups {}\n',
|
|
269
|
+
);
|
|
270
|
+
execFileSync('git', ['add', 'src/groups/groups.ts'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
271
|
+
|
|
272
|
+
const groupService: Service = {
|
|
273
|
+
name: 'Groups',
|
|
248
274
|
operations: [
|
|
249
275
|
{
|
|
250
|
-
name: '
|
|
251
|
-
httpMethod: '
|
|
252
|
-
path: '/organizations/{
|
|
253
|
-
pathParams: [
|
|
254
|
-
{
|
|
255
|
-
name: 'id',
|
|
256
|
-
type: { kind: 'primitive', type: 'string' },
|
|
257
|
-
required: true,
|
|
258
|
-
},
|
|
259
|
-
],
|
|
276
|
+
name: 'createGroup',
|
|
277
|
+
httpMethod: 'post',
|
|
278
|
+
path: '/organizations/{organizationId}/groups',
|
|
279
|
+
pathParams: [{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
260
280
|
queryParams: [],
|
|
261
281
|
headerParams: [],
|
|
262
282
|
response: { kind: 'primitive', type: 'unknown' },
|
|
@@ -264,1895 +284,215 @@ describe('generateResources', () => {
|
|
|
264
284
|
injectIdempotencyKey: false,
|
|
265
285
|
},
|
|
266
286
|
],
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
287
|
+
};
|
|
288
|
+
const spec: ApiSpec = { ...emptySpec, services: [groupService] };
|
|
289
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
290
|
+
...ctx,
|
|
291
|
+
spec,
|
|
292
|
+
outputDir: tmpRoot,
|
|
293
|
+
emitterOptions: { ownedServices: ['Groups'] },
|
|
294
|
+
} as EmitterContext);
|
|
295
|
+
|
|
296
|
+
expect(result.some((f) => f.path === 'src/groups/groups.ts')).toBe(false);
|
|
297
|
+
} finally {
|
|
298
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('treats prior-manifest generated files as managed before they are git-tracked', () => {
|
|
303
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
304
|
+
try {
|
|
305
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'connect'), { recursive: true });
|
|
306
|
+
fs.writeFileSync(
|
|
307
|
+
path.join(tmpRoot, 'src', 'connect', 'connect.ts'),
|
|
308
|
+
['// This file is auto-generated by oagen. Do not edit.', '', 'export class Connect {}'].join('\n'),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const spec: ApiSpec = { ...emptySpec, services: [connectService] };
|
|
312
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
313
|
+
...ctx,
|
|
314
|
+
spec,
|
|
315
|
+
outputDir: tmpRoot,
|
|
316
|
+
emitterOptions: { adoptMissingServices: true },
|
|
317
|
+
priorTargetManifestPaths: new Set(['src/connect/connect.ts']),
|
|
318
|
+
} as EmitterContext);
|
|
319
|
+
|
|
320
|
+
const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
|
|
321
|
+
expect(resourceFile).toBeDefined();
|
|
322
|
+
expect(resourceFile!.overwriteExisting).toBe(true);
|
|
323
|
+
} finally {
|
|
324
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('harvests serializer exports from prior-manifest generated files before they are git-tracked', () => {
|
|
329
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
330
|
+
try {
|
|
331
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'connect', 'serializers'), { recursive: true });
|
|
332
|
+
fs.writeFileSync(
|
|
333
|
+
path.join(tmpRoot, 'src', 'connect', 'serializers', 'create-m2m-application.serializer.ts'),
|
|
334
|
+
[
|
|
335
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
336
|
+
'',
|
|
337
|
+
'export const serializeCreateM2MApplication = (payload: unknown): unknown => payload;',
|
|
338
|
+
].join('\n'),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const service: Service = {
|
|
342
|
+
name: 'Connect',
|
|
284
343
|
operations: [
|
|
285
344
|
{
|
|
286
|
-
name: '
|
|
287
|
-
httpMethod: '
|
|
288
|
-
path: '/applications
|
|
289
|
-
pathParams: [
|
|
290
|
-
{
|
|
291
|
-
name: 'id',
|
|
292
|
-
type: { kind: 'primitive', type: 'string' },
|
|
293
|
-
required: true,
|
|
294
|
-
},
|
|
295
|
-
],
|
|
345
|
+
name: 'createApplication',
|
|
346
|
+
httpMethod: 'post',
|
|
347
|
+
path: '/connect/applications',
|
|
348
|
+
pathParams: [],
|
|
296
349
|
queryParams: [],
|
|
297
350
|
headerParams: [],
|
|
298
|
-
|
|
351
|
+
requestBody: { kind: 'model', name: 'CreateM2MApplication' },
|
|
352
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
299
353
|
errors: [],
|
|
300
354
|
injectIdempotencyKey: false,
|
|
301
355
|
},
|
|
302
356
|
],
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
357
|
+
};
|
|
358
|
+
const spec: ApiSpec = {
|
|
359
|
+
...emptySpec,
|
|
360
|
+
services: [service],
|
|
361
|
+
models: [
|
|
362
|
+
{
|
|
363
|
+
name: 'CreateM2MApplication',
|
|
364
|
+
fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
};
|
|
368
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
369
|
+
...ctx,
|
|
370
|
+
spec,
|
|
371
|
+
outputDir: tmpRoot,
|
|
372
|
+
emitterOptions: { adoptMissingServices: true },
|
|
373
|
+
priorTargetManifestPaths: new Set([
|
|
374
|
+
'src/connect/connect.ts',
|
|
375
|
+
'src/connect/serializers/create-m2m-application.serializer.ts',
|
|
376
|
+
]),
|
|
377
|
+
} as EmitterContext);
|
|
308
378
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
379
|
+
const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
|
|
380
|
+
expect(resourceFile?.content).toContain(
|
|
381
|
+
"import { serializeCreateM2MApplication } from './serializers/create-m2m-application.serializer';",
|
|
382
|
+
);
|
|
383
|
+
} finally {
|
|
384
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
385
|
+
}
|
|
315
386
|
});
|
|
387
|
+
});
|
|
316
388
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
pathParams: [],
|
|
327
|
-
queryParams: [],
|
|
328
|
-
headerParams: [],
|
|
329
|
-
requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
|
|
330
|
-
response: { kind: 'model', name: 'Organization' },
|
|
331
|
-
errors: [],
|
|
332
|
-
injectIdempotencyKey: true,
|
|
389
|
+
describe('resolveResourceClassName', () => {
|
|
390
|
+
it('uses overlay name when baseline has compatible constructor', () => {
|
|
391
|
+
const service: Service = { name: 'Organizations', operations: [] };
|
|
392
|
+
const ctxWithBaseline: EmitterContext = {
|
|
393
|
+
...ctx,
|
|
394
|
+
apiSurface: {
|
|
395
|
+
classes: {
|
|
396
|
+
Organizations: {
|
|
397
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
333
398
|
},
|
|
334
|
-
|
|
335
|
-
},
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const files = generateResources(services, ctx);
|
|
339
|
-
const content = files[0].content;
|
|
340
|
-
expect(content).toContain(
|
|
341
|
-
'async createOrganization(payload: CreateOrganizationInput, requestOptions: PostOptions = {}): Promise<Organization>',
|
|
342
|
-
);
|
|
343
|
-
expect(content).toContain('serializeCreateOrganizationInput(payload)');
|
|
344
|
-
expect(content).toContain('requestOptions,');
|
|
399
|
+
},
|
|
400
|
+
} as any,
|
|
401
|
+
};
|
|
402
|
+
expect(resolveResourceClassName(service, ctxWithBaseline)).toBe('Organizations');
|
|
345
403
|
});
|
|
346
404
|
|
|
347
|
-
it('
|
|
348
|
-
const
|
|
349
|
-
name: '
|
|
405
|
+
it('appends Endpoints suffix when IR name collides with overlay name', () => {
|
|
406
|
+
const service: Service = {
|
|
407
|
+
name: 'Webhooks',
|
|
350
408
|
operations: [
|
|
351
409
|
{
|
|
352
|
-
name: '
|
|
353
|
-
httpMethod: '
|
|
354
|
-
path: '/
|
|
410
|
+
name: 'listWebhooks',
|
|
411
|
+
httpMethod: 'get',
|
|
412
|
+
path: '/webhooks',
|
|
355
413
|
pathParams: [],
|
|
356
414
|
queryParams: [],
|
|
357
415
|
headerParams: [],
|
|
358
|
-
|
|
359
|
-
response: { kind: 'model', name: 'AuthenticationFactor' },
|
|
416
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
360
417
|
errors: [],
|
|
361
|
-
injectIdempotencyKey:
|
|
418
|
+
injectIdempotencyKey: false,
|
|
362
419
|
},
|
|
363
420
|
],
|
|
364
421
|
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
requiredExports: new Map(),
|
|
386
|
-
modelNameByIR: new Map(),
|
|
387
|
-
fileBySymbol: new Map(),
|
|
388
|
-
},
|
|
422
|
+
const ctxWithIncompat: EmitterContext = {
|
|
423
|
+
...ctx,
|
|
424
|
+
apiSurface: {
|
|
425
|
+
classes: {
|
|
426
|
+
Webhooks: {
|
|
427
|
+
constructorParams: [{ name: 'crypto', type: 'CryptoProvider' }],
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
} as any,
|
|
431
|
+
resolvedOperations: [
|
|
432
|
+
{
|
|
433
|
+
operation: service.operations[0],
|
|
434
|
+
service,
|
|
435
|
+
methodName: 'list_webhooks',
|
|
436
|
+
mountOn: 'Webhooks',
|
|
437
|
+
defaults: {},
|
|
438
|
+
inferFromClient: [],
|
|
439
|
+
urlBuilder: false,
|
|
440
|
+
},
|
|
441
|
+
],
|
|
389
442
|
};
|
|
443
|
+
expect(resolveResourceClassName(service, ctxWithIncompat)).toBe('WebhooksEndpoints');
|
|
444
|
+
expect(resolveResourceDir(service, ctxWithIncompat)).toBe('webhooks');
|
|
390
445
|
|
|
391
|
-
const
|
|
392
|
-
expect(
|
|
393
|
-
expect(
|
|
394
|
-
|
|
395
|
-
const content = files[0].content;
|
|
396
|
-
expect(content).toContain('export class Mfa {');
|
|
446
|
+
const result = generateResources([service], { ...ctxWithIncompat, spec: { ...emptySpec, services: [service] } });
|
|
447
|
+
expect(result.some((f) => f.path === 'src/webhooks/webhooks-endpoints.ts')).toBe(true);
|
|
448
|
+
expect(result.some((f) => f.path === 'src/webhooks-endpoints/webhooks-endpoints.ts')).toBe(false);
|
|
397
449
|
});
|
|
450
|
+
});
|
|
398
451
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
name: 'Radar',
|
|
403
|
-
operations: [
|
|
404
|
-
{
|
|
405
|
-
name: 'updateAttempt',
|
|
406
|
-
description: 'Update a Radar attempt\n\nYou may optionally inform Radar that an attempt was successful.',
|
|
407
|
-
httpMethod: 'put',
|
|
408
|
-
path: '/radar/attempts/{id}',
|
|
409
|
-
pathParams: [
|
|
410
|
-
{
|
|
411
|
-
name: 'id',
|
|
412
|
-
type: { kind: 'primitive', type: 'string' },
|
|
413
|
-
required: true,
|
|
414
|
-
description: 'The unique identifier of the attempt.',
|
|
415
|
-
},
|
|
416
|
-
],
|
|
417
|
-
queryParams: [],
|
|
418
|
-
headerParams: [],
|
|
419
|
-
requestBody: { kind: 'model', name: 'UpdateAttemptInput' },
|
|
420
|
-
response: { kind: 'model', name: 'RadarAttempt' },
|
|
421
|
-
errors: [],
|
|
422
|
-
injectIdempotencyKey: false,
|
|
423
|
-
deprecated: true,
|
|
424
|
-
},
|
|
425
|
-
],
|
|
426
|
-
},
|
|
427
|
-
];
|
|
428
|
-
|
|
429
|
-
const files = generateResources(services, ctx);
|
|
430
|
-
const content = files[0].content;
|
|
431
|
-
|
|
432
|
-
expect(content).toContain(' /**');
|
|
433
|
-
expect(content).toContain(' * Update a Radar attempt');
|
|
434
|
-
expect(content).toContain(' *');
|
|
435
|
-
expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
|
|
436
|
-
expect(content).toContain(' * @param id - The unique identifier of the attempt.');
|
|
437
|
-
expect(content).toContain(' * @returns {Promise<RadarAttempt>}');
|
|
438
|
-
expect(content).toContain(' * @deprecated');
|
|
439
|
-
expect(content).toContain(' */');
|
|
452
|
+
describe('hasCompatibleConstructor', () => {
|
|
453
|
+
it('returns true when no baseline exists', () => {
|
|
454
|
+
expect(hasCompatibleConstructor('NewService', ctx)).toBe(true);
|
|
440
455
|
});
|
|
441
456
|
|
|
442
|
-
it('
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
{
|
|
448
|
-
name: '
|
|
449
|
-
httpMethod: 'get',
|
|
450
|
-
path: '/organizations/{id}',
|
|
451
|
-
pathParams: [
|
|
452
|
-
{
|
|
453
|
-
name: 'id',
|
|
454
|
-
type: { kind: 'primitive', type: 'string' },
|
|
455
|
-
required: true,
|
|
456
|
-
},
|
|
457
|
-
],
|
|
458
|
-
queryParams: [],
|
|
459
|
-
headerParams: [],
|
|
460
|
-
response: { kind: 'model', name: 'Organization' },
|
|
461
|
-
errors: [],
|
|
462
|
-
injectIdempotencyKey: false,
|
|
457
|
+
it('returns true when baseline has workos: WorkOS param', () => {
|
|
458
|
+
const ctxWithBaseline: EmitterContext = {
|
|
459
|
+
...ctx,
|
|
460
|
+
apiSurface: {
|
|
461
|
+
classes: {
|
|
462
|
+
Organizations: {
|
|
463
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
463
464
|
},
|
|
464
|
-
|
|
465
|
-
},
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const files = generateResources(services, ctx);
|
|
469
|
-
const content = files[0].content;
|
|
470
|
-
expect(content).toContain('@returns {Promise<Organization>}');
|
|
465
|
+
},
|
|
466
|
+
} as any,
|
|
467
|
+
};
|
|
468
|
+
expect(hasCompatibleConstructor('Organizations', ctxWithBaseline)).toBe(true);
|
|
471
469
|
});
|
|
472
470
|
|
|
473
|
-
it('
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
{
|
|
479
|
-
name: '
|
|
480
|
-
httpMethod: 'post',
|
|
481
|
-
path: '/authorization/roles',
|
|
482
|
-
pathParams: [],
|
|
483
|
-
queryParams: [],
|
|
484
|
-
headerParams: [],
|
|
485
|
-
requestBody: { kind: 'model', name: 'CreateRoleInput' },
|
|
486
|
-
response: { kind: 'model', name: 'Role' },
|
|
487
|
-
errors: [],
|
|
488
|
-
injectIdempotencyKey: false,
|
|
471
|
+
it('returns false when baseline has incompatible constructor', () => {
|
|
472
|
+
const ctxWithIncompat: EmitterContext = {
|
|
473
|
+
...ctx,
|
|
474
|
+
apiSurface: {
|
|
475
|
+
classes: {
|
|
476
|
+
Webhooks: {
|
|
477
|
+
constructorParams: [{ name: 'crypto', type: 'CryptoProvider' }],
|
|
489
478
|
},
|
|
490
|
-
|
|
491
|
-
},
|
|
492
|
-
];
|
|
493
|
-
|
|
494
|
-
const overlayCtx: EmitterContext = {
|
|
495
|
-
namespace: 'workos',
|
|
496
|
-
namespacePascal: 'WorkOS',
|
|
497
|
-
spec: { ...emptySpec, services, models: [] },
|
|
498
|
-
overlayLookup: {
|
|
499
|
-
methodByOperation: new Map([
|
|
500
|
-
[
|
|
501
|
-
'POST /authorization/roles',
|
|
502
|
-
{
|
|
503
|
-
className: 'Authorization',
|
|
504
|
-
methodName: 'createEnvironmentRole',
|
|
505
|
-
params: [{ name: 'payload', type: 'CreateRoleInput', optional: false }],
|
|
506
|
-
returnType: 'Promise<EnvironmentRole>',
|
|
507
|
-
},
|
|
508
|
-
],
|
|
509
|
-
]),
|
|
510
|
-
httpKeyByMethod: new Map(),
|
|
511
|
-
interfaceByName: new Map(),
|
|
512
|
-
typeAliasByName: new Map(),
|
|
513
|
-
requiredExports: new Map(),
|
|
514
|
-
modelNameByIR: new Map(),
|
|
515
|
-
fileBySymbol: new Map(),
|
|
516
|
-
},
|
|
479
|
+
},
|
|
480
|
+
} as any,
|
|
517
481
|
};
|
|
518
|
-
|
|
519
|
-
const files = generateResources(services, overlayCtx);
|
|
520
|
-
const content = files[0].content;
|
|
521
|
-
// JSDoc should use the overlay return type, not the spec schema name
|
|
522
|
-
expect(content).toContain('@returns {Promise<EnvironmentRole>}');
|
|
523
|
-
expect(content).not.toContain('@returns {Role}');
|
|
524
|
-
expect(content).not.toContain('@returns {Promise<Role>}');
|
|
482
|
+
expect(hasCompatibleConstructor('Webhooks', ctxWithIncompat)).toBe(false);
|
|
525
483
|
});
|
|
526
484
|
|
|
527
|
-
it('
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
{
|
|
533
|
-
|
|
534
|
-
httpMethod: 'get',
|
|
535
|
-
path: '/organizations/{id}',
|
|
536
|
-
pathParams: [
|
|
537
|
-
{
|
|
538
|
-
name: 'id',
|
|
539
|
-
type: { kind: 'primitive', type: 'string' },
|
|
540
|
-
required: true,
|
|
541
|
-
},
|
|
542
|
-
],
|
|
543
|
-
queryParams: [
|
|
544
|
-
{
|
|
545
|
-
name: 'include_fields',
|
|
546
|
-
type: { kind: 'primitive', type: 'string' },
|
|
547
|
-
required: false,
|
|
548
|
-
description: 'Comma-separated list of fields to include.',
|
|
549
|
-
},
|
|
550
|
-
],
|
|
551
|
-
headerParams: [],
|
|
552
|
-
response: { kind: 'model', name: 'Organization' },
|
|
553
|
-
errors: [],
|
|
554
|
-
injectIdempotencyKey: false,
|
|
485
|
+
it('returns true when baseline has no constructor params', () => {
|
|
486
|
+
const ctxWithEmptyCtor: EmitterContext = {
|
|
487
|
+
...ctx,
|
|
488
|
+
apiSurface: {
|
|
489
|
+
classes: {
|
|
490
|
+
Utils: {
|
|
491
|
+
constructorParams: [],
|
|
555
492
|
},
|
|
556
|
-
],
|
|
557
|
-
},
|
|
558
|
-
];
|
|
559
|
-
|
|
560
|
-
const files = generateResources(services, ctx);
|
|
561
|
-
const content = files[0].content;
|
|
562
|
-
expect(content).toContain('@param options.includeFields - Comma-separated list of fields to include.');
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
it('renders header and cookie param docs', () => {
|
|
566
|
-
const services: Service[] = [
|
|
567
|
-
{
|
|
568
|
-
name: 'Sessions',
|
|
569
|
-
operations: [
|
|
570
|
-
{
|
|
571
|
-
name: 'getSession',
|
|
572
|
-
httpMethod: 'get',
|
|
573
|
-
path: '/sessions/{id}',
|
|
574
|
-
pathParams: [
|
|
575
|
-
{
|
|
576
|
-
name: 'id',
|
|
577
|
-
type: { kind: 'primitive', type: 'string' },
|
|
578
|
-
required: true,
|
|
579
|
-
},
|
|
580
|
-
],
|
|
581
|
-
queryParams: [],
|
|
582
|
-
headerParams: [
|
|
583
|
-
{
|
|
584
|
-
name: 'X-Request-Id',
|
|
585
|
-
type: { kind: 'primitive', type: 'string' },
|
|
586
|
-
required: false,
|
|
587
|
-
description: 'Unique request identifier.',
|
|
588
|
-
},
|
|
589
|
-
],
|
|
590
|
-
cookieParams: [
|
|
591
|
-
{
|
|
592
|
-
name: 'session_token',
|
|
593
|
-
type: { kind: 'primitive', type: 'string' },
|
|
594
|
-
required: true,
|
|
595
|
-
description: 'The session cookie.',
|
|
596
|
-
},
|
|
597
|
-
],
|
|
598
|
-
response: { kind: 'model', name: 'Session' },
|
|
599
|
-
errors: [],
|
|
600
|
-
injectIdempotencyKey: false,
|
|
601
|
-
},
|
|
602
|
-
],
|
|
603
|
-
},
|
|
604
|
-
];
|
|
605
|
-
|
|
606
|
-
const files = generateResources(services, ctx);
|
|
607
|
-
const content = files[0].content;
|
|
608
|
-
// Header and cookie params are intentionally NOT documented in JSDoc —
|
|
609
|
-
// they are not exposed in the method signature (handled internally by the SDK).
|
|
610
|
-
expect(content).not.toContain('@param xRequestId');
|
|
611
|
-
expect(content).not.toContain('@param sessionToken');
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
it('renders single @returns without status-code duplicates', () => {
|
|
615
|
-
const services: Service[] = [
|
|
616
|
-
{
|
|
617
|
-
name: 'Organizations',
|
|
618
|
-
operations: [
|
|
619
|
-
{
|
|
620
|
-
name: 'createOrganization',
|
|
621
|
-
httpMethod: 'post',
|
|
622
|
-
path: '/organizations',
|
|
623
|
-
pathParams: [],
|
|
624
|
-
queryParams: [],
|
|
625
|
-
headerParams: [],
|
|
626
|
-
requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
|
|
627
|
-
response: { kind: 'model', name: 'Organization' },
|
|
628
|
-
successResponses: [
|
|
629
|
-
{
|
|
630
|
-
statusCode: 200,
|
|
631
|
-
type: { kind: 'model', name: 'Organization' },
|
|
632
|
-
},
|
|
633
|
-
{
|
|
634
|
-
statusCode: 201,
|
|
635
|
-
type: { kind: 'model', name: 'Organization' },
|
|
636
|
-
},
|
|
637
|
-
],
|
|
638
|
-
errors: [],
|
|
639
|
-
injectIdempotencyKey: false,
|
|
640
|
-
},
|
|
641
|
-
],
|
|
642
|
-
},
|
|
643
|
-
];
|
|
644
|
-
|
|
645
|
-
const files = generateResources(services, ctx);
|
|
646
|
-
const content = files[0].content;
|
|
647
|
-
// Only emit a single @returns for the primary response model (no status-code variants)
|
|
648
|
-
expect(content).toContain('@returns {Promise<Organization>}');
|
|
649
|
-
expect(content).not.toContain('@returns {Promise<Organization>} 200');
|
|
650
|
-
expect(content).not.toContain('@returns {Promise<Organization>} 201');
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
it('generates DELETE-with-body method using deleteWithBody', () => {
|
|
654
|
-
const services: Service[] = [
|
|
655
|
-
{
|
|
656
|
-
name: 'Radar',
|
|
657
|
-
operations: [
|
|
658
|
-
{
|
|
659
|
-
name: 'deleteRadarListEntry',
|
|
660
|
-
httpMethod: 'delete',
|
|
661
|
-
path: '/radar/lists/{listId}/entries',
|
|
662
|
-
pathParams: [
|
|
663
|
-
{
|
|
664
|
-
name: 'listId',
|
|
665
|
-
type: { kind: 'primitive', type: 'string' },
|
|
666
|
-
required: true,
|
|
667
|
-
},
|
|
668
|
-
],
|
|
669
|
-
queryParams: [],
|
|
670
|
-
headerParams: [],
|
|
671
|
-
requestBody: { kind: 'model', name: 'DeleteRadarListEntryInput' },
|
|
672
|
-
response: { kind: 'primitive', type: 'unknown' },
|
|
673
|
-
errors: [],
|
|
674
|
-
injectIdempotencyKey: false,
|
|
675
|
-
},
|
|
676
|
-
],
|
|
677
|
-
},
|
|
678
|
-
];
|
|
679
|
-
|
|
680
|
-
const files = generateResources(services, ctx);
|
|
681
|
-
const content = files[0].content;
|
|
682
|
-
expect(content).toContain(
|
|
683
|
-
'async deleteRadarListEntry(listId: string, payload: DeleteRadarListEntryInput): Promise<void>',
|
|
684
|
-
);
|
|
685
|
-
expect(content).toContain('await this.workos.deleteWithBody(');
|
|
686
|
-
expect(content).toContain('serializeDeleteRadarListEntryInput(payload)');
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
it('renders deprecated path params', () => {
|
|
690
|
-
const services: Service[] = [
|
|
691
|
-
{
|
|
692
|
-
name: 'Organizations',
|
|
693
|
-
operations: [
|
|
694
|
-
{
|
|
695
|
-
name: 'getOrganization',
|
|
696
|
-
httpMethod: 'get',
|
|
697
|
-
path: '/organizations/{slug}',
|
|
698
|
-
pathParams: [
|
|
699
|
-
{
|
|
700
|
-
name: 'slug',
|
|
701
|
-
type: { kind: 'primitive', type: 'string' },
|
|
702
|
-
required: true,
|
|
703
|
-
description: 'The organization slug.',
|
|
704
|
-
deprecated: true,
|
|
705
|
-
},
|
|
706
|
-
],
|
|
707
|
-
queryParams: [],
|
|
708
|
-
headerParams: [],
|
|
709
|
-
response: { kind: 'model', name: 'Organization' },
|
|
710
|
-
errors: [],
|
|
711
|
-
injectIdempotencyKey: false,
|
|
712
|
-
},
|
|
713
|
-
],
|
|
714
|
-
},
|
|
715
|
-
];
|
|
716
|
-
|
|
717
|
-
const files = generateResources(services, ctx);
|
|
718
|
-
const content = files[0].content;
|
|
719
|
-
expect(content).toContain('@param slug - (deprecated) The organization slug.');
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
it('generates typed options interface for non-paginated GET with query params', () => {
|
|
723
|
-
const services: Service[] = [
|
|
724
|
-
{
|
|
725
|
-
name: 'Organizations',
|
|
726
|
-
operations: [
|
|
727
|
-
{
|
|
728
|
-
name: 'getOrganization',
|
|
729
|
-
httpMethod: 'get',
|
|
730
|
-
path: '/organizations/{id}',
|
|
731
|
-
pathParams: [
|
|
732
|
-
{
|
|
733
|
-
name: 'id',
|
|
734
|
-
type: { kind: 'primitive', type: 'string' },
|
|
735
|
-
required: true,
|
|
736
|
-
},
|
|
737
|
-
],
|
|
738
|
-
queryParams: [
|
|
739
|
-
{
|
|
740
|
-
name: 'include_fields',
|
|
741
|
-
type: { kind: 'primitive', type: 'string' },
|
|
742
|
-
required: false,
|
|
743
|
-
description: 'Comma-separated list of fields to include.',
|
|
744
|
-
},
|
|
745
|
-
],
|
|
746
|
-
headerParams: [],
|
|
747
|
-
response: { kind: 'model', name: 'Organization' },
|
|
748
|
-
errors: [],
|
|
749
|
-
injectIdempotencyKey: false,
|
|
750
|
-
},
|
|
751
|
-
],
|
|
752
|
-
},
|
|
753
|
-
];
|
|
754
|
-
|
|
755
|
-
const files = generateResources(services, ctx);
|
|
756
|
-
const content = files[0].content;
|
|
757
|
-
|
|
758
|
-
// Should generate a typed options interface
|
|
759
|
-
expect(content).toContain('export interface GetOrganizationOptions {');
|
|
760
|
-
expect(content).toContain('includeFields?: string;');
|
|
761
|
-
|
|
762
|
-
// Should use the typed options in the method signature
|
|
763
|
-
expect(content).toContain(
|
|
764
|
-
'async getOrganization(id: string, options?: GetOrganizationOptions): Promise<Organization>',
|
|
765
|
-
);
|
|
766
|
-
|
|
767
|
-
// Should NOT use Record<string, unknown>
|
|
768
|
-
expect(content).not.toContain('Record<string, unknown>');
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
it('generates typed options interface for void GET with query params', () => {
|
|
772
|
-
const services: Service[] = [
|
|
773
|
-
{
|
|
774
|
-
name: 'Auth',
|
|
775
|
-
operations: [
|
|
776
|
-
{
|
|
777
|
-
name: 'authorize',
|
|
778
|
-
httpMethod: 'get',
|
|
779
|
-
path: '/user_management/authorize',
|
|
780
|
-
pathParams: [],
|
|
781
|
-
queryParams: [
|
|
782
|
-
{
|
|
783
|
-
name: 'client_id',
|
|
784
|
-
type: { kind: 'primitive', type: 'string' },
|
|
785
|
-
required: true,
|
|
786
|
-
},
|
|
787
|
-
{
|
|
788
|
-
name: 'redirect_uri',
|
|
789
|
-
type: { kind: 'primitive', type: 'string' },
|
|
790
|
-
required: true,
|
|
791
|
-
},
|
|
792
|
-
{
|
|
793
|
-
name: 'response_type',
|
|
794
|
-
type: { kind: 'primitive', type: 'string' },
|
|
795
|
-
required: true,
|
|
796
|
-
},
|
|
797
|
-
],
|
|
798
|
-
headerParams: [],
|
|
799
|
-
response: { kind: 'primitive', type: 'unknown' },
|
|
800
|
-
errors: [],
|
|
801
|
-
injectIdempotencyKey: false,
|
|
802
|
-
},
|
|
803
|
-
],
|
|
804
|
-
},
|
|
805
|
-
];
|
|
806
|
-
|
|
807
|
-
const files = generateResources(services, ctx);
|
|
808
|
-
const content = files[0].content;
|
|
809
|
-
|
|
810
|
-
// Should generate a typed options interface
|
|
811
|
-
expect(content).toContain('export interface AuthorizeOptions {');
|
|
812
|
-
expect(content).toContain('clientId: string;');
|
|
813
|
-
expect(content).toContain('redirectUri: string;');
|
|
814
|
-
expect(content).toContain('responseType: string;');
|
|
815
|
-
|
|
816
|
-
// Should use the typed options in the method signature
|
|
817
|
-
expect(content).toContain('async authorize(options?: AuthorizeOptions): Promise<void>');
|
|
818
|
-
|
|
819
|
-
// Should pass options as query params
|
|
820
|
-
expect(content).toContain('query: options');
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
it('falls back to pass-through for non-discriminated union when models not in spec', () => {
|
|
824
|
-
const services: Service[] = [
|
|
825
|
-
{
|
|
826
|
-
name: 'Auth',
|
|
827
|
-
operations: [
|
|
828
|
-
{
|
|
829
|
-
name: 'authenticate',
|
|
830
|
-
httpMethod: 'post',
|
|
831
|
-
path: '/user_management/authenticate',
|
|
832
|
-
pathParams: [],
|
|
833
|
-
queryParams: [],
|
|
834
|
-
headerParams: [],
|
|
835
|
-
requestBody: {
|
|
836
|
-
kind: 'union',
|
|
837
|
-
variants: [
|
|
838
|
-
{ kind: 'model', name: 'AuthByPassword' },
|
|
839
|
-
{ kind: 'model', name: 'AuthByCode' },
|
|
840
|
-
{ kind: 'model', name: 'AuthByMagicAuth' },
|
|
841
|
-
],
|
|
842
|
-
},
|
|
843
|
-
response: { kind: 'model', name: 'AuthenticateResponse' },
|
|
844
|
-
errors: [],
|
|
845
|
-
injectIdempotencyKey: false,
|
|
846
|
-
},
|
|
847
|
-
],
|
|
848
|
-
},
|
|
849
|
-
];
|
|
850
|
-
|
|
851
|
-
const files = generateResources(services, ctx);
|
|
852
|
-
const content = files[0].content;
|
|
853
|
-
|
|
854
|
-
// Should use the union type for the payload parameter
|
|
855
|
-
expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
|
|
856
|
-
|
|
857
|
-
// Should NOT use Record<string, unknown>
|
|
858
|
-
expect(content).not.toContain('Record<string, unknown>');
|
|
859
|
-
|
|
860
|
-
// Models not in spec → falls back to pass-through
|
|
861
|
-
expect(content).toContain("'/user_management/authenticate',");
|
|
862
|
-
expect(content).toContain('payload,');
|
|
863
|
-
|
|
864
|
-
// Should import all union variant types
|
|
865
|
-
expect(content).toContain('AuthByPassword');
|
|
866
|
-
expect(content).toContain('AuthByCode');
|
|
867
|
-
expect(content).toContain('AuthByMagicAuth');
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
it('generates field-guard serializer dispatch for non-discriminated union with models', () => {
|
|
871
|
-
const services: Service[] = [
|
|
872
|
-
{
|
|
873
|
-
name: 'Applications',
|
|
874
|
-
operations: [
|
|
875
|
-
{
|
|
876
|
-
name: 'createApplication',
|
|
877
|
-
httpMethod: 'post',
|
|
878
|
-
path: '/connect/applications',
|
|
879
|
-
pathParams: [],
|
|
880
|
-
queryParams: [],
|
|
881
|
-
headerParams: [],
|
|
882
|
-
requestBody: {
|
|
883
|
-
kind: 'union',
|
|
884
|
-
variants: [
|
|
885
|
-
{ kind: 'model', name: 'CreateOAuthApplication' },
|
|
886
|
-
{ kind: 'model', name: 'CreateM2MApplication' },
|
|
887
|
-
],
|
|
888
|
-
},
|
|
889
|
-
response: { kind: 'model', name: 'ConnectApplication' },
|
|
890
|
-
errors: [],
|
|
891
|
-
injectIdempotencyKey: false,
|
|
892
|
-
},
|
|
893
|
-
],
|
|
894
|
-
},
|
|
895
|
-
];
|
|
896
|
-
|
|
897
|
-
const testCtx: EmitterContext = {
|
|
898
|
-
namespace: 'workos',
|
|
899
|
-
namespacePascal: 'WorkOS',
|
|
900
|
-
spec: {
|
|
901
|
-
...emptySpec,
|
|
902
|
-
services,
|
|
903
|
-
models: [
|
|
904
|
-
{
|
|
905
|
-
name: 'CreateOAuthApplication',
|
|
906
|
-
fields: [
|
|
907
|
-
{
|
|
908
|
-
name: 'name',
|
|
909
|
-
type: { kind: 'primitive', type: 'string' },
|
|
910
|
-
required: true,
|
|
911
|
-
},
|
|
912
|
-
{
|
|
913
|
-
name: 'redirect_uris',
|
|
914
|
-
type: {
|
|
915
|
-
kind: 'array',
|
|
916
|
-
items: { kind: 'primitive', type: 'string' },
|
|
917
|
-
},
|
|
918
|
-
required: true,
|
|
919
|
-
},
|
|
920
|
-
{
|
|
921
|
-
name: 'uses_pkce',
|
|
922
|
-
type: { kind: 'primitive', type: 'boolean' },
|
|
923
|
-
required: false,
|
|
924
|
-
},
|
|
925
|
-
],
|
|
926
|
-
},
|
|
927
|
-
{
|
|
928
|
-
name: 'CreateM2MApplication',
|
|
929
|
-
fields: [
|
|
930
|
-
{
|
|
931
|
-
name: 'name',
|
|
932
|
-
type: { kind: 'primitive', type: 'string' },
|
|
933
|
-
required: true,
|
|
934
|
-
},
|
|
935
|
-
{
|
|
936
|
-
name: 'scopes',
|
|
937
|
-
type: {
|
|
938
|
-
kind: 'array',
|
|
939
|
-
items: { kind: 'primitive', type: 'string' },
|
|
940
|
-
},
|
|
941
|
-
required: true,
|
|
942
|
-
},
|
|
943
|
-
],
|
|
944
|
-
},
|
|
945
|
-
{
|
|
946
|
-
name: 'ConnectApplication',
|
|
947
|
-
fields: [
|
|
948
|
-
{
|
|
949
|
-
name: 'id',
|
|
950
|
-
type: { kind: 'primitive', type: 'string' },
|
|
951
|
-
required: true,
|
|
952
|
-
},
|
|
953
|
-
],
|
|
954
|
-
},
|
|
955
|
-
],
|
|
956
|
-
},
|
|
957
|
-
};
|
|
958
|
-
|
|
959
|
-
const files = generateResources(services, testCtx);
|
|
960
|
-
const content = files[0].content;
|
|
961
|
-
|
|
962
|
-
// Should use the union type for the payload parameter
|
|
963
|
-
expect(content).toContain('payload: CreateOAuthApplication | CreateM2MApplication');
|
|
964
|
-
|
|
965
|
-
// Should dispatch via unique required field guards
|
|
966
|
-
expect(content).toContain("'redirectUris' in payload");
|
|
967
|
-
expect(content).toContain('serializeCreateOAuthApplication(payload as any)');
|
|
968
|
-
expect(content).toContain('serializeCreateM2MApplication(payload as any)');
|
|
969
|
-
|
|
970
|
-
// Should import serializers for all union variants
|
|
971
|
-
expect(content).toContain('serializeCreateOAuthApplication');
|
|
972
|
-
expect(content).toContain('serializeCreateM2MApplication');
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
it('generates discriminated union serializer dispatch for request body', () => {
|
|
976
|
-
const services: Service[] = [
|
|
977
|
-
{
|
|
978
|
-
name: 'Auth',
|
|
979
|
-
operations: [
|
|
980
|
-
{
|
|
981
|
-
name: 'authenticate',
|
|
982
|
-
httpMethod: 'post',
|
|
983
|
-
path: '/user_management/authenticate',
|
|
984
|
-
pathParams: [],
|
|
985
|
-
queryParams: [],
|
|
986
|
-
headerParams: [],
|
|
987
|
-
requestBody: {
|
|
988
|
-
kind: 'union',
|
|
989
|
-
variants: [
|
|
990
|
-
{ kind: 'model', name: 'AuthByPassword' },
|
|
991
|
-
{ kind: 'model', name: 'AuthByCode' },
|
|
992
|
-
{ kind: 'model', name: 'AuthByMagicAuth' },
|
|
993
|
-
],
|
|
994
|
-
discriminator: {
|
|
995
|
-
property: 'grant_type',
|
|
996
|
-
mapping: {
|
|
997
|
-
password: 'AuthByPassword',
|
|
998
|
-
authorization_code: 'AuthByCode',
|
|
999
|
-
'urn:workos:oauth:grant-type:magic-auth:code': 'AuthByMagicAuth',
|
|
1000
|
-
},
|
|
1001
|
-
},
|
|
1002
|
-
},
|
|
1003
|
-
response: { kind: 'model', name: 'AuthenticateResponse' },
|
|
1004
|
-
errors: [],
|
|
1005
|
-
injectIdempotencyKey: false,
|
|
1006
|
-
},
|
|
1007
|
-
],
|
|
1008
|
-
},
|
|
1009
|
-
];
|
|
1010
|
-
|
|
1011
|
-
const files = generateResources(services, ctx);
|
|
1012
|
-
const content = files[0].content;
|
|
1013
|
-
|
|
1014
|
-
// Should use the union type for the payload parameter
|
|
1015
|
-
expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
|
|
1016
|
-
|
|
1017
|
-
// Should dispatch to the correct serializer based on the discriminator,
|
|
1018
|
-
// using the typed discriminator so TS narrows payload per case.
|
|
1019
|
-
expect(content).toContain('switch (payload.grantType)');
|
|
1020
|
-
expect(content).toContain("case 'password': return serializeAuthByPassword(payload)");
|
|
1021
|
-
expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload)");
|
|
1022
|
-
expect(content).toContain(
|
|
1023
|
-
"case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload)",
|
|
1024
|
-
);
|
|
1025
|
-
|
|
1026
|
-
// Should not use `as any` casts — TS discriminated-union narrowing makes
|
|
1027
|
-
// them unnecessary and they suppress real type mismatches.
|
|
1028
|
-
expect(content).not.toContain('switch ((payload as any)');
|
|
1029
|
-
expect(content).not.toMatch(/return serialize\w+\(payload as any\)/);
|
|
1030
|
-
|
|
1031
|
-
// Should import serializers for all union variants
|
|
1032
|
-
expect(content).toContain('serializeAuthByPassword');
|
|
1033
|
-
expect(content).toContain('serializeAuthByCode');
|
|
1034
|
-
expect(content).toContain('serializeAuthByMagicAuth');
|
|
1035
|
-
|
|
1036
|
-
// Should NOT pass payload directly without serialization
|
|
1037
|
-
expect(content).not.toMatch(/,\n\s+payload,\n/);
|
|
1038
|
-
|
|
1039
|
-
// Default branch must throw — silently forwarding unserialized camelCase
|
|
1040
|
-
// to the API produces malformed requests when the discriminator is unknown.
|
|
1041
|
-
expect(content).toContain('default:');
|
|
1042
|
-
expect(content).toContain('const _unknown: never = payload');
|
|
1043
|
-
expect(content).toContain('throw new Error');
|
|
1044
|
-
expect(content).not.toMatch(/default:\s*return payload/);
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
it('generates discriminated union serializer dispatch for void method', () => {
|
|
1048
|
-
const services: Service[] = [
|
|
1049
|
-
{
|
|
1050
|
-
name: 'Auth',
|
|
1051
|
-
operations: [
|
|
1052
|
-
{
|
|
1053
|
-
name: 'sendToken',
|
|
1054
|
-
httpMethod: 'post',
|
|
1055
|
-
path: '/auth/token',
|
|
1056
|
-
pathParams: [],
|
|
1057
|
-
queryParams: [],
|
|
1058
|
-
headerParams: [],
|
|
1059
|
-
requestBody: {
|
|
1060
|
-
kind: 'union',
|
|
1061
|
-
variants: [
|
|
1062
|
-
{ kind: 'model', name: 'TokenByCode' },
|
|
1063
|
-
{ kind: 'model', name: 'TokenByRefresh' },
|
|
1064
|
-
],
|
|
1065
|
-
discriminator: {
|
|
1066
|
-
property: 'grant_type',
|
|
1067
|
-
mapping: {
|
|
1068
|
-
authorization_code: 'TokenByCode',
|
|
1069
|
-
refresh_token: 'TokenByRefresh',
|
|
1070
|
-
},
|
|
1071
|
-
},
|
|
1072
|
-
},
|
|
1073
|
-
response: { kind: 'primitive', type: 'unknown' },
|
|
1074
|
-
errors: [],
|
|
1075
|
-
injectIdempotencyKey: false,
|
|
1076
|
-
},
|
|
1077
|
-
],
|
|
1078
|
-
},
|
|
1079
|
-
];
|
|
1080
|
-
|
|
1081
|
-
const files = generateResources(services, ctx);
|
|
1082
|
-
const content = files[0].content;
|
|
1083
|
-
|
|
1084
|
-
// Should dispatch to the correct serializer using the typed discriminator.
|
|
1085
|
-
expect(content).toContain('switch (payload.grantType)');
|
|
1086
|
-
expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload)");
|
|
1087
|
-
expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload)");
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
it('uses AutoPaginatable pattern in paginated methods', () => {
|
|
1091
|
-
const services: Service[] = [
|
|
1092
|
-
{
|
|
1093
|
-
name: 'Connections',
|
|
1094
|
-
operations: [
|
|
1095
|
-
{
|
|
1096
|
-
name: 'listConnections',
|
|
1097
|
-
httpMethod: 'get',
|
|
1098
|
-
path: '/connections',
|
|
1099
|
-
pathParams: [],
|
|
1100
|
-
queryParams: [],
|
|
1101
|
-
headerParams: [],
|
|
1102
|
-
response: { kind: 'model', name: 'Connection' },
|
|
1103
|
-
errors: [],
|
|
1104
|
-
pagination: {
|
|
1105
|
-
strategy: 'cursor',
|
|
1106
|
-
param: 'after',
|
|
1107
|
-
dataPath: 'data',
|
|
1108
|
-
itemType: { kind: 'model', name: 'Connection' },
|
|
1109
|
-
},
|
|
1110
|
-
injectIdempotencyKey: false,
|
|
1111
|
-
},
|
|
1112
|
-
],
|
|
1113
|
-
},
|
|
1114
|
-
];
|
|
1115
|
-
|
|
1116
|
-
const files = generateResources(services, ctx);
|
|
1117
|
-
const content = files[0].content;
|
|
1118
|
-
|
|
1119
|
-
// Should use AutoPaginatable + fetchAndDeserialize pattern for paginated methods
|
|
1120
|
-
expect(content).toContain('new AutoPaginatable(');
|
|
1121
|
-
expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
|
|
1122
|
-
expect(content).toContain('deserializeConnection');
|
|
1123
|
-
});
|
|
1124
|
-
|
|
1125
|
-
it('prefixes ListOptions with service name when method is "list"', () => {
|
|
1126
|
-
const services: Service[] = [
|
|
1127
|
-
{
|
|
1128
|
-
name: 'Payments',
|
|
1129
|
-
operations: [
|
|
1130
|
-
{
|
|
1131
|
-
name: 'list',
|
|
1132
|
-
httpMethod: 'get',
|
|
1133
|
-
path: '/payments',
|
|
1134
|
-
pathParams: [],
|
|
1135
|
-
queryParams: [
|
|
1136
|
-
{
|
|
1137
|
-
name: 'connection_type',
|
|
1138
|
-
type: { kind: 'primitive', type: 'string' },
|
|
1139
|
-
required: false,
|
|
1140
|
-
},
|
|
1141
|
-
],
|
|
1142
|
-
headerParams: [],
|
|
1143
|
-
response: { kind: 'model', name: 'Connection' },
|
|
1144
|
-
errors: [],
|
|
1145
|
-
pagination: {
|
|
1146
|
-
strategy: 'cursor',
|
|
1147
|
-
param: 'after',
|
|
1148
|
-
dataPath: 'data',
|
|
1149
|
-
itemType: { kind: 'model', name: 'Connection' },
|
|
1150
|
-
},
|
|
1151
|
-
injectIdempotencyKey: false,
|
|
1152
|
-
},
|
|
1153
|
-
],
|
|
1154
|
-
},
|
|
1155
|
-
];
|
|
1156
|
-
|
|
1157
|
-
// Use overlay to resolve method name to "list"
|
|
1158
|
-
const overlayCtx: EmitterContext = {
|
|
1159
|
-
namespace: 'workos',
|
|
1160
|
-
namespacePascal: 'WorkOS',
|
|
1161
|
-
spec: { ...emptySpec, services, models: [] },
|
|
1162
|
-
overlayLookup: {
|
|
1163
|
-
methodByOperation: new Map([
|
|
1164
|
-
[
|
|
1165
|
-
'GET /payments',
|
|
1166
|
-
{
|
|
1167
|
-
className: 'Payments',
|
|
1168
|
-
methodName: 'list',
|
|
1169
|
-
params: [],
|
|
1170
|
-
returnType: 'void',
|
|
1171
|
-
},
|
|
1172
|
-
],
|
|
1173
|
-
]),
|
|
1174
|
-
httpKeyByMethod: new Map(),
|
|
1175
|
-
interfaceByName: new Map(),
|
|
1176
|
-
typeAliasByName: new Map(),
|
|
1177
|
-
requiredExports: new Map(),
|
|
1178
|
-
modelNameByIR: new Map(),
|
|
1179
|
-
fileBySymbol: new Map(),
|
|
1180
|
-
},
|
|
1181
|
-
};
|
|
1182
|
-
|
|
1183
|
-
const files = generateResources(services, overlayCtx);
|
|
1184
|
-
const content = files[0].content;
|
|
1185
|
-
|
|
1186
|
-
// Should use service-prefixed options name instead of generic "ListOptions"
|
|
1187
|
-
const optionsFile = files.find((f) => f.path.endsWith('payments-list-options.interface.ts'));
|
|
1188
|
-
expect(optionsFile).toBeDefined();
|
|
1189
|
-
expect(optionsFile!.content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
|
|
1190
|
-
expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
|
|
1191
|
-
// Should NOT use the generic "ListOptions"
|
|
1192
|
-
expect(content).not.toContain('export interface ListOptions ');
|
|
1193
|
-
expect(files.every((f) => !f.path.endsWith('/list-options.interface.ts'))).toBe(true);
|
|
1194
|
-
});
|
|
1195
|
-
|
|
1196
|
-
it('does not prefix ListOptions when method is not "list"', () => {
|
|
1197
|
-
const services: Service[] = [
|
|
1198
|
-
{
|
|
1199
|
-
name: 'Organizations',
|
|
1200
|
-
operations: [
|
|
1201
|
-
{
|
|
1202
|
-
name: 'listOrganizations',
|
|
1203
|
-
httpMethod: 'get',
|
|
1204
|
-
path: '/organizations',
|
|
1205
|
-
pathParams: [],
|
|
1206
|
-
queryParams: [
|
|
1207
|
-
{
|
|
1208
|
-
name: 'domains',
|
|
1209
|
-
type: {
|
|
1210
|
-
kind: 'array',
|
|
1211
|
-
items: { kind: 'primitive', type: 'string' },
|
|
1212
|
-
},
|
|
1213
|
-
required: false,
|
|
1214
|
-
},
|
|
1215
|
-
],
|
|
1216
|
-
headerParams: [],
|
|
1217
|
-
response: { kind: 'model', name: 'Organization' },
|
|
1218
|
-
errors: [],
|
|
1219
|
-
pagination: {
|
|
1220
|
-
strategy: 'cursor',
|
|
1221
|
-
param: 'after',
|
|
1222
|
-
dataPath: 'data',
|
|
1223
|
-
itemType: { kind: 'model', name: 'Organization' },
|
|
1224
|
-
},
|
|
1225
|
-
injectIdempotencyKey: false,
|
|
1226
|
-
},
|
|
1227
|
-
],
|
|
1228
|
-
},
|
|
1229
|
-
];
|
|
1230
|
-
|
|
1231
|
-
const files = generateResources(services, ctx);
|
|
1232
|
-
|
|
1233
|
-
// Method is "listOrganizations", not "list", so options name should be normal
|
|
1234
|
-
const optionsFile = files.find((f) => f.path.endsWith('list-organizations-options.interface.ts'));
|
|
1235
|
-
expect(optionsFile).toBeDefined();
|
|
1236
|
-
expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
|
|
1237
|
-
});
|
|
1238
|
-
|
|
1239
|
-
it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
|
|
1240
|
-
const services: Service[] = [
|
|
1241
|
-
{
|
|
1242
|
-
name: 'SSOService',
|
|
1243
|
-
operations: [
|
|
1244
|
-
{
|
|
1245
|
-
name: 'getAuthorizationUrl',
|
|
1246
|
-
httpMethod: 'get',
|
|
1247
|
-
path: '/sso/authorize',
|
|
1248
|
-
pathParams: [],
|
|
1249
|
-
queryParams: [],
|
|
1250
|
-
headerParams: [],
|
|
1251
|
-
response: { kind: 'model', name: 'AuthorizationUrl' },
|
|
1252
|
-
errors: [],
|
|
1253
|
-
injectIdempotencyKey: false,
|
|
1254
|
-
},
|
|
1255
|
-
{
|
|
1256
|
-
name: 'logout',
|
|
1257
|
-
httpMethod: 'get',
|
|
1258
|
-
path: '/sso/logout',
|
|
1259
|
-
pathParams: [],
|
|
1260
|
-
queryParams: [],
|
|
1261
|
-
headerParams: [],
|
|
1262
|
-
response: { kind: 'model', name: 'LogoutResult' },
|
|
1263
|
-
errors: [],
|
|
1264
|
-
injectIdempotencyKey: false,
|
|
1265
|
-
},
|
|
1266
|
-
],
|
|
1267
|
-
},
|
|
1268
|
-
];
|
|
1269
|
-
|
|
1270
|
-
// Overlay maps both operations to SSO class
|
|
1271
|
-
// Baseline SSO class exists but only has getAuthorizationUrl (logout is missing)
|
|
1272
|
-
const overlayCtx: EmitterContext = {
|
|
1273
|
-
namespace: 'workos',
|
|
1274
|
-
namespacePascal: 'WorkOS',
|
|
1275
|
-
spec: { ...emptySpec, services, models: [] },
|
|
1276
|
-
overlayLookup: {
|
|
1277
|
-
methodByOperation: new Map([
|
|
1278
|
-
[
|
|
1279
|
-
'GET /sso/authorize',
|
|
1280
|
-
{
|
|
1281
|
-
className: 'SSO',
|
|
1282
|
-
methodName: 'getAuthorizationUrl',
|
|
1283
|
-
params: [],
|
|
1284
|
-
returnType: 'void',
|
|
1285
|
-
},
|
|
1286
|
-
],
|
|
1287
|
-
[
|
|
1288
|
-
'GET /sso/logout',
|
|
1289
|
-
{
|
|
1290
|
-
className: 'SSO',
|
|
1291
|
-
methodName: 'logout',
|
|
1292
|
-
params: [],
|
|
1293
|
-
returnType: 'void',
|
|
1294
|
-
},
|
|
1295
|
-
],
|
|
1296
|
-
]),
|
|
1297
|
-
httpKeyByMethod: new Map(),
|
|
1298
|
-
interfaceByName: new Map(),
|
|
1299
|
-
typeAliasByName: new Map(),
|
|
1300
|
-
requiredExports: new Map(),
|
|
1301
|
-
modelNameByIR: new Map(),
|
|
1302
|
-
fileBySymbol: new Map(),
|
|
1303
|
-
},
|
|
1304
|
-
apiSurface: {
|
|
1305
|
-
language: 'node',
|
|
1306
|
-
extractedFrom: 'test',
|
|
1307
|
-
extractedAt: '2024-01-01',
|
|
1308
|
-
classes: {
|
|
1309
|
-
SSO: {
|
|
1310
|
-
name: 'SSO',
|
|
1311
|
-
methods: {
|
|
1312
|
-
getAuthorizationUrl: [
|
|
1313
|
-
{
|
|
1314
|
-
name: 'getAuthorizationUrl',
|
|
1315
|
-
params: [],
|
|
1316
|
-
returnType: 'void',
|
|
1317
|
-
async: true,
|
|
1318
|
-
},
|
|
1319
|
-
],
|
|
1320
|
-
// logout method is intentionally ABSENT
|
|
1321
|
-
},
|
|
1322
|
-
properties: {},
|
|
1323
|
-
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1324
|
-
},
|
|
1325
|
-
},
|
|
1326
|
-
interfaces: {},
|
|
1327
|
-
typeAliases: {},
|
|
1328
|
-
enums: {},
|
|
1329
|
-
exports: {},
|
|
1330
|
-
},
|
|
1331
|
-
};
|
|
1332
|
-
|
|
1333
|
-
const files = generateResources(services, overlayCtx);
|
|
1334
|
-
expect(files.length).toBe(1);
|
|
1335
|
-
|
|
1336
|
-
// skipIfExists should be removed because 'logout' is absent from baseline
|
|
1337
|
-
expect(files[0].skipIfExists).toBeUndefined();
|
|
1338
|
-
});
|
|
1339
|
-
|
|
1340
|
-
it('keeps skipIfExists when fully-covered service has all methods in baseline', () => {
|
|
1341
|
-
const services: Service[] = [
|
|
1342
|
-
{
|
|
1343
|
-
name: 'SSOService',
|
|
1344
|
-
operations: [
|
|
1345
|
-
{
|
|
1346
|
-
name: 'getAuthorizationUrl',
|
|
1347
|
-
httpMethod: 'get',
|
|
1348
|
-
path: '/sso/authorize',
|
|
1349
|
-
pathParams: [],
|
|
1350
|
-
queryParams: [],
|
|
1351
|
-
headerParams: [],
|
|
1352
|
-
response: { kind: 'model', name: 'AuthorizationUrl' },
|
|
1353
|
-
errors: [],
|
|
1354
|
-
injectIdempotencyKey: false,
|
|
1355
|
-
},
|
|
1356
|
-
],
|
|
1357
|
-
},
|
|
1358
|
-
];
|
|
1359
|
-
|
|
1360
|
-
const overlayCtx: EmitterContext = {
|
|
1361
|
-
namespace: 'workos',
|
|
1362
|
-
namespacePascal: 'WorkOS',
|
|
1363
|
-
spec: { ...emptySpec, services, models: [] },
|
|
1364
|
-
overlayLookup: {
|
|
1365
|
-
methodByOperation: new Map([
|
|
1366
|
-
[
|
|
1367
|
-
'GET /sso/authorize',
|
|
1368
|
-
{
|
|
1369
|
-
className: 'SSO',
|
|
1370
|
-
methodName: 'getAuthorizationUrl',
|
|
1371
|
-
params: [],
|
|
1372
|
-
returnType: 'void',
|
|
1373
|
-
},
|
|
1374
|
-
],
|
|
1375
|
-
]),
|
|
1376
|
-
httpKeyByMethod: new Map(),
|
|
1377
|
-
interfaceByName: new Map(),
|
|
1378
|
-
typeAliasByName: new Map(),
|
|
1379
|
-
requiredExports: new Map(),
|
|
1380
|
-
modelNameByIR: new Map(),
|
|
1381
|
-
fileBySymbol: new Map(),
|
|
1382
|
-
},
|
|
1383
|
-
apiSurface: {
|
|
1384
|
-
language: 'node',
|
|
1385
|
-
extractedFrom: 'test',
|
|
1386
|
-
extractedAt: '2024-01-01',
|
|
1387
|
-
classes: {
|
|
1388
|
-
SSO: {
|
|
1389
|
-
name: 'SSO',
|
|
1390
|
-
methods: {
|
|
1391
|
-
getAuthorizationUrl: [
|
|
1392
|
-
{
|
|
1393
|
-
name: 'getAuthorizationUrl',
|
|
1394
|
-
params: [],
|
|
1395
|
-
returnType: 'void',
|
|
1396
|
-
async: true,
|
|
1397
|
-
},
|
|
1398
|
-
],
|
|
1399
|
-
},
|
|
1400
|
-
properties: {},
|
|
1401
|
-
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1402
|
-
},
|
|
1403
|
-
},
|
|
1404
|
-
interfaces: {},
|
|
1405
|
-
typeAliases: {},
|
|
1406
|
-
enums: {},
|
|
1407
|
-
exports: {},
|
|
1408
|
-
},
|
|
1409
|
-
};
|
|
1410
|
-
|
|
1411
|
-
const files = generateResources(services, overlayCtx);
|
|
1412
|
-
// Fully covered services with no new methods are skipped entirely
|
|
1413
|
-
expect(files.length).toBe(0);
|
|
1414
|
-
});
|
|
1415
|
-
|
|
1416
|
-
it('removes skipIfExists for purely oagen-managed services (no baseline)', () => {
|
|
1417
|
-
const services: Service[] = [
|
|
1418
|
-
{
|
|
1419
|
-
name: 'Applications',
|
|
1420
|
-
operations: [
|
|
1421
|
-
{
|
|
1422
|
-
name: 'create',
|
|
1423
|
-
httpMethod: 'post',
|
|
1424
|
-
path: '/connect/applications',
|
|
1425
|
-
pathParams: [],
|
|
1426
|
-
queryParams: [],
|
|
1427
|
-
headerParams: [],
|
|
1428
|
-
response: { kind: 'model', name: 'ConnectApplication' },
|
|
1429
|
-
errors: [],
|
|
1430
|
-
injectIdempotencyKey: false,
|
|
1431
|
-
},
|
|
1432
|
-
],
|
|
1433
|
-
},
|
|
1434
|
-
];
|
|
1435
|
-
|
|
1436
|
-
const overlayCtx: EmitterContext = {
|
|
1437
|
-
namespace: 'workos',
|
|
1438
|
-
namespacePascal: 'WorkOS',
|
|
1439
|
-
spec: { ...emptySpec, services, models: [] },
|
|
1440
|
-
overlayLookup: {
|
|
1441
|
-
methodByOperation: new Map([
|
|
1442
|
-
[
|
|
1443
|
-
'POST /connect/applications',
|
|
1444
|
-
{
|
|
1445
|
-
className: 'Applications',
|
|
1446
|
-
methodName: 'create',
|
|
1447
|
-
params: [],
|
|
1448
|
-
returnType: 'ConnectApplication',
|
|
1449
|
-
},
|
|
1450
|
-
],
|
|
1451
|
-
]),
|
|
1452
|
-
httpKeyByMethod: new Map(),
|
|
1453
|
-
interfaceByName: new Map(),
|
|
1454
|
-
typeAliasByName: new Map(),
|
|
1455
|
-
requiredExports: new Map(),
|
|
1456
|
-
modelNameByIR: new Map(),
|
|
1457
|
-
fileBySymbol: new Map(),
|
|
1458
|
-
},
|
|
1459
|
-
apiSurface: {
|
|
1460
|
-
language: 'node',
|
|
1461
|
-
extractedFrom: 'test',
|
|
1462
|
-
extractedAt: '2024-01-01',
|
|
1463
|
-
// No baseline class for Applications — purely oagen-managed.
|
|
1464
|
-
classes: {},
|
|
1465
|
-
interfaces: {},
|
|
1466
|
-
typeAliases: {},
|
|
1467
|
-
enums: {},
|
|
1468
|
-
exports: {},
|
|
1469
|
-
},
|
|
1470
|
-
};
|
|
1471
|
-
|
|
1472
|
-
const files = generateResources(services, overlayCtx);
|
|
1473
|
-
expect(files.length).toBe(1);
|
|
1474
|
-
|
|
1475
|
-
// skipIfExists must be removed so emitter improvements always overwrite.
|
|
1476
|
-
expect(files[0].skipIfExists).toBeUndefined();
|
|
1477
|
-
});
|
|
1478
|
-
});
|
|
1479
|
-
|
|
1480
|
-
describe('resolveResourceClassName', () => {
|
|
1481
|
-
const webhooksService: Service = {
|
|
1482
|
-
name: 'WebhookEvents',
|
|
1483
|
-
operations: [
|
|
1484
|
-
{
|
|
1485
|
-
name: 'listWebhookEvents',
|
|
1486
|
-
httpMethod: 'get',
|
|
1487
|
-
path: '/webhook_events',
|
|
1488
|
-
pathParams: [],
|
|
1489
|
-
queryParams: [],
|
|
1490
|
-
headerParams: [],
|
|
1491
|
-
response: { kind: 'model', name: 'WebhookEvent' },
|
|
1492
|
-
errors: [],
|
|
1493
|
-
injectIdempotencyKey: false,
|
|
1494
|
-
},
|
|
1495
|
-
],
|
|
1496
|
-
};
|
|
1497
|
-
|
|
1498
|
-
it('generates separate class when baseline has incompatible constructor', () => {
|
|
1499
|
-
const overlayCtx: EmitterContext = {
|
|
1500
|
-
namespace: 'workos',
|
|
1501
|
-
namespacePascal: 'WorkOS',
|
|
1502
|
-
spec: { ...emptySpec, services: [webhooksService] },
|
|
1503
|
-
overlayLookup: {
|
|
1504
|
-
methodByOperation: new Map([
|
|
1505
|
-
[
|
|
1506
|
-
'GET /webhook_events',
|
|
1507
|
-
{
|
|
1508
|
-
className: 'Webhooks',
|
|
1509
|
-
methodName: 'listWebhookEvents',
|
|
1510
|
-
params: [],
|
|
1511
|
-
returnType: 'void',
|
|
1512
|
-
},
|
|
1513
|
-
],
|
|
1514
|
-
]),
|
|
1515
|
-
httpKeyByMethod: new Map(),
|
|
1516
|
-
interfaceByName: new Map(),
|
|
1517
|
-
typeAliasByName: new Map(),
|
|
1518
|
-
requiredExports: new Map(),
|
|
1519
|
-
modelNameByIR: new Map(),
|
|
1520
|
-
fileBySymbol: new Map(),
|
|
1521
|
-
},
|
|
1522
|
-
apiSurface: {
|
|
1523
|
-
language: 'node',
|
|
1524
|
-
extractedFrom: 'test',
|
|
1525
|
-
extractedAt: '2024-01-01',
|
|
1526
|
-
classes: {
|
|
1527
|
-
Webhooks: {
|
|
1528
|
-
name: 'Webhooks',
|
|
1529
|
-
methods: {},
|
|
1530
|
-
properties: {},
|
|
1531
|
-
constructorParams: [
|
|
1532
|
-
{
|
|
1533
|
-
name: 'cryptoProvider',
|
|
1534
|
-
type: 'CryptoProvider',
|
|
1535
|
-
optional: false,
|
|
1536
|
-
},
|
|
1537
|
-
],
|
|
1538
|
-
},
|
|
1539
|
-
},
|
|
1540
|
-
interfaces: {},
|
|
1541
|
-
typeAliases: {},
|
|
1542
|
-
enums: {},
|
|
1543
|
-
exports: {},
|
|
1544
|
-
},
|
|
1545
|
-
};
|
|
1546
|
-
|
|
1547
|
-
const result = resolveResourceClassName(webhooksService, overlayCtx);
|
|
1548
|
-
// Falls back to IR name since overlay name has incompatible constructor
|
|
1549
|
-
expect(result).toBe('WebhookEvents');
|
|
1550
|
-
});
|
|
1551
|
-
|
|
1552
|
-
it('uses overlay name when baseline has compatible constructor', () => {
|
|
1553
|
-
const overlayCtx: EmitterContext = {
|
|
1554
|
-
namespace: 'workos',
|
|
1555
|
-
namespacePascal: 'WorkOS',
|
|
1556
|
-
spec: { ...emptySpec, services: [webhooksService] },
|
|
1557
|
-
overlayLookup: {
|
|
1558
|
-
methodByOperation: new Map([
|
|
1559
|
-
[
|
|
1560
|
-
'GET /webhook_events',
|
|
1561
|
-
{
|
|
1562
|
-
className: 'Webhooks',
|
|
1563
|
-
methodName: 'listWebhookEvents',
|
|
1564
|
-
params: [],
|
|
1565
|
-
returnType: 'void',
|
|
1566
|
-
},
|
|
1567
|
-
],
|
|
1568
|
-
]),
|
|
1569
|
-
httpKeyByMethod: new Map(),
|
|
1570
|
-
interfaceByName: new Map(),
|
|
1571
|
-
typeAliasByName: new Map(),
|
|
1572
|
-
requiredExports: new Map(),
|
|
1573
|
-
modelNameByIR: new Map(),
|
|
1574
|
-
fileBySymbol: new Map(),
|
|
1575
|
-
},
|
|
1576
|
-
apiSurface: {
|
|
1577
|
-
language: 'node',
|
|
1578
|
-
extractedFrom: 'test',
|
|
1579
|
-
extractedAt: '2024-01-01',
|
|
1580
|
-
classes: {
|
|
1581
|
-
Webhooks: {
|
|
1582
|
-
name: 'Webhooks',
|
|
1583
|
-
methods: {},
|
|
1584
|
-
properties: {},
|
|
1585
|
-
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1586
|
-
},
|
|
1587
|
-
},
|
|
1588
|
-
interfaces: {},
|
|
1589
|
-
typeAliases: {},
|
|
1590
|
-
enums: {},
|
|
1591
|
-
exports: {},
|
|
1592
|
-
},
|
|
1593
|
-
};
|
|
1594
|
-
|
|
1595
|
-
const result = resolveResourceClassName(webhooksService, overlayCtx);
|
|
1596
|
-
expect(result).toBe('Webhooks');
|
|
1597
|
-
});
|
|
1598
|
-
|
|
1599
|
-
it('appends Endpoints suffix when IR name collides with overlay name', () => {
|
|
1600
|
-
const collisionService: Service = {
|
|
1601
|
-
name: 'Webhooks',
|
|
1602
|
-
operations: [
|
|
1603
|
-
{
|
|
1604
|
-
name: 'listWebhooks',
|
|
1605
|
-
httpMethod: 'get',
|
|
1606
|
-
path: '/webhooks',
|
|
1607
|
-
pathParams: [],
|
|
1608
|
-
queryParams: [],
|
|
1609
|
-
headerParams: [],
|
|
1610
|
-
response: { kind: 'model', name: 'Webhook' },
|
|
1611
|
-
errors: [],
|
|
1612
|
-
injectIdempotencyKey: false,
|
|
1613
|
-
},
|
|
1614
|
-
],
|
|
1615
|
-
};
|
|
1616
|
-
|
|
1617
|
-
const overlayCtx: EmitterContext = {
|
|
1618
|
-
namespace: 'workos',
|
|
1619
|
-
namespacePascal: 'WorkOS',
|
|
1620
|
-
spec: { ...emptySpec, services: [collisionService] },
|
|
1621
|
-
overlayLookup: {
|
|
1622
|
-
methodByOperation: new Map([
|
|
1623
|
-
[
|
|
1624
|
-
'GET /webhooks',
|
|
1625
|
-
{
|
|
1626
|
-
className: 'Webhooks',
|
|
1627
|
-
methodName: 'listWebhooks',
|
|
1628
|
-
params: [],
|
|
1629
|
-
returnType: 'void',
|
|
1630
|
-
},
|
|
1631
|
-
],
|
|
1632
|
-
]),
|
|
1633
|
-
httpKeyByMethod: new Map(),
|
|
1634
|
-
interfaceByName: new Map(),
|
|
1635
|
-
typeAliasByName: new Map(),
|
|
1636
|
-
requiredExports: new Map(),
|
|
1637
|
-
modelNameByIR: new Map(),
|
|
1638
|
-
fileBySymbol: new Map(),
|
|
1639
|
-
},
|
|
1640
|
-
apiSurface: {
|
|
1641
|
-
language: 'node',
|
|
1642
|
-
extractedFrom: 'test',
|
|
1643
|
-
extractedAt: '2024-01-01',
|
|
1644
|
-
classes: {
|
|
1645
|
-
Webhooks: {
|
|
1646
|
-
name: 'Webhooks',
|
|
1647
|
-
methods: {},
|
|
1648
|
-
properties: {},
|
|
1649
|
-
constructorParams: [
|
|
1650
|
-
{
|
|
1651
|
-
name: 'cryptoProvider',
|
|
1652
|
-
type: 'CryptoProvider',
|
|
1653
|
-
optional: false,
|
|
1654
|
-
},
|
|
1655
|
-
],
|
|
1656
|
-
},
|
|
1657
|
-
},
|
|
1658
|
-
interfaces: {},
|
|
1659
|
-
typeAliases: {},
|
|
1660
|
-
enums: {},
|
|
1661
|
-
exports: {},
|
|
1662
|
-
},
|
|
1663
|
-
};
|
|
1664
|
-
|
|
1665
|
-
const result = resolveResourceClassName(collisionService, overlayCtx);
|
|
1666
|
-
// IR name "Webhooks" collides with overlay name "Webhooks", so append Endpoints
|
|
1667
|
-
expect(result).toBe('WebhooksEndpoints');
|
|
1668
|
-
});
|
|
1669
|
-
});
|
|
1670
|
-
|
|
1671
|
-
describe('hasCompatibleConstructor', () => {
|
|
1672
|
-
it('returns true when no baseline exists', () => {
|
|
1673
|
-
expect(hasCompatibleConstructor('NewService', ctx)).toBe(true);
|
|
1674
|
-
});
|
|
1675
|
-
|
|
1676
|
-
it('returns true when baseline has workos: WorkOS param', () => {
|
|
1677
|
-
const ctxWithSurface: EmitterContext = {
|
|
1678
|
-
...ctx,
|
|
1679
|
-
apiSurface: {
|
|
1680
|
-
language: 'node',
|
|
1681
|
-
extractedFrom: 'test',
|
|
1682
|
-
extractedAt: '2024-01-01',
|
|
1683
|
-
classes: {
|
|
1684
|
-
Organizations: {
|
|
1685
|
-
name: 'Organizations',
|
|
1686
|
-
methods: {},
|
|
1687
|
-
properties: {},
|
|
1688
|
-
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1689
|
-
},
|
|
1690
|
-
},
|
|
1691
|
-
interfaces: {},
|
|
1692
|
-
typeAliases: {},
|
|
1693
|
-
enums: {},
|
|
1694
|
-
exports: {},
|
|
1695
|
-
},
|
|
1696
|
-
};
|
|
1697
|
-
|
|
1698
|
-
expect(hasCompatibleConstructor('Organizations', ctxWithSurface)).toBe(true);
|
|
1699
|
-
});
|
|
1700
|
-
|
|
1701
|
-
it('returns false when baseline has incompatible constructor', () => {
|
|
1702
|
-
const ctxWithSurface: EmitterContext = {
|
|
1703
|
-
...ctx,
|
|
1704
|
-
apiSurface: {
|
|
1705
|
-
language: 'node',
|
|
1706
|
-
extractedFrom: 'test',
|
|
1707
|
-
extractedAt: '2024-01-01',
|
|
1708
|
-
classes: {
|
|
1709
|
-
Webhooks: {
|
|
1710
|
-
name: 'Webhooks',
|
|
1711
|
-
methods: {},
|
|
1712
|
-
properties: {},
|
|
1713
|
-
constructorParams: [
|
|
1714
|
-
{
|
|
1715
|
-
name: 'cryptoProvider',
|
|
1716
|
-
type: 'CryptoProvider',
|
|
1717
|
-
optional: false,
|
|
1718
|
-
},
|
|
1719
|
-
],
|
|
1720
|
-
},
|
|
1721
|
-
},
|
|
1722
|
-
interfaces: {},
|
|
1723
|
-
typeAliases: {},
|
|
1724
|
-
enums: {},
|
|
1725
|
-
exports: {},
|
|
1726
|
-
},
|
|
1727
|
-
};
|
|
1728
|
-
|
|
1729
|
-
expect(hasCompatibleConstructor('Webhooks', ctxWithSurface)).toBe(false);
|
|
1730
|
-
});
|
|
1731
|
-
|
|
1732
|
-
it('returns true when baseline has no constructor params', () => {
|
|
1733
|
-
const ctxWithSurface: EmitterContext = {
|
|
1734
|
-
...ctx,
|
|
1735
|
-
apiSurface: {
|
|
1736
|
-
language: 'node',
|
|
1737
|
-
extractedFrom: 'test',
|
|
1738
|
-
extractedAt: '2024-01-01',
|
|
1739
|
-
classes: {
|
|
1740
|
-
EmptyService: {
|
|
1741
|
-
name: 'EmptyService',
|
|
1742
|
-
methods: {},
|
|
1743
|
-
properties: {},
|
|
1744
|
-
constructorParams: [],
|
|
1745
|
-
},
|
|
1746
|
-
},
|
|
1747
|
-
interfaces: {},
|
|
1748
|
-
typeAliases: {},
|
|
1749
|
-
enums: {},
|
|
1750
|
-
exports: {},
|
|
1751
|
-
},
|
|
1752
|
-
};
|
|
1753
|
-
|
|
1754
|
-
expect(hasCompatibleConstructor('EmptyService', ctxWithSurface)).toBe(true);
|
|
1755
|
-
});
|
|
1756
|
-
});
|
|
1757
|
-
|
|
1758
|
-
describe('partial service coverage', () => {
|
|
1759
|
-
it('generates methods for uncovered operations in partially covered services', () => {
|
|
1760
|
-
const services: Service[] = [
|
|
1761
|
-
{
|
|
1762
|
-
name: 'AuditLogs',
|
|
1763
|
-
operations: [
|
|
1764
|
-
{
|
|
1765
|
-
name: 'createEvent',
|
|
1766
|
-
httpMethod: 'post',
|
|
1767
|
-
path: '/audit_logs/events',
|
|
1768
|
-
pathParams: [],
|
|
1769
|
-
queryParams: [],
|
|
1770
|
-
headerParams: [],
|
|
1771
|
-
response: { kind: 'model', name: 'AuditLogEvent' },
|
|
1772
|
-
errors: [],
|
|
1773
|
-
injectIdempotencyKey: false,
|
|
1774
|
-
},
|
|
1775
|
-
{
|
|
1776
|
-
name: 'getRetention',
|
|
1777
|
-
httpMethod: 'get',
|
|
1778
|
-
path: '/audit_logs/retention',
|
|
1779
|
-
pathParams: [],
|
|
1780
|
-
queryParams: [],
|
|
1781
|
-
headerParams: [],
|
|
1782
|
-
response: { kind: 'model', name: 'AuditLogRetention' },
|
|
1783
|
-
errors: [],
|
|
1784
|
-
injectIdempotencyKey: false,
|
|
1785
|
-
},
|
|
1786
|
-
],
|
|
1787
|
-
},
|
|
1788
|
-
];
|
|
1789
|
-
|
|
1790
|
-
// createEvent is covered by existing AuditLogs class, getRetention is NOT
|
|
1791
|
-
const ctxPartial: EmitterContext = {
|
|
1792
|
-
...ctx,
|
|
1793
|
-
spec: { ...emptySpec, services, models: [] },
|
|
1794
|
-
overlayLookup: {
|
|
1795
|
-
methodByOperation: new Map([
|
|
1796
|
-
[
|
|
1797
|
-
'POST /audit_logs/events',
|
|
1798
|
-
{
|
|
1799
|
-
className: 'AuditLogs',
|
|
1800
|
-
methodName: 'createEvent',
|
|
1801
|
-
params: [],
|
|
1802
|
-
returnType: 'AuditLogEvent',
|
|
1803
|
-
},
|
|
1804
|
-
],
|
|
1805
|
-
]),
|
|
1806
|
-
httpKeyByMethod: new Map(),
|
|
1807
|
-
interfaceByName: new Map(),
|
|
1808
|
-
typeAliasByName: new Map(),
|
|
1809
|
-
requiredExports: new Map(),
|
|
1810
|
-
modelNameByIR: new Map(),
|
|
1811
|
-
fileBySymbol: new Map(),
|
|
1812
|
-
},
|
|
1813
|
-
apiSurface: {
|
|
1814
|
-
language: 'node',
|
|
1815
|
-
extractedFrom: 'test',
|
|
1816
|
-
extractedAt: '2024-01-01',
|
|
1817
|
-
classes: {
|
|
1818
|
-
AuditLogs: {
|
|
1819
|
-
name: 'AuditLogs',
|
|
1820
|
-
methods: {
|
|
1821
|
-
createEvent: [
|
|
1822
|
-
{
|
|
1823
|
-
name: 'createEvent',
|
|
1824
|
-
params: [],
|
|
1825
|
-
returnType: 'AuditLogEvent',
|
|
1826
|
-
async: true,
|
|
1827
|
-
},
|
|
1828
|
-
],
|
|
1829
|
-
},
|
|
1830
|
-
properties: {},
|
|
1831
|
-
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1832
|
-
},
|
|
1833
|
-
},
|
|
1834
|
-
interfaces: {},
|
|
1835
|
-
typeAliases: {},
|
|
1836
|
-
enums: {},
|
|
1837
|
-
exports: {},
|
|
1838
|
-
},
|
|
1839
|
-
};
|
|
1840
|
-
|
|
1841
|
-
const files = generateResources(services, ctxPartial);
|
|
1842
|
-
expect(files.length).toBe(1);
|
|
1843
|
-
const content = files[0].content;
|
|
1844
|
-
|
|
1845
|
-
// Should generate method for uncovered operation
|
|
1846
|
-
expect(content).toContain('async getRetention');
|
|
1847
|
-
// Should also generate covered operation so the merger can apply JSDoc
|
|
1848
|
-
expect(content).toContain('async createEvent');
|
|
1849
|
-
});
|
|
1850
|
-
|
|
1851
|
-
it('skips fully covered services with no new methods', () => {
|
|
1852
|
-
const services: Service[] = [
|
|
1853
|
-
{
|
|
1854
|
-
name: 'Permissions',
|
|
1855
|
-
operations: [
|
|
1856
|
-
{
|
|
1857
|
-
name: 'listPermissions',
|
|
1858
|
-
description: 'List all permissions.',
|
|
1859
|
-
httpMethod: 'get',
|
|
1860
|
-
path: '/authorization/permissions',
|
|
1861
|
-
pathParams: [],
|
|
1862
|
-
queryParams: [],
|
|
1863
|
-
headerParams: [],
|
|
1864
|
-
response: { kind: 'model', name: 'PermissionList' },
|
|
1865
|
-
errors: [{ statusCode: 404 }],
|
|
1866
|
-
injectIdempotencyKey: false,
|
|
1867
|
-
},
|
|
1868
|
-
],
|
|
1869
|
-
},
|
|
1870
|
-
];
|
|
1871
|
-
|
|
1872
|
-
const ctxCovered: EmitterContext = {
|
|
1873
|
-
...ctx,
|
|
1874
|
-
spec: { ...emptySpec, services, models: [] },
|
|
1875
|
-
overlayLookup: {
|
|
1876
|
-
methodByOperation: new Map([
|
|
1877
|
-
[
|
|
1878
|
-
'GET /authorization/permissions',
|
|
1879
|
-
{
|
|
1880
|
-
className: 'Permissions',
|
|
1881
|
-
methodName: 'listPermissions',
|
|
1882
|
-
params: [],
|
|
1883
|
-
returnType: 'void',
|
|
1884
|
-
},
|
|
1885
|
-
],
|
|
1886
|
-
]),
|
|
1887
|
-
httpKeyByMethod: new Map(),
|
|
1888
|
-
interfaceByName: new Map(),
|
|
1889
|
-
typeAliasByName: new Map(),
|
|
1890
|
-
requiredExports: new Map(),
|
|
1891
|
-
modelNameByIR: new Map(),
|
|
1892
|
-
fileBySymbol: new Map(),
|
|
1893
|
-
},
|
|
1894
|
-
apiSurface: {
|
|
1895
|
-
language: 'node',
|
|
1896
|
-
extractedFrom: 'test',
|
|
1897
|
-
extractedAt: '2024-01-01',
|
|
1898
|
-
classes: {
|
|
1899
|
-
Permissions: {
|
|
1900
|
-
name: 'Permissions',
|
|
1901
|
-
methods: {
|
|
1902
|
-
listPermissions: [
|
|
1903
|
-
{
|
|
1904
|
-
name: 'listPermissions',
|
|
1905
|
-
params: [],
|
|
1906
|
-
returnType: 'void',
|
|
1907
|
-
async: true,
|
|
1908
|
-
},
|
|
1909
|
-
],
|
|
1910
|
-
},
|
|
1911
|
-
properties: {},
|
|
1912
|
-
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1913
|
-
},
|
|
1914
|
-
},
|
|
1915
|
-
interfaces: {},
|
|
1916
|
-
typeAliases: {},
|
|
1917
|
-
enums: {},
|
|
1918
|
-
exports: {},
|
|
1919
|
-
},
|
|
1920
|
-
};
|
|
1921
|
-
|
|
1922
|
-
const files = generateResources(services, ctxCovered);
|
|
1923
|
-
// Fully covered services with no new methods are skipped entirely
|
|
1924
|
-
// (JSDoc-only updates are deferred until a structural change touches the file)
|
|
1925
|
-
expect(files.length).toBe(0);
|
|
1926
|
-
});
|
|
1927
|
-
|
|
1928
|
-
it('uses resolved operation method names when provided', () => {
|
|
1929
|
-
const op = {
|
|
1930
|
-
name: 'listRolesOrganizations',
|
|
1931
|
-
httpMethod: 'get' as const,
|
|
1932
|
-
path: '/authorization/organizations/{organizationId}/roles',
|
|
1933
|
-
pathParams: [
|
|
1934
|
-
{
|
|
1935
|
-
name: 'organizationId',
|
|
1936
|
-
type: { kind: 'primitive' as const, type: 'string' as const },
|
|
1937
|
-
required: true,
|
|
1938
493
|
},
|
|
1939
|
-
|
|
1940
|
-
queryParams: [],
|
|
1941
|
-
headerParams: [],
|
|
1942
|
-
response: { kind: 'model' as const, name: 'RoleList' },
|
|
1943
|
-
errors: [],
|
|
1944
|
-
pagination: {
|
|
1945
|
-
strategy: 'cursor' as const,
|
|
1946
|
-
param: 'after',
|
|
1947
|
-
itemType: { kind: 'model' as const, name: 'RoleList' },
|
|
1948
|
-
},
|
|
1949
|
-
injectIdempotencyKey: false,
|
|
1950
|
-
};
|
|
1951
|
-
const services: Service[] = [
|
|
1952
|
-
{
|
|
1953
|
-
name: 'Authorization',
|
|
1954
|
-
operations: [op],
|
|
1955
|
-
},
|
|
1956
|
-
];
|
|
1957
|
-
|
|
1958
|
-
const ctxResolved: EmitterContext = {
|
|
1959
|
-
...ctx,
|
|
1960
|
-
spec: {
|
|
1961
|
-
...emptySpec,
|
|
1962
|
-
services,
|
|
1963
|
-
models: [{ name: 'RoleList', fields: [] }],
|
|
1964
|
-
},
|
|
1965
|
-
resolvedOperations: [
|
|
1966
|
-
{
|
|
1967
|
-
operation: op,
|
|
1968
|
-
service: services[0],
|
|
1969
|
-
methodName: 'list_organization_roles',
|
|
1970
|
-
mountOn: 'Authorization',
|
|
1971
|
-
} as any,
|
|
1972
|
-
],
|
|
1973
|
-
};
|
|
1974
|
-
|
|
1975
|
-
const files = generateResources(services, ctxResolved);
|
|
1976
|
-
expect(files.length).toBe(1);
|
|
1977
|
-
const content = files[0].content;
|
|
1978
|
-
// Should use the resolved operation name (converted to camelCase)
|
|
1979
|
-
expect(content).toContain('async listOrganizationRoles');
|
|
1980
|
-
expect(content).not.toContain('async listRolesOrganizations');
|
|
1981
|
-
});
|
|
1982
|
-
|
|
1983
|
-
it('deduplicates method names for operations on different paths', () => {
|
|
1984
|
-
const services: Service[] = [
|
|
1985
|
-
{
|
|
1986
|
-
name: 'Organizations',
|
|
1987
|
-
operations: [
|
|
1988
|
-
{
|
|
1989
|
-
name: 'create',
|
|
1990
|
-
httpMethod: 'post',
|
|
1991
|
-
path: '/organization_domains',
|
|
1992
|
-
pathParams: [],
|
|
1993
|
-
queryParams: [],
|
|
1994
|
-
headerParams: [],
|
|
1995
|
-
requestBody: { kind: 'model', name: 'CreateOrgDomain' },
|
|
1996
|
-
response: { kind: 'model', name: 'OrgDomain' },
|
|
1997
|
-
errors: [],
|
|
1998
|
-
injectIdempotencyKey: false,
|
|
1999
|
-
},
|
|
2000
|
-
{
|
|
2001
|
-
name: 'create',
|
|
2002
|
-
httpMethod: 'post',
|
|
2003
|
-
path: '/organizations',
|
|
2004
|
-
pathParams: [],
|
|
2005
|
-
queryParams: [],
|
|
2006
|
-
headerParams: [],
|
|
2007
|
-
requestBody: { kind: 'model', name: 'CreateOrg' },
|
|
2008
|
-
response: { kind: 'model', name: 'Organization' },
|
|
2009
|
-
errors: [],
|
|
2010
|
-
injectIdempotencyKey: false,
|
|
2011
|
-
},
|
|
2012
|
-
],
|
|
2013
|
-
},
|
|
2014
|
-
];
|
|
2015
|
-
|
|
2016
|
-
const ctxDedup: EmitterContext = {
|
|
2017
|
-
...ctx,
|
|
2018
|
-
spec: {
|
|
2019
|
-
...emptySpec,
|
|
2020
|
-
services,
|
|
2021
|
-
models: [
|
|
2022
|
-
{ name: 'CreateOrgDomain', fields: [] },
|
|
2023
|
-
{ name: 'OrgDomain', fields: [] },
|
|
2024
|
-
{ name: 'CreateOrg', fields: [] },
|
|
2025
|
-
{ name: 'Organization', fields: [] },
|
|
2026
|
-
],
|
|
2027
|
-
},
|
|
2028
|
-
};
|
|
2029
|
-
|
|
2030
|
-
const files = generateResources(services, ctxDedup);
|
|
2031
|
-
expect(files.length).toBe(1);
|
|
2032
|
-
const content = files[0].content;
|
|
2033
|
-
// The best-scoring plan keeps the name; the other gets disambiguated.
|
|
2034
|
-
// "create" matches "organizations" path better (the word "create" doesn't
|
|
2035
|
-
// appear in either path, but scoring is equal — first wins).
|
|
2036
|
-
// The other gets a path suffix.
|
|
2037
|
-
const createMatches = content.match(/async create\b/g);
|
|
2038
|
-
// At most one un-suffixed "create"
|
|
2039
|
-
expect(createMatches?.length ?? 0).toBeLessThanOrEqual(1);
|
|
2040
|
-
// The two methods should have different names
|
|
2041
|
-
const methodNames = [...content.matchAll(/async (\w+)\(/g)].map((m) => m[1]);
|
|
2042
|
-
const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
|
|
2043
|
-
expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
|
|
2044
|
-
});
|
|
2045
|
-
|
|
2046
|
-
it('omits @param payload when overlay method has no payload param', () => {
|
|
2047
|
-
const services: Service[] = [
|
|
2048
|
-
{
|
|
2049
|
-
name: 'AuditLogs',
|
|
2050
|
-
operations: [
|
|
2051
|
-
{
|
|
2052
|
-
name: 'createEvent',
|
|
2053
|
-
httpMethod: 'post',
|
|
2054
|
-
path: '/audit_logs/events',
|
|
2055
|
-
pathParams: [],
|
|
2056
|
-
queryParams: [],
|
|
2057
|
-
headerParams: [],
|
|
2058
|
-
requestBody: { kind: 'model', name: 'CreateAuditLogEvent' },
|
|
2059
|
-
response: { kind: 'primitive', type: 'unknown' },
|
|
2060
|
-
errors: [],
|
|
2061
|
-
injectIdempotencyKey: false,
|
|
2062
|
-
},
|
|
2063
|
-
],
|
|
2064
|
-
},
|
|
2065
|
-
];
|
|
2066
|
-
|
|
2067
|
-
const overlayCtx: EmitterContext = {
|
|
2068
|
-
namespace: 'workos',
|
|
2069
|
-
namespacePascal: 'WorkOS',
|
|
2070
|
-
spec: { ...emptySpec, services, models: [] },
|
|
2071
|
-
overlayLookup: {
|
|
2072
|
-
methodByOperation: new Map([
|
|
2073
|
-
[
|
|
2074
|
-
'POST /audit_logs/events',
|
|
2075
|
-
{
|
|
2076
|
-
className: 'AuditLogs',
|
|
2077
|
-
methodName: 'createEvent',
|
|
2078
|
-
params: [
|
|
2079
|
-
{ name: 'organization', type: 'string', optional: false },
|
|
2080
|
-
{ name: 'event', type: 'CreateAuditLogEventOptions', optional: false },
|
|
2081
|
-
{ name: 'options', type: 'CreateAuditLogEventRequestOptions', optional: true },
|
|
2082
|
-
],
|
|
2083
|
-
returnType: 'Promise<void>',
|
|
2084
|
-
},
|
|
2085
|
-
],
|
|
2086
|
-
]),
|
|
2087
|
-
httpKeyByMethod: new Map(),
|
|
2088
|
-
interfaceByName: new Map(),
|
|
2089
|
-
typeAliasByName: new Map(),
|
|
2090
|
-
requiredExports: new Map(),
|
|
2091
|
-
modelNameByIR: new Map(),
|
|
2092
|
-
fileBySymbol: new Map(),
|
|
2093
|
-
},
|
|
2094
|
-
};
|
|
2095
|
-
|
|
2096
|
-
const files = generateResources(services, overlayCtx);
|
|
2097
|
-
const content = files[0].content;
|
|
2098
|
-
// Overlay has (organization, event, options) — no payload param
|
|
2099
|
-
expect(content).not.toContain('@param payload');
|
|
2100
|
-
});
|
|
2101
|
-
|
|
2102
|
-
it('documents @param options when overlay folds path params into options', () => {
|
|
2103
|
-
const services: Service[] = [
|
|
2104
|
-
{
|
|
2105
|
-
name: 'FeatureFlags',
|
|
2106
|
-
operations: [
|
|
2107
|
-
{
|
|
2108
|
-
name: 'addFeatureFlagTarget',
|
|
2109
|
-
httpMethod: 'post',
|
|
2110
|
-
path: '/feature-flags/{slug}/targets/{target_id}',
|
|
2111
|
-
pathParams: [
|
|
2112
|
-
{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
2113
|
-
{ name: 'target_id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
2114
|
-
],
|
|
2115
|
-
queryParams: [],
|
|
2116
|
-
headerParams: [],
|
|
2117
|
-
response: { kind: 'primitive', type: 'unknown' },
|
|
2118
|
-
errors: [],
|
|
2119
|
-
injectIdempotencyKey: false,
|
|
2120
|
-
},
|
|
2121
|
-
],
|
|
2122
|
-
},
|
|
2123
|
-
];
|
|
2124
|
-
|
|
2125
|
-
const overlayCtx: EmitterContext = {
|
|
2126
|
-
namespace: 'workos',
|
|
2127
|
-
namespacePascal: 'WorkOS',
|
|
2128
|
-
spec: { ...emptySpec, services, models: [] },
|
|
2129
|
-
overlayLookup: {
|
|
2130
|
-
methodByOperation: new Map([
|
|
2131
|
-
[
|
|
2132
|
-
'POST /feature-flags/{slug}/targets/{target_id}',
|
|
2133
|
-
{
|
|
2134
|
-
className: 'FeatureFlags',
|
|
2135
|
-
methodName: 'addFlagTarget',
|
|
2136
|
-
params: [{ name: 'options', type: 'AddFlagTargetOptions', optional: false }],
|
|
2137
|
-
returnType: 'Promise<void>',
|
|
2138
|
-
},
|
|
2139
|
-
],
|
|
2140
|
-
]),
|
|
2141
|
-
httpKeyByMethod: new Map(),
|
|
2142
|
-
interfaceByName: new Map(),
|
|
2143
|
-
typeAliasByName: new Map(),
|
|
2144
|
-
requiredExports: new Map(),
|
|
2145
|
-
modelNameByIR: new Map(),
|
|
2146
|
-
fileBySymbol: new Map(),
|
|
2147
|
-
},
|
|
494
|
+
} as any,
|
|
2148
495
|
};
|
|
2149
|
-
|
|
2150
|
-
const files = generateResources(services, overlayCtx);
|
|
2151
|
-
const content = files[0].content;
|
|
2152
|
-
// Path params (slug, targetId) are folded into options — should not appear as top-level @param
|
|
2153
|
-
expect(content).not.toContain('@param slug');
|
|
2154
|
-
expect(content).not.toContain('@param targetId');
|
|
2155
|
-
// Should have @param options since it's in the overlay signature
|
|
2156
|
-
expect(content).toContain('@param options');
|
|
496
|
+
expect(hasCompatibleConstructor('Utils', ctxWithEmptyCtor)).toBe(true);
|
|
2157
497
|
});
|
|
2158
498
|
});
|