@workos/oagen-emitters 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3288 -791
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +42 -12
- package/package.json +2 -2
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- package/src/go/index.ts +5 -2
- package/src/go/naming.ts +5 -17
- package/src/index.ts +1 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +50 -0
- package/src/node/index.ts +1 -0
- package/src/node/resources.ts +164 -44
- package/src/node/tests.ts +37 -7
- package/src/php/client.ts +11 -3
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +81 -6
- package/src/php/tests.ts +93 -17
- package/src/php/wrappers.ts +1 -0
- package/src/python/client.ts +37 -29
- package/src/python/enums.ts +7 -7
- package/src/python/models.ts +1 -1
- package/src/python/naming.ts +2 -22
- package/src/shared/model-utils.ts +232 -15
- package/src/shared/naming-utils.ts +47 -0
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/resources.test.ts +216 -15
- package/test/php/client.test.ts +2 -1
- package/test/php/resources.test.ts +38 -0
- package/test/php/tests.test.ts +67 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateTests } from '../../src/kotlin/tests.js';
|
|
3
|
+
import { generateEnums } from '../../src/kotlin/enums.js';
|
|
4
|
+
import type { EmitterContext, ApiSpec, Service, Model, ResolvedOperation } from '@workos/oagen';
|
|
5
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
6
|
+
|
|
7
|
+
const models: Model[] = [
|
|
8
|
+
{
|
|
9
|
+
name: 'Organization',
|
|
10
|
+
fields: [
|
|
11
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
12
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const services: Service[] = [
|
|
18
|
+
{
|
|
19
|
+
name: 'Organizations',
|
|
20
|
+
operations: [
|
|
21
|
+
{
|
|
22
|
+
name: 'getOrganization',
|
|
23
|
+
httpMethod: 'get',
|
|
24
|
+
path: '/organizations/{id}',
|
|
25
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
26
|
+
queryParams: [],
|
|
27
|
+
headerParams: [],
|
|
28
|
+
response: { kind: 'model', name: 'Organization' },
|
|
29
|
+
errors: [],
|
|
30
|
+
injectIdempotencyKey: false,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'deleteOrganization',
|
|
34
|
+
httpMethod: 'delete',
|
|
35
|
+
path: '/organizations/{id}',
|
|
36
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
37
|
+
queryParams: [],
|
|
38
|
+
headerParams: [],
|
|
39
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
40
|
+
errors: [],
|
|
41
|
+
injectIdempotencyKey: false,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const spec: ApiSpec = {
|
|
48
|
+
name: 'TestAPI',
|
|
49
|
+
version: '1.0.0',
|
|
50
|
+
baseUrl: 'https://api.workos.com',
|
|
51
|
+
services,
|
|
52
|
+
models,
|
|
53
|
+
enums: [],
|
|
54
|
+
sdk: defaultSdkBehavior(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function buildResolvedOps(services: Service[]): ResolvedOperation[] {
|
|
58
|
+
return services.flatMap((svc) =>
|
|
59
|
+
svc.operations.map((op) => ({
|
|
60
|
+
service: svc,
|
|
61
|
+
operation: op,
|
|
62
|
+
methodName: op.name,
|
|
63
|
+
mountOn: svc.name,
|
|
64
|
+
})),
|
|
65
|
+
) as ResolvedOperation[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ctx: EmitterContext = {
|
|
69
|
+
namespace: 'workos',
|
|
70
|
+
namespacePascal: 'WorkOS',
|
|
71
|
+
spec,
|
|
72
|
+
resolvedOperations: buildResolvedOps(services),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe('kotlin/tests', () => {
|
|
76
|
+
it('generates per-mount-group test files', () => {
|
|
77
|
+
generateEnums([], ctx);
|
|
78
|
+
const files = generateTests(spec, ctx);
|
|
79
|
+
const testFile = files.find((f) => f.path.includes('OrganizationsTest.kt'));
|
|
80
|
+
expect(testFile).toBeDefined();
|
|
81
|
+
|
|
82
|
+
const content = testFile!.content;
|
|
83
|
+
expect(content).toContain('class OrganizationsTest');
|
|
84
|
+
expect(content).toContain('TestBase');
|
|
85
|
+
expect(content).toContain('@Test');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('generates happy-path test for void/delete methods', () => {
|
|
89
|
+
generateEnums([], ctx);
|
|
90
|
+
const files = generateTests(spec, ctx);
|
|
91
|
+
const testFile = files.find((f) => f.path.includes('OrganizationsTest.kt'))!;
|
|
92
|
+
const content = testFile.content;
|
|
93
|
+
|
|
94
|
+
// Delete method should have an active test, not a @Disabled placeholder.
|
|
95
|
+
// Method name is trimmed from deleteOrganization -> delete by resolveMethodName.
|
|
96
|
+
expect(content).toContain('delete completes without throwing');
|
|
97
|
+
expect(content).not.toContain('@Disabled("generator: could not synthesize required arguments for delete")');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('generates field-value assertions for non-void responses', () => {
|
|
101
|
+
generateEnums([], ctx);
|
|
102
|
+
const files = generateTests(spec, ctx);
|
|
103
|
+
const testFile = files.find((f) => f.path.includes('OrganizationsTest.kt'))!;
|
|
104
|
+
const content = testFile.content;
|
|
105
|
+
|
|
106
|
+
// GET method returning Organization should assert field values
|
|
107
|
+
expect(content).toContain('assertEquals("sample", result.id)');
|
|
108
|
+
expect(content).toContain('assertEquals("sample", result.name)');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('generates error-mapping tests', () => {
|
|
112
|
+
generateEnums([], ctx);
|
|
113
|
+
const files = generateTests(spec, ctx);
|
|
114
|
+
const testFile = files.find((f) => f.path.includes('OrganizationsTest.kt'))!;
|
|
115
|
+
const content = testFile.content;
|
|
116
|
+
|
|
117
|
+
expect(content).toContain('UnauthorizedException');
|
|
118
|
+
expect(content).toContain('NotFoundException');
|
|
119
|
+
expect(content).toContain('RateLimitException');
|
|
120
|
+
expect(content).toContain('GenericServerException');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('generates round-trip test using synthJson for broader coverage', () => {
|
|
124
|
+
generateEnums([], ctx);
|
|
125
|
+
const files = generateTests(spec, ctx);
|
|
126
|
+
const roundTrip = files.find((f) => f.path.includes('GeneratedModelRoundTripTest.kt'));
|
|
127
|
+
expect(roundTrip).toBeDefined();
|
|
128
|
+
|
|
129
|
+
const content = roundTrip!.content;
|
|
130
|
+
expect(content).toContain('Organization round-trips through Jackson');
|
|
131
|
+
expect(content).toContain('assertEquals(tree1, tree2)');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('generates forward-compat test with OffsetDateTime round-trip', () => {
|
|
135
|
+
generateEnums([], ctx);
|
|
136
|
+
const files = generateTests(spec, ctx);
|
|
137
|
+
const fwdCompat = files.find((f) => f.path.includes('GeneratedForwardCompatTest.kt'));
|
|
138
|
+
expect(fwdCompat).toBeDefined();
|
|
139
|
+
|
|
140
|
+
const content = fwdCompat!.content;
|
|
141
|
+
expect(content).toContain('OffsetDateTime round-trips');
|
|
142
|
+
expect(content).toContain('assertEquals(parsed.toInstant(), reparsed.toInstant())');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('emits valid ISO-8601 for date-time fields in round-trip fixtures', () => {
|
|
146
|
+
const dtModels: Model[] = [
|
|
147
|
+
{
|
|
148
|
+
name: 'Event',
|
|
149
|
+
fields: [
|
|
150
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
151
|
+
{
|
|
152
|
+
name: 'created_at',
|
|
153
|
+
type: { kind: 'primitive', type: 'string', format: 'date-time' },
|
|
154
|
+
required: true,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
const dtSpec: ApiSpec = { ...spec, models: dtModels };
|
|
160
|
+
const dtCtx: EmitterContext = {
|
|
161
|
+
...ctx,
|
|
162
|
+
spec: dtSpec,
|
|
163
|
+
resolvedOperations: buildResolvedOps(services),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
generateEnums([], dtCtx);
|
|
167
|
+
const files = generateTests(dtSpec, dtCtx);
|
|
168
|
+
const roundTrip = files.find((f) => f.path.includes('GeneratedModelRoundTripTest.kt'));
|
|
169
|
+
expect(roundTrip).toBeDefined();
|
|
170
|
+
|
|
171
|
+
const content = roundTrip!.content;
|
|
172
|
+
// Should use ISO-8601 timestamp, not "sample"
|
|
173
|
+
expect(content).toContain('2024-01-01T00:00:00Z');
|
|
174
|
+
expect(content).not.toMatch(/created_at.*"sample"/);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/test/node/client.test.ts
CHANGED
|
@@ -194,6 +194,80 @@ describe('generateClient', () => {
|
|
|
194
194
|
expect(serviceBarrel!.content).toContain("export * from './authentication-factor.interface';");
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
it('propagates @deprecated from baseline service class to the property declaration', () => {
|
|
198
|
+
// Regression test for PR #1535 reviewer comment r3075509969: when a
|
|
199
|
+
// service class has `@deprecated` in its JSDoc, TS's deprecation-lint
|
|
200
|
+
// only fires at `new X()` call sites — not at `workos.x` access unless
|
|
201
|
+
// the property itself is annotated. The emitter propagates the class
|
|
202
|
+
// deprecation to the property JSDoc so IDEs surface the strikethrough
|
|
203
|
+
// on every access.
|
|
204
|
+
const deprecatedCtx: EmitterContext = {
|
|
205
|
+
...ctx,
|
|
206
|
+
apiSurface: {
|
|
207
|
+
language: 'node',
|
|
208
|
+
extractedFrom: 'test',
|
|
209
|
+
extractedAt: '2026-01-01',
|
|
210
|
+
classes: {
|
|
211
|
+
Organizations: {
|
|
212
|
+
name: 'Organizations',
|
|
213
|
+
methods: {},
|
|
214
|
+
properties: {},
|
|
215
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
216
|
+
deprecationMessage: 'Use `workos.connect` instead.',
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
interfaces: {},
|
|
220
|
+
typeAliases: {},
|
|
221
|
+
enums: {},
|
|
222
|
+
exports: {},
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const files = generateClient(spec, deprecatedCtx);
|
|
227
|
+
const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
|
|
228
|
+
const content = workosFile.content;
|
|
229
|
+
|
|
230
|
+
// Property JSDoc carries the deprecation and the directive is preserved
|
|
231
|
+
// on the line immediately above the accessor.
|
|
232
|
+
expect(content).toMatch(
|
|
233
|
+
/\/\*\* @deprecated Use `workos\.connect` instead\. \*\/\s+readonly organizations = new Organizations\(this\);/,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('emits a bare @deprecated when the baseline class deprecation has no message', () => {
|
|
238
|
+
const deprecatedCtx: EmitterContext = {
|
|
239
|
+
...ctx,
|
|
240
|
+
apiSurface: {
|
|
241
|
+
language: 'node',
|
|
242
|
+
extractedFrom: 'test',
|
|
243
|
+
extractedAt: '2026-01-01',
|
|
244
|
+
classes: {
|
|
245
|
+
Organizations: {
|
|
246
|
+
name: 'Organizations',
|
|
247
|
+
methods: {},
|
|
248
|
+
properties: {},
|
|
249
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
250
|
+
deprecationMessage: '',
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
interfaces: {},
|
|
254
|
+
typeAliases: {},
|
|
255
|
+
enums: {},
|
|
256
|
+
exports: {},
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const files = generateClient(spec, deprecatedCtx);
|
|
261
|
+
const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
|
|
262
|
+
expect(workosFile.content).toMatch(/\/\*\* @deprecated \*\/\s+readonly organizations = new Organizations\(this\);/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('does not emit @deprecated when the baseline class has no deprecationMessage', () => {
|
|
266
|
+
const files = generateClient(spec, ctx);
|
|
267
|
+
const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
|
|
268
|
+
expect(workosFile.content).not.toContain('@deprecated');
|
|
269
|
+
});
|
|
270
|
+
|
|
197
271
|
it('does not generate error handling in WorkOS client (lives in WorkOSBase)', () => {
|
|
198
272
|
const files = generateClient(spec, ctx);
|
|
199
273
|
const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
|
|
@@ -102,13 +102,93 @@ describe('generateResources', () => {
|
|
|
102
102
|
// Should have AutoPaginatable type import and createPaginatedList import
|
|
103
103
|
expect(content).toContain("import type { AutoPaginatable } from '../common/utils/pagination'");
|
|
104
104
|
expect(content).toContain("import { createPaginatedList } from '../common/utils/fetch-and-deserialize'");
|
|
105
|
+
// Options interface lives in its own file under interfaces/ so the
|
|
106
|
+
// per-service barrel picks it up.
|
|
107
|
+
expect(content).toContain(
|
|
108
|
+
"import type { ListOrganizationsOptions } from './interfaces/list-organizations-options.interface';",
|
|
109
|
+
);
|
|
105
110
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
// The options interface file is emitted separately.
|
|
112
|
+
const optionsFile = files.find(
|
|
113
|
+
(f) => f.path === 'src/organizations/interfaces/list-organizations-options.interface.ts',
|
|
114
|
+
);
|
|
115
|
+
expect(optionsFile).toBeDefined();
|
|
116
|
+
expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
|
|
117
|
+
expect(optionsFile!.content).toContain('domains?: string[];');
|
|
109
118
|
|
|
110
119
|
// Should return AutoPaginatable
|
|
111
120
|
expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
|
|
121
|
+
|
|
122
|
+
// `domains` has the same camelCase and snake_case spelling, so no wire
|
|
123
|
+
// serializer should be emitted and createPaginatedList should be called
|
|
124
|
+
// with just options (no 5th arg).
|
|
125
|
+
expect(content).not.toContain('serializeListOrganizationsOptions');
|
|
126
|
+
expect(content).toMatch(/createPaginatedList<[^>]+>\([^)]+options\);/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('emits wire-options serializer for paginated list with camelCase filter fields', () => {
|
|
130
|
+
// Regression test for PR #1535 reviewer comment r3075477146: list
|
|
131
|
+
// methods whose extended filter fields have divergent camelCase/snake_case
|
|
132
|
+
// spellings (e.g. `organizationId` ↔ `organization_id`) must translate
|
|
133
|
+
// keys before hitting the wire, otherwise the API silently ignores them.
|
|
134
|
+
const services: Service[] = [
|
|
135
|
+
{
|
|
136
|
+
name: 'Applications',
|
|
137
|
+
operations: [
|
|
138
|
+
{
|
|
139
|
+
name: 'listApplications',
|
|
140
|
+
httpMethod: 'get',
|
|
141
|
+
path: '/connect/applications',
|
|
142
|
+
pathParams: [],
|
|
143
|
+
queryParams: [
|
|
144
|
+
{
|
|
145
|
+
name: 'organization_id',
|
|
146
|
+
type: { kind: 'primitive', type: 'string' },
|
|
147
|
+
required: false,
|
|
148
|
+
description: 'Filter by organization ID.',
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
headerParams: [],
|
|
152
|
+
response: { kind: 'model', name: 'ConnectApplication' },
|
|
153
|
+
errors: [],
|
|
154
|
+
pagination: {
|
|
155
|
+
strategy: 'cursor',
|
|
156
|
+
param: 'after',
|
|
157
|
+
dataPath: 'data',
|
|
158
|
+
itemType: { kind: 'model', name: 'ConnectApplication' },
|
|
159
|
+
},
|
|
160
|
+
injectIdempotencyKey: false,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const files = generateResources(services, ctx);
|
|
167
|
+
const content = files[0].content;
|
|
168
|
+
|
|
169
|
+
// Options interface uses camelCase (user-facing) and lives in its own file.
|
|
170
|
+
const optionsFile = files.find(
|
|
171
|
+
(f) => f.path === 'src/applications/interfaces/list-applications-options.interface.ts',
|
|
172
|
+
);
|
|
173
|
+
expect(optionsFile).toBeDefined();
|
|
174
|
+
expect(optionsFile!.content).toContain('export interface ListApplicationsOptions extends PaginationOptions {');
|
|
175
|
+
expect(optionsFile!.content).toContain('organizationId?: string;');
|
|
176
|
+
|
|
177
|
+
// Resource class imports the options type from the interface file.
|
|
178
|
+
expect(content).toContain(
|
|
179
|
+
"import type { ListApplicationsOptions } from './interfaces/list-applications-options.interface';",
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Wire-options serializer emits snake_case key for the extension field
|
|
183
|
+
// and leaves standard pagination fields unchanged.
|
|
184
|
+
expect(content).toContain(
|
|
185
|
+
'const serializeListApplicationsOptions = (options: ListApplicationsOptions): PaginationOptions => {',
|
|
186
|
+
);
|
|
187
|
+
expect(content).toContain('wire.organization_id = options.organizationId');
|
|
188
|
+
expect(content).not.toContain('wire.organizationId');
|
|
189
|
+
|
|
190
|
+
// createPaginatedList is invoked with the serializer as the 5th arg.
|
|
191
|
+
expect(content).toMatch(/createPaginatedList<[^>]+>\([^)]+options,\s*serializeListApplicationsOptions\);/);
|
|
112
192
|
});
|
|
113
193
|
|
|
114
194
|
it('uses item type not list wrapper type for paginated methods', () => {
|
|
@@ -191,6 +271,47 @@ describe('generateResources', () => {
|
|
|
191
271
|
expect(content).toContain('await this.workos.delete(');
|
|
192
272
|
});
|
|
193
273
|
|
|
274
|
+
it('generates unpaginated GET returning an array of models', () => {
|
|
275
|
+
// Regression test for PR #1535 reviewer comment (r3074705330): endpoints
|
|
276
|
+
// whose OpenAPI response is `type: array` must return `Model[]` and map
|
|
277
|
+
// the deserializer over each element, not treat the array as a single
|
|
278
|
+
// object (which silently produces garbage at runtime).
|
|
279
|
+
const services: Service[] = [
|
|
280
|
+
{
|
|
281
|
+
name: 'Secrets',
|
|
282
|
+
operations: [
|
|
283
|
+
{
|
|
284
|
+
name: 'listSecrets',
|
|
285
|
+
httpMethod: 'get',
|
|
286
|
+
path: '/applications/{id}/secrets',
|
|
287
|
+
pathParams: [
|
|
288
|
+
{
|
|
289
|
+
name: 'id',
|
|
290
|
+
type: { kind: 'primitive', type: 'string' },
|
|
291
|
+
required: true,
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
queryParams: [],
|
|
295
|
+
headerParams: [],
|
|
296
|
+
response: { kind: 'array', items: { kind: 'model', name: 'Secret' } },
|
|
297
|
+
errors: [],
|
|
298
|
+
injectIdempotencyKey: false,
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
const files = generateResources(services, ctx);
|
|
305
|
+
const content = files[0].content;
|
|
306
|
+
|
|
307
|
+
expect(content).toContain('async listSecrets(id: string): Promise<Secret[]>');
|
|
308
|
+
expect(content).toContain('this.workos.get<SecretResponse[]>');
|
|
309
|
+
expect(content).toContain('return data.map(deserializeSecret);');
|
|
310
|
+
// Should NOT produce the single-object form — that was the bug.
|
|
311
|
+
expect(content).not.toMatch(/Promise<Secret>\s*\{/);
|
|
312
|
+
expect(content).not.toContain('return deserializeSecret(data);');
|
|
313
|
+
});
|
|
314
|
+
|
|
194
315
|
it('generates POST method with body and idempotency', () => {
|
|
195
316
|
const services: Service[] = [
|
|
196
317
|
{
|
|
@@ -891,14 +1012,20 @@ describe('generateResources', () => {
|
|
|
891
1012
|
// Should use the union type for the payload parameter
|
|
892
1013
|
expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
|
|
893
1014
|
|
|
894
|
-
// Should dispatch to the correct serializer based on the discriminator
|
|
895
|
-
|
|
896
|
-
expect(content).toContain(
|
|
897
|
-
expect(content).toContain("case '
|
|
1015
|
+
// Should dispatch to the correct serializer based on the discriminator,
|
|
1016
|
+
// using the typed discriminator so TS narrows payload per case.
|
|
1017
|
+
expect(content).toContain('switch (payload.grantType)');
|
|
1018
|
+
expect(content).toContain("case 'password': return serializeAuthByPassword(payload)");
|
|
1019
|
+
expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload)");
|
|
898
1020
|
expect(content).toContain(
|
|
899
|
-
"case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload
|
|
1021
|
+
"case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload)",
|
|
900
1022
|
);
|
|
901
1023
|
|
|
1024
|
+
// Should not use `as any` casts — TS discriminated-union narrowing makes
|
|
1025
|
+
// them unnecessary and they suppress real type mismatches.
|
|
1026
|
+
expect(content).not.toContain('switch ((payload as any)');
|
|
1027
|
+
expect(content).not.toMatch(/return serialize\w+\(payload as any\)/);
|
|
1028
|
+
|
|
902
1029
|
// Should import serializers for all union variants
|
|
903
1030
|
expect(content).toContain('serializeAuthByPassword');
|
|
904
1031
|
expect(content).toContain('serializeAuthByCode');
|
|
@@ -906,6 +1033,13 @@ describe('generateResources', () => {
|
|
|
906
1033
|
|
|
907
1034
|
// Should NOT pass payload directly without serialization
|
|
908
1035
|
expect(content).not.toMatch(/,\n\s+payload,\n/);
|
|
1036
|
+
|
|
1037
|
+
// Default branch must throw — silently forwarding unserialized camelCase
|
|
1038
|
+
// to the API produces malformed requests when the discriminator is unknown.
|
|
1039
|
+
expect(content).toContain('default:');
|
|
1040
|
+
expect(content).toContain('const _unknown: never = payload');
|
|
1041
|
+
expect(content).toContain('throw new Error');
|
|
1042
|
+
expect(content).not.toMatch(/default:\s*return payload/);
|
|
909
1043
|
});
|
|
910
1044
|
|
|
911
1045
|
it('generates discriminated union serializer dispatch for void method', () => {
|
|
@@ -945,10 +1079,10 @@ describe('generateResources', () => {
|
|
|
945
1079
|
const files = generateResources(services, ctx);
|
|
946
1080
|
const content = files[0].content;
|
|
947
1081
|
|
|
948
|
-
// Should dispatch to the correct serializer
|
|
949
|
-
expect(content).toContain('switch (
|
|
950
|
-
expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload
|
|
951
|
-
expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload
|
|
1082
|
+
// Should dispatch to the correct serializer using the typed discriminator.
|
|
1083
|
+
expect(content).toContain('switch (payload.grantType)');
|
|
1084
|
+
expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload)");
|
|
1085
|
+
expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload)");
|
|
952
1086
|
});
|
|
953
1087
|
|
|
954
1088
|
it('uses createPaginatedList helper in paginated methods', () => {
|
|
@@ -1048,10 +1182,13 @@ describe('generateResources', () => {
|
|
|
1048
1182
|
const content = files[0].content;
|
|
1049
1183
|
|
|
1050
1184
|
// Should use service-prefixed options name instead of generic "ListOptions"
|
|
1051
|
-
|
|
1185
|
+
const optionsFile = files.find((f) => f.path.endsWith('payments-list-options.interface.ts'));
|
|
1186
|
+
expect(optionsFile).toBeDefined();
|
|
1187
|
+
expect(optionsFile!.content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
|
|
1052
1188
|
expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
|
|
1053
1189
|
// Should NOT use the generic "ListOptions"
|
|
1054
1190
|
expect(content).not.toContain('export interface ListOptions ');
|
|
1191
|
+
expect(files.every((f) => !f.path.endsWith('/list-options.interface.ts'))).toBe(true);
|
|
1055
1192
|
});
|
|
1056
1193
|
|
|
1057
1194
|
it('does not prefix ListOptions when method is not "list"', () => {
|
|
@@ -1090,10 +1227,11 @@ describe('generateResources', () => {
|
|
|
1090
1227
|
];
|
|
1091
1228
|
|
|
1092
1229
|
const files = generateResources(services, ctx);
|
|
1093
|
-
const content = files[0].content;
|
|
1094
1230
|
|
|
1095
1231
|
// Method is "listOrganizations", not "list", so options name should be normal
|
|
1096
|
-
|
|
1232
|
+
const optionsFile = files.find((f) => f.path.endsWith('list-organizations-options.interface.ts'));
|
|
1233
|
+
expect(optionsFile).toBeDefined();
|
|
1234
|
+
expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
|
|
1097
1235
|
});
|
|
1098
1236
|
|
|
1099
1237
|
it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
|
|
@@ -1274,6 +1412,69 @@ describe('generateResources', () => {
|
|
|
1274
1412
|
// skipIfExists should stay true because all methods exist in baseline
|
|
1275
1413
|
expect(files[0].skipIfExists).toBe(true);
|
|
1276
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
|
+
});
|
|
1277
1478
|
});
|
|
1278
1479
|
|
|
1279
1480
|
describe('resolveResourceClassName', () => {
|
package/test/php/client.test.ts
CHANGED
|
@@ -74,8 +74,9 @@ describe('generateClient', () => {
|
|
|
74
74
|
expect(result[0].content).toContain("string $baseUrl = 'https://api.example.com'");
|
|
75
75
|
expect(result[0].content).toContain('int $timeout = 60');
|
|
76
76
|
expect(result[0].content).toContain('int $maxRetries = 3');
|
|
77
|
+
expect(result[0].content).toContain('?string $userAgent = null');
|
|
77
78
|
expect(result[0].content).toContain(
|
|
78
|
-
'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler)',
|
|
79
|
+
'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent)',
|
|
79
80
|
);
|
|
80
81
|
expect(result[0].content).not.toContain('self::$apiKey = $apiKey;');
|
|
81
82
|
expect(result[0].content).not.toContain('self::$clientId = $clientId;');
|
|
@@ -470,6 +470,44 @@ describe('generateResources', () => {
|
|
|
470
470
|
// Should inject default and inferred values into query
|
|
471
471
|
expect(content).toContain("'response_type' => 'code'");
|
|
472
472
|
expect(content).toContain("$query['client_id'] = $this->client->requireClientId()");
|
|
473
|
+
|
|
474
|
+
// Redirect endpoint: should return string and build URL, not make HTTP request
|
|
475
|
+
expect(content).toContain('): string {');
|
|
476
|
+
expect(content).toContain('$this->client->buildUrl(');
|
|
477
|
+
expect(content).not.toContain('$this->client->request(');
|
|
478
|
+
expect(content).toContain('@return string');
|
|
479
|
+
// Should pass $options to buildUrl for base URL overrides
|
|
480
|
+
expect(content).toContain('$options);');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('generates redirect endpoint that builds URL for GET with primitive unknown response', () => {
|
|
484
|
+
const logoutServices: Service[] = [
|
|
485
|
+
{
|
|
486
|
+
name: 'SSO',
|
|
487
|
+
operations: [
|
|
488
|
+
{
|
|
489
|
+
name: 'getLogoutUrl',
|
|
490
|
+
httpMethod: 'get',
|
|
491
|
+
path: '/sso/logout',
|
|
492
|
+
pathParams: [],
|
|
493
|
+
queryParams: [{ name: 'token', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
494
|
+
headerParams: [],
|
|
495
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
496
|
+
errors: [],
|
|
497
|
+
injectIdempotencyKey: false,
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
},
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
const spec = { ...emptySpec, services: logoutServices };
|
|
504
|
+
const result = generateResources(logoutServices, { ...ctx, spec });
|
|
505
|
+
const content = result[0].content;
|
|
506
|
+
|
|
507
|
+
expect(content).toContain('): string {');
|
|
508
|
+
expect(content).toContain("return $this->client->buildUrl('sso/logout', $query, $options);");
|
|
509
|
+
expect(content).not.toContain('$this->client->request(');
|
|
510
|
+
expect(content).toContain('@return string');
|
|
473
511
|
});
|
|
474
512
|
|
|
475
513
|
it('skips base method when wrappers exist', () => {
|