@workos/oagen-emitters 0.12.1 → 0.12.3
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-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
- package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- 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 +354 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +547 -351
- package/src/node/naming.ts +122 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/path-expression.ts +11 -4
- package/src/node/resources.ts +473 -48
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +152 -93
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -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 +435 -2025
- package/test/node/tests.test.ts +214 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/dist/plugin-CmfzawTp.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,209 @@ 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
|
|
128
|
+
const spec: ApiSpec = { ...emptySpec, services };
|
|
129
|
+
const ctxWithSpec: EmitterContext = { ...ctx, spec };
|
|
130
|
+
const result = generateResources(services, ctxWithSpec);
|
|
101
131
|
|
|
102
|
-
|
|
103
|
-
expect(
|
|
104
|
-
expect(content).toContain(
|
|
105
|
-
|
|
106
|
-
// per-service barrel picks it up.
|
|
107
|
-
expect(content).toContain(
|
|
108
|
-
"import type { ListOrganizationsOptions } from './interfaces/list-organizations-options.interface';",
|
|
109
|
-
);
|
|
132
|
+
const resourceFile = result.find((f) => f.path.includes('organizations.ts'));
|
|
133
|
+
expect(resourceFile).toBeDefined();
|
|
134
|
+
expect(resourceFile!.content).toContain('Promise<void>');
|
|
135
|
+
});
|
|
110
136
|
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
};
|
|
118
169
|
|
|
119
|
-
|
|
120
|
-
expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
|
|
170
|
+
const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
|
|
121
171
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
expect(
|
|
125
|
-
expect(content).toContain('
|
|
126
|
-
expect(content).toContain('
|
|
127
|
-
expect(content).toContain('options,');
|
|
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');
|
|
128
177
|
});
|
|
129
178
|
|
|
130
|
-
it('
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
179
|
+
it('options-object: URL template binds to the SDK field name, not the spec path-param name', () => {
|
|
180
|
+
// When the spec uses `omId` as a path-param name but the baseline options
|
|
181
|
+
// interface exposes `organizationMembershipId`, both the destructure and
|
|
182
|
+
// the URL template should reference `organizationMembershipId` directly —
|
|
183
|
+
// no `organizationMembershipId: omId` rename indirection in the body.
|
|
184
|
+
const operation = {
|
|
185
|
+
name: 'removeOrganizationMembership',
|
|
186
|
+
httpMethod: 'delete' as const,
|
|
187
|
+
path: '/organizations/{organizationId}/groups/{groupId}/organization-memberships/{omId}',
|
|
188
|
+
pathParams: [
|
|
189
|
+
{ name: 'organizationId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
|
|
190
|
+
{ name: 'groupId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
|
|
191
|
+
{ name: 'omId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
|
|
192
|
+
],
|
|
193
|
+
queryParams: [],
|
|
194
|
+
headerParams: [],
|
|
195
|
+
response: { kind: 'primitive' as const, type: 'unknown' as const },
|
|
196
|
+
errors: [],
|
|
197
|
+
injectIdempotencyKey: false,
|
|
198
|
+
};
|
|
199
|
+
const service: Service = { name: 'Groups', operations: [operation] };
|
|
200
|
+
const spec: ApiSpec = { ...emptySpec, services: [service] };
|
|
201
|
+
const ctxWithBaseline: EmitterContext = {
|
|
202
|
+
...ctx,
|
|
203
|
+
spec,
|
|
204
|
+
emitterOptions: { ownedServices: ['Groups'] },
|
|
205
|
+
apiSurface: {
|
|
206
|
+
classes: {
|
|
207
|
+
Groups: {
|
|
208
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
209
|
+
methods: {
|
|
210
|
+
removeOrganizationMembership: [
|
|
211
|
+
{
|
|
212
|
+
name: 'removeOrganizationMembership',
|
|
213
|
+
params: [
|
|
214
|
+
{
|
|
215
|
+
name: 'options',
|
|
216
|
+
type: 'RemoveGroupOrganizationMembershipOptions',
|
|
217
|
+
passingStyle: 'options_object',
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
returnType: 'Promise<void>',
|
|
221
|
+
async: true,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
160
224
|
},
|
|
161
|
-
injectIdempotencyKey: false,
|
|
162
225
|
},
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
});
|
|
195
|
-
|
|
196
|
-
it('uses item type not list wrapper type for paginated methods', () => {
|
|
197
|
-
// The response model is the list wrapper (ConnectionList), but the pagination
|
|
198
|
-
// itemType is the actual item (Connection). The generated code should use the
|
|
199
|
-
// item type for fetchAndDeserialize, not the list wrapper.
|
|
200
|
-
const services: Service[] = [
|
|
201
|
-
{
|
|
202
|
-
name: 'SSO',
|
|
203
|
-
operations: [
|
|
204
|
-
{
|
|
205
|
-
name: 'listConnections',
|
|
206
|
-
httpMethod: 'get',
|
|
207
|
-
path: '/connections',
|
|
208
|
-
pathParams: [],
|
|
209
|
-
queryParams: [],
|
|
210
|
-
headerParams: [],
|
|
211
|
-
response: { kind: 'model', name: 'ConnectionList' },
|
|
212
|
-
errors: [],
|
|
213
|
-
pagination: {
|
|
214
|
-
strategy: 'cursor',
|
|
215
|
-
param: 'after',
|
|
216
|
-
dataPath: 'data',
|
|
217
|
-
itemType: { kind: 'model', name: 'Connection' },
|
|
226
|
+
},
|
|
227
|
+
interfaces: {
|
|
228
|
+
RemoveGroupOrganizationMembershipOptions: {
|
|
229
|
+
fields: {
|
|
230
|
+
organizationId: { type: 'string', required: true },
|
|
231
|
+
groupId: { type: 'string', required: true },
|
|
232
|
+
organizationMembershipId: { type: 'string', required: true },
|
|
218
233
|
},
|
|
219
|
-
injectIdempotencyKey: false,
|
|
220
234
|
},
|
|
221
|
-
|
|
222
|
-
},
|
|
223
|
-
];
|
|
224
|
-
|
|
225
|
-
const testCtx: EmitterContext = {
|
|
226
|
-
namespace: 'workos',
|
|
227
|
-
namespacePascal: 'WorkOS',
|
|
228
|
-
spec: { ...emptySpec, services, models: [] },
|
|
235
|
+
},
|
|
236
|
+
} as any,
|
|
229
237
|
};
|
|
230
238
|
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
expect(content).toContain('
|
|
236
|
-
expect(content).toContain('
|
|
237
|
-
expect(content).toContain('
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
239
|
+
const result = generateResources([service], ctxWithBaseline);
|
|
240
|
+
const resourceFile = result.find((f) => f.path === 'src/groups/groups.ts');
|
|
241
|
+
expect(resourceFile).toBeDefined();
|
|
242
|
+
const content = resourceFile!.content;
|
|
243
|
+
expect(content).toContain('const { organizationId, groupId, organizationMembershipId } = options;');
|
|
244
|
+
expect(content).toContain('${encodeURIComponent(organizationMembershipId)}');
|
|
245
|
+
expect(content).not.toContain('organizationMembershipId: omId');
|
|
246
|
+
expect(content).not.toContain('encodeURIComponent(omId)');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('drops brand-new service paths in an existing SDK by default', () => {
|
|
250
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
251
|
+
try {
|
|
252
|
+
const spec: ApiSpec = { ...emptySpec, services: [connectService] };
|
|
253
|
+
const result = nodeEmitter.generateResources(spec.services, { ...ctx, spec, outputDir: tmpRoot });
|
|
254
|
+
|
|
255
|
+
expect(result.some((f) => f.path === 'src/connect/connect.ts')).toBe(false);
|
|
256
|
+
} finally {
|
|
257
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('adopts brand-new service paths when configured', () => {
|
|
262
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
263
|
+
try {
|
|
264
|
+
const spec: ApiSpec = { ...emptySpec, services: [connectService] };
|
|
265
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
266
|
+
...ctx,
|
|
267
|
+
spec,
|
|
268
|
+
outputDir: tmpRoot,
|
|
269
|
+
emitterOptions: { adoptMissingServices: true },
|
|
270
|
+
} as EmitterContext);
|
|
271
|
+
|
|
272
|
+
const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
|
|
273
|
+
expect(resourceFile).toBeDefined();
|
|
274
|
+
expect(resourceFile!.content).toContain('export class Connect');
|
|
275
|
+
} finally {
|
|
276
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('overwrites tracked files for explicitly owned services', () => {
|
|
281
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
282
|
+
try {
|
|
283
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'groups'), { recursive: true });
|
|
284
|
+
fs.writeFileSync(
|
|
285
|
+
path.join(tmpRoot, 'src', 'groups', 'groups.ts'),
|
|
286
|
+
'export class Groups { async createGroup() {} }\n',
|
|
287
|
+
);
|
|
288
|
+
execFileSync('git', ['add', 'src/groups/groups.ts'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
289
|
+
|
|
290
|
+
const groupService: Service = {
|
|
291
|
+
name: 'Groups',
|
|
292
|
+
operations: [
|
|
293
|
+
{
|
|
294
|
+
name: 'createGroup',
|
|
295
|
+
httpMethod: 'post',
|
|
296
|
+
path: '/organizations/{organizationId}/groups',
|
|
297
|
+
pathParams: [{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
260
298
|
queryParams: [],
|
|
261
299
|
headerParams: [],
|
|
262
300
|
response: { kind: 'primitive', type: 'unknown' },
|
|
@@ -264,1895 +302,267 @@ describe('generateResources', () => {
|
|
|
264
302
|
injectIdempotencyKey: false,
|
|
265
303
|
},
|
|
266
304
|
],
|
|
267
|
-
}
|
|
268
|
-
|
|
305
|
+
};
|
|
306
|
+
const spec: ApiSpec = { ...emptySpec, services: [groupService] };
|
|
307
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
308
|
+
...ctx,
|
|
309
|
+
spec,
|
|
310
|
+
outputDir: tmpRoot,
|
|
311
|
+
emitterOptions: { ownedServices: ['Groups'] },
|
|
312
|
+
apiSurface: {
|
|
313
|
+
classes: {
|
|
314
|
+
Groups: {
|
|
315
|
+
methods: {
|
|
316
|
+
createGroup: [{ name: 'createGroup', params: [], returnType: 'Promise<void>', async: true }],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
} as any,
|
|
321
|
+
} as EmitterContext);
|
|
269
322
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
323
|
+
const resourceFile = result.find((f) => f.path === 'src/groups/groups.ts');
|
|
324
|
+
expect(resourceFile).toBeDefined();
|
|
325
|
+
expect(resourceFile!.overwriteExisting).toBe(true);
|
|
326
|
+
expect(resourceFile!.skipIfExists).toBe(false);
|
|
327
|
+
} finally {
|
|
328
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
329
|
+
}
|
|
274
330
|
});
|
|
275
331
|
|
|
276
|
-
it('
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
332
|
+
it('does not overwrite protected files for explicitly owned services', () => {
|
|
333
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
334
|
+
try {
|
|
335
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'groups'), { recursive: true });
|
|
336
|
+
fs.writeFileSync(
|
|
337
|
+
path.join(tmpRoot, 'src', 'groups', 'groups.ts'),
|
|
338
|
+
'// @oagen-ignore-file\nexport class Groups {}\n',
|
|
339
|
+
);
|
|
340
|
+
execFileSync('git', ['add', 'src/groups/groups.ts'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
341
|
+
|
|
342
|
+
const groupService: Service = {
|
|
343
|
+
name: 'Groups',
|
|
284
344
|
operations: [
|
|
285
345
|
{
|
|
286
|
-
name: '
|
|
287
|
-
httpMethod: '
|
|
288
|
-
path: '/
|
|
289
|
-
pathParams: [
|
|
290
|
-
{
|
|
291
|
-
name: 'id',
|
|
292
|
-
type: { kind: 'primitive', type: 'string' },
|
|
293
|
-
required: true,
|
|
294
|
-
},
|
|
295
|
-
],
|
|
346
|
+
name: 'createGroup',
|
|
347
|
+
httpMethod: 'post',
|
|
348
|
+
path: '/organizations/{organizationId}/groups',
|
|
349
|
+
pathParams: [{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
296
350
|
queryParams: [],
|
|
297
351
|
headerParams: [],
|
|
298
|
-
response: { kind: '
|
|
352
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
299
353
|
errors: [],
|
|
300
354
|
injectIdempotencyKey: false,
|
|
301
355
|
},
|
|
302
356
|
],
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
357
|
+
};
|
|
358
|
+
const spec: ApiSpec = { ...emptySpec, services: [groupService] };
|
|
359
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
360
|
+
...ctx,
|
|
361
|
+
spec,
|
|
362
|
+
outputDir: tmpRoot,
|
|
363
|
+
emitterOptions: { ownedServices: ['Groups'] },
|
|
364
|
+
} as EmitterContext);
|
|
365
|
+
|
|
366
|
+
expect(result.some((f) => f.path === 'src/groups/groups.ts')).toBe(false);
|
|
367
|
+
} finally {
|
|
368
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('treats prior-manifest generated files as managed before they are git-tracked', () => {
|
|
373
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
374
|
+
try {
|
|
375
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'connect'), { recursive: true });
|
|
376
|
+
fs.writeFileSync(
|
|
377
|
+
path.join(tmpRoot, 'src', 'connect', 'connect.ts'),
|
|
378
|
+
['// This file is auto-generated by oagen. Do not edit.', '', 'export class Connect {}'].join('\n'),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const spec: ApiSpec = { ...emptySpec, services: [connectService] };
|
|
382
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
383
|
+
...ctx,
|
|
384
|
+
spec,
|
|
385
|
+
outputDir: tmpRoot,
|
|
386
|
+
emitterOptions: { adoptMissingServices: true },
|
|
387
|
+
priorTargetManifestPaths: new Set(['src/connect/connect.ts']),
|
|
388
|
+
} as EmitterContext);
|
|
389
|
+
|
|
390
|
+
const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
|
|
391
|
+
expect(resourceFile).toBeDefined();
|
|
392
|
+
expect(resourceFile!.overwriteExisting).toBe(true);
|
|
393
|
+
} finally {
|
|
394
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('harvests serializer exports from prior-manifest generated files before they are git-tracked', () => {
|
|
399
|
+
const tmpRoot = createTrackedSdkRoot();
|
|
400
|
+
try {
|
|
401
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'connect', 'serializers'), { recursive: true });
|
|
402
|
+
fs.writeFileSync(
|
|
403
|
+
path.join(tmpRoot, 'src', 'connect', 'serializers', 'create-m2m-application.serializer.ts'),
|
|
404
|
+
[
|
|
405
|
+
'// This file is auto-generated by oagen. Do not edit.',
|
|
406
|
+
'',
|
|
407
|
+
'export const serializeCreateM2MApplication = (payload: unknown): unknown => payload;',
|
|
408
|
+
].join('\n'),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const service: Service = {
|
|
412
|
+
name: 'Connect',
|
|
321
413
|
operations: [
|
|
322
414
|
{
|
|
323
|
-
name: '
|
|
415
|
+
name: 'createApplication',
|
|
324
416
|
httpMethod: 'post',
|
|
325
|
-
path: '/
|
|
417
|
+
path: '/connect/applications',
|
|
326
418
|
pathParams: [],
|
|
327
419
|
queryParams: [],
|
|
328
420
|
headerParams: [],
|
|
329
|
-
requestBody: { kind: 'model', name: '
|
|
330
|
-
response: { kind: '
|
|
421
|
+
requestBody: { kind: 'model', name: 'CreateM2MApplication' },
|
|
422
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
331
423
|
errors: [],
|
|
332
|
-
injectIdempotencyKey:
|
|
424
|
+
injectIdempotencyKey: false,
|
|
333
425
|
},
|
|
334
426
|
],
|
|
335
|
-
}
|
|
336
|
-
|
|
427
|
+
};
|
|
428
|
+
const spec: ApiSpec = {
|
|
429
|
+
...emptySpec,
|
|
430
|
+
services: [service],
|
|
431
|
+
models: [
|
|
432
|
+
{
|
|
433
|
+
name: 'CreateM2MApplication',
|
|
434
|
+
fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
};
|
|
438
|
+
const result = nodeEmitter.generateResources(spec.services, {
|
|
439
|
+
...ctx,
|
|
440
|
+
spec,
|
|
441
|
+
outputDir: tmpRoot,
|
|
442
|
+
emitterOptions: { adoptMissingServices: true },
|
|
443
|
+
priorTargetManifestPaths: new Set([
|
|
444
|
+
'src/connect/connect.ts',
|
|
445
|
+
'src/connect/serializers/create-m2m-application.serializer.ts',
|
|
446
|
+
]),
|
|
447
|
+
} as EmitterContext);
|
|
448
|
+
|
|
449
|
+
const resourceFile = result.find((f) => f.path === 'src/connect/connect.ts');
|
|
450
|
+
expect(resourceFile?.content).toContain(
|
|
451
|
+
"import { serializeCreateM2MApplication } from './serializers/create-m2m-application.serializer';",
|
|
452
|
+
);
|
|
453
|
+
} finally {
|
|
454
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
});
|
|
337
458
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
459
|
+
describe('resolveResourceClassName', () => {
|
|
460
|
+
it('uses overlay name when baseline has compatible constructor', () => {
|
|
461
|
+
const service: Service = { name: 'Organizations', operations: [] };
|
|
462
|
+
const ctxWithBaseline: EmitterContext = {
|
|
463
|
+
...ctx,
|
|
464
|
+
apiSurface: {
|
|
465
|
+
classes: {
|
|
466
|
+
Organizations: {
|
|
467
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
} as any,
|
|
471
|
+
};
|
|
472
|
+
expect(resolveResourceClassName(service, ctxWithBaseline)).toBe('Organizations');
|
|
345
473
|
});
|
|
346
474
|
|
|
347
|
-
it('
|
|
348
|
-
const
|
|
349
|
-
name: '
|
|
475
|
+
it('appends Endpoints suffix when IR name collides with overlay name', () => {
|
|
476
|
+
const service: Service = {
|
|
477
|
+
name: 'Webhooks',
|
|
350
478
|
operations: [
|
|
351
479
|
{
|
|
352
|
-
name: '
|
|
353
|
-
httpMethod: '
|
|
354
|
-
path: '/
|
|
480
|
+
name: 'listWebhooks',
|
|
481
|
+
httpMethod: 'get',
|
|
482
|
+
path: '/webhooks',
|
|
355
483
|
pathParams: [],
|
|
356
484
|
queryParams: [],
|
|
357
485
|
headerParams: [],
|
|
358
|
-
|
|
359
|
-
response: { kind: 'model', name: 'AuthenticationFactor' },
|
|
486
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
360
487
|
errors: [],
|
|
361
|
-
injectIdempotencyKey:
|
|
488
|
+
injectIdempotencyKey: false,
|
|
362
489
|
},
|
|
363
490
|
],
|
|
364
491
|
};
|
|
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
|
-
},
|
|
492
|
+
const ctxWithIncompat: EmitterContext = {
|
|
493
|
+
...ctx,
|
|
494
|
+
apiSurface: {
|
|
495
|
+
classes: {
|
|
496
|
+
Webhooks: {
|
|
497
|
+
constructorParams: [{ name: 'crypto', type: 'CryptoProvider' }],
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
} as any,
|
|
501
|
+
resolvedOperations: [
|
|
502
|
+
{
|
|
503
|
+
operation: service.operations[0],
|
|
504
|
+
service,
|
|
505
|
+
methodName: 'list_webhooks',
|
|
506
|
+
mountOn: 'Webhooks',
|
|
507
|
+
defaults: {},
|
|
508
|
+
inferFromClient: [],
|
|
509
|
+
urlBuilder: false,
|
|
510
|
+
},
|
|
511
|
+
],
|
|
389
512
|
};
|
|
513
|
+
expect(resolveResourceClassName(service, ctxWithIncompat)).toBe('WebhooksEndpoints');
|
|
514
|
+
expect(resolveResourceDir(service, ctxWithIncompat)).toBe('webhooks');
|
|
390
515
|
|
|
391
|
-
const
|
|
392
|
-
expect(
|
|
393
|
-
expect(
|
|
394
|
-
|
|
395
|
-
const content = files[0].content;
|
|
396
|
-
expect(content).toContain('export class Mfa {');
|
|
516
|
+
const result = generateResources([service], { ...ctxWithIncompat, spec: { ...emptySpec, services: [service] } });
|
|
517
|
+
expect(result.some((f) => f.path === 'src/webhooks/webhooks-endpoints.ts')).toBe(true);
|
|
518
|
+
expect(result.some((f) => f.path === 'src/webhooks-endpoints/webhooks-endpoints.ts')).toBe(false);
|
|
397
519
|
});
|
|
520
|
+
});
|
|
398
521
|
|
|
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(' */');
|
|
522
|
+
describe('hasCompatibleConstructor', () => {
|
|
523
|
+
it('returns true when no baseline exists', () => {
|
|
524
|
+
expect(hasCompatibleConstructor('NewService', ctx)).toBe(true);
|
|
440
525
|
});
|
|
441
526
|
|
|
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,
|
|
527
|
+
it('returns true when baseline has workos: WorkOS param', () => {
|
|
528
|
+
const ctxWithBaseline: EmitterContext = {
|
|
529
|
+
...ctx,
|
|
530
|
+
apiSurface: {
|
|
531
|
+
classes: {
|
|
532
|
+
Organizations: {
|
|
533
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
463
534
|
},
|
|
464
|
-
|
|
465
|
-
},
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const files = generateResources(services, ctx);
|
|
469
|
-
const content = files[0].content;
|
|
470
|
-
expect(content).toContain('@returns {Promise<Organization>}');
|
|
535
|
+
},
|
|
536
|
+
} as any,
|
|
537
|
+
};
|
|
538
|
+
expect(hasCompatibleConstructor('Organizations', ctxWithBaseline)).toBe(true);
|
|
471
539
|
});
|
|
472
540
|
|
|
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,
|
|
541
|
+
it('returns false when baseline has incompatible constructor', () => {
|
|
542
|
+
const ctxWithIncompat: EmitterContext = {
|
|
543
|
+
...ctx,
|
|
544
|
+
apiSurface: {
|
|
545
|
+
classes: {
|
|
546
|
+
Webhooks: {
|
|
547
|
+
constructorParams: [{ name: 'crypto', type: 'CryptoProvider' }],
|
|
489
548
|
},
|
|
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
|
-
},
|
|
549
|
+
},
|
|
550
|
+
} as any,
|
|
517
551
|
};
|
|
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>}');
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
it('renders query param docs for non-paginated operations', () => {
|
|
528
|
-
const services: Service[] = [
|
|
529
|
-
{
|
|
530
|
-
name: 'Organizations',
|
|
531
|
-
operations: [
|
|
532
|
-
{
|
|
533
|
-
name: 'getOrganization',
|
|
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,
|
|
555
|
-
},
|
|
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);
|
|
552
|
+
expect(hasCompatibleConstructor('Webhooks', ctxWithIncompat)).toBe(false);
|
|
1730
553
|
});
|
|
1731
554
|
|
|
1732
555
|
it('returns true when baseline has no constructor params', () => {
|
|
1733
|
-
const
|
|
556
|
+
const ctxWithEmptyCtor: EmitterContext = {
|
|
1734
557
|
...ctx,
|
|
1735
558
|
apiSurface: {
|
|
1736
|
-
language: 'node',
|
|
1737
|
-
extractedFrom: 'test',
|
|
1738
|
-
extractedAt: '2024-01-01',
|
|
1739
559
|
classes: {
|
|
1740
|
-
|
|
1741
|
-
name: 'EmptyService',
|
|
1742
|
-
methods: {},
|
|
1743
|
-
properties: {},
|
|
560
|
+
Utils: {
|
|
1744
561
|
constructorParams: [],
|
|
1745
562
|
},
|
|
1746
563
|
},
|
|
1747
|
-
|
|
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
|
-
},
|
|
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
|
-
},
|
|
564
|
+
} as any,
|
|
2148
565
|
};
|
|
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');
|
|
566
|
+
expect(hasCompatibleConstructor('Utils', ctxWithEmptyCtor)).toBe(true);
|
|
2157
567
|
});
|
|
2158
568
|
});
|