@workos/oagen-emitters 0.2.1 → 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/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14549 -3385
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +328 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +45 -12
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- 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/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +84 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +179 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +4 -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 +128 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +3 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +527 -397
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +69 -19
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +179 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +279 -0
- package/src/php/resources.ts +636 -0
- package/src/php/tests.ts +609 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +152 -0
- package/src/python/client.ts +345 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +189 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +472 -0
- package/src/shared/naming-utils.ts +154 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +70 -0
- 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/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +92 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +315 -84
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +95 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +682 -0
- package/test/php/tests.test.ts +185 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { generateResources, resolveResourceClassName, hasCompatibleConstructor } from '../../src/node/resources.js';
|
|
3
3
|
import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
|
|
4
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
5
|
|
|
5
6
|
const emptySpec: ApiSpec = {
|
|
6
7
|
name: 'Test',
|
|
@@ -9,6 +10,7 @@ const emptySpec: ApiSpec = {
|
|
|
9
10
|
services: [],
|
|
10
11
|
models: [],
|
|
11
12
|
enums: [],
|
|
13
|
+
sdk: defaultSdkBehavior(),
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
const ctx: EmitterContext = {
|
|
@@ -100,13 +102,93 @@ describe('generateResources', () => {
|
|
|
100
102
|
// Should have AutoPaginatable type import and createPaginatedList import
|
|
101
103
|
expect(content).toContain("import type { AutoPaginatable } from '../common/utils/pagination'");
|
|
102
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
|
+
);
|
|
103
110
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
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[];');
|
|
107
118
|
|
|
108
119
|
// Should return AutoPaginatable
|
|
109
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\);/);
|
|
110
192
|
});
|
|
111
193
|
|
|
112
194
|
it('uses item type not list wrapper type for paginated methods', () => {
|
|
@@ -189,6 +271,47 @@ describe('generateResources', () => {
|
|
|
189
271
|
expect(content).toContain('await this.workos.delete(');
|
|
190
272
|
});
|
|
191
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
|
+
|
|
192
315
|
it('generates POST method with body and idempotency', () => {
|
|
193
316
|
const services: Service[] = [
|
|
194
317
|
{
|
|
@@ -309,7 +432,7 @@ describe('generateResources', () => {
|
|
|
309
432
|
expect(content).toContain(' *');
|
|
310
433
|
expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
|
|
311
434
|
expect(content).toContain(' * @param id - The unique identifier of the attempt.');
|
|
312
|
-
expect(content).toContain(' * @returns {RadarAttempt}');
|
|
435
|
+
expect(content).toContain(' * @returns {Promise<RadarAttempt>}');
|
|
313
436
|
expect(content).toContain(' * @deprecated');
|
|
314
437
|
expect(content).toContain(' */');
|
|
315
438
|
});
|
|
@@ -342,7 +465,61 @@ describe('generateResources', () => {
|
|
|
342
465
|
|
|
343
466
|
const files = generateResources(services, ctx);
|
|
344
467
|
const content = files[0].content;
|
|
345
|
-
expect(content).toContain('@returns {Organization}');
|
|
468
|
+
expect(content).toContain('@returns {Promise<Organization>}');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('renders @returns from overlay return type when available', () => {
|
|
472
|
+
const services: Service[] = [
|
|
473
|
+
{
|
|
474
|
+
name: 'Authorization',
|
|
475
|
+
operations: [
|
|
476
|
+
{
|
|
477
|
+
name: 'createEnvironmentRole',
|
|
478
|
+
httpMethod: 'post',
|
|
479
|
+
path: '/authorization/roles',
|
|
480
|
+
pathParams: [],
|
|
481
|
+
queryParams: [],
|
|
482
|
+
headerParams: [],
|
|
483
|
+
requestBody: { kind: 'model', name: 'CreateRoleInput' },
|
|
484
|
+
response: { kind: 'model', name: 'Role' },
|
|
485
|
+
errors: [],
|
|
486
|
+
injectIdempotencyKey: false,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
];
|
|
491
|
+
|
|
492
|
+
const overlayCtx: EmitterContext = {
|
|
493
|
+
namespace: 'workos',
|
|
494
|
+
namespacePascal: 'WorkOS',
|
|
495
|
+
spec: { ...emptySpec, services, models: [] },
|
|
496
|
+
overlayLookup: {
|
|
497
|
+
methodByOperation: new Map([
|
|
498
|
+
[
|
|
499
|
+
'POST /authorization/roles',
|
|
500
|
+
{
|
|
501
|
+
className: 'Authorization',
|
|
502
|
+
methodName: 'createEnvironmentRole',
|
|
503
|
+
params: [{ name: 'payload', type: 'CreateRoleInput', optional: false }],
|
|
504
|
+
returnType: 'Promise<EnvironmentRole>',
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
]),
|
|
508
|
+
httpKeyByMethod: new Map(),
|
|
509
|
+
interfaceByName: new Map(),
|
|
510
|
+
typeAliasByName: new Map(),
|
|
511
|
+
requiredExports: new Map(),
|
|
512
|
+
modelNameByIR: new Map(),
|
|
513
|
+
fileBySymbol: new Map(),
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const files = generateResources(services, overlayCtx);
|
|
518
|
+
const content = files[0].content;
|
|
519
|
+
// JSDoc should use the overlay return type, not the spec schema name
|
|
520
|
+
expect(content).toContain('@returns {Promise<EnvironmentRole>}');
|
|
521
|
+
expect(content).not.toContain('@returns {Role}');
|
|
522
|
+
expect(content).not.toContain('@returns {Promise<Role>}');
|
|
346
523
|
});
|
|
347
524
|
|
|
348
525
|
it('renders query param docs for non-paginated operations', () => {
|
|
@@ -466,9 +643,9 @@ describe('generateResources', () => {
|
|
|
466
643
|
const files = generateResources(services, ctx);
|
|
467
644
|
const content = files[0].content;
|
|
468
645
|
// Only emit a single @returns for the primary response model (no status-code variants)
|
|
469
|
-
expect(content).toContain('@returns {Organization}');
|
|
470
|
-
expect(content).not.toContain('@returns {Organization} 200');
|
|
471
|
-
expect(content).not.toContain('@returns {Organization} 201');
|
|
646
|
+
expect(content).toContain('@returns {Promise<Organization>}');
|
|
647
|
+
expect(content).not.toContain('@returns {Promise<Organization>} 200');
|
|
648
|
+
expect(content).not.toContain('@returns {Promise<Organization>} 201');
|
|
472
649
|
});
|
|
473
650
|
|
|
474
651
|
it('generates DELETE-with-body method using deleteWithBody', () => {
|
|
@@ -835,14 +1012,20 @@ describe('generateResources', () => {
|
|
|
835
1012
|
// Should use the union type for the payload parameter
|
|
836
1013
|
expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
|
|
837
1014
|
|
|
838
|
-
// Should dispatch to the correct serializer based on the discriminator
|
|
839
|
-
|
|
840
|
-
expect(content).toContain(
|
|
841
|
-
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)");
|
|
842
1020
|
expect(content).toContain(
|
|
843
|
-
"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)",
|
|
844
1022
|
);
|
|
845
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
|
+
|
|
846
1029
|
// Should import serializers for all union variants
|
|
847
1030
|
expect(content).toContain('serializeAuthByPassword');
|
|
848
1031
|
expect(content).toContain('serializeAuthByCode');
|
|
@@ -850,6 +1033,13 @@ describe('generateResources', () => {
|
|
|
850
1033
|
|
|
851
1034
|
// Should NOT pass payload directly without serialization
|
|
852
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/);
|
|
853
1043
|
});
|
|
854
1044
|
|
|
855
1045
|
it('generates discriminated union serializer dispatch for void method', () => {
|
|
@@ -889,10 +1079,10 @@ describe('generateResources', () => {
|
|
|
889
1079
|
const files = generateResources(services, ctx);
|
|
890
1080
|
const content = files[0].content;
|
|
891
1081
|
|
|
892
|
-
// Should dispatch to the correct serializer
|
|
893
|
-
expect(content).toContain('switch (
|
|
894
|
-
expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload
|
|
895
|
-
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)");
|
|
896
1086
|
});
|
|
897
1087
|
|
|
898
1088
|
it('uses createPaginatedList helper in paginated methods', () => {
|
|
@@ -992,10 +1182,13 @@ describe('generateResources', () => {
|
|
|
992
1182
|
const content = files[0].content;
|
|
993
1183
|
|
|
994
1184
|
// Should use service-prefixed options name instead of generic "ListOptions"
|
|
995
|
-
|
|
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 {');
|
|
996
1188
|
expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
|
|
997
1189
|
// Should NOT use the generic "ListOptions"
|
|
998
1190
|
expect(content).not.toContain('export interface ListOptions ');
|
|
1191
|
+
expect(files.every((f) => !f.path.endsWith('/list-options.interface.ts'))).toBe(true);
|
|
999
1192
|
});
|
|
1000
1193
|
|
|
1001
1194
|
it('does not prefix ListOptions when method is not "list"', () => {
|
|
@@ -1034,10 +1227,11 @@ describe('generateResources', () => {
|
|
|
1034
1227
|
];
|
|
1035
1228
|
|
|
1036
1229
|
const files = generateResources(services, ctx);
|
|
1037
|
-
const content = files[0].content;
|
|
1038
1230
|
|
|
1039
1231
|
// Method is "listOrganizations", not "list", so options name should be normal
|
|
1040
|
-
|
|
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 {');
|
|
1041
1235
|
});
|
|
1042
1236
|
|
|
1043
1237
|
it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
|
|
@@ -1218,6 +1412,69 @@ describe('generateResources', () => {
|
|
|
1218
1412
|
// skipIfExists should stay true because all methods exist in baseline
|
|
1219
1413
|
expect(files[0].skipIfExists).toBe(true);
|
|
1220
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
|
+
});
|
|
1221
1478
|
});
|
|
1222
1479
|
|
|
1223
1480
|
describe('resolveResourceClassName', () => {
|
|
@@ -1665,91 +1922,65 @@ describe('partial service coverage', () => {
|
|
|
1665
1922
|
const files = generateResources(services, ctxCovered);
|
|
1666
1923
|
expect(files.length).toBe(1);
|
|
1667
1924
|
const content = files[0].content;
|
|
1668
|
-
//
|
|
1925
|
+
// All methods should have generated JSDoc — the merger matches by name
|
|
1926
|
+
// and handles @deprecated preservation, so the emitter always provides
|
|
1927
|
+
// docstrings for the merger to work with.
|
|
1669
1928
|
expect(content).toContain('List all permissions.');
|
|
1670
1929
|
// skipIfExists should remain true for covered services
|
|
1671
1930
|
expect(files[0].skipIfExists).toBe(true);
|
|
1672
1931
|
});
|
|
1673
1932
|
|
|
1674
|
-
it('
|
|
1933
|
+
it('uses resolved operation method names when provided', () => {
|
|
1934
|
+
const op = {
|
|
1935
|
+
name: 'listRolesOrganizations',
|
|
1936
|
+
httpMethod: 'get' as const,
|
|
1937
|
+
path: '/authorization/organizations/{organizationId}/roles',
|
|
1938
|
+
pathParams: [
|
|
1939
|
+
{
|
|
1940
|
+
name: 'organizationId',
|
|
1941
|
+
type: { kind: 'primitive' as const, type: 'string' as const },
|
|
1942
|
+
required: true,
|
|
1943
|
+
},
|
|
1944
|
+
],
|
|
1945
|
+
queryParams: [],
|
|
1946
|
+
headerParams: [],
|
|
1947
|
+
response: { kind: 'model' as const, name: 'RoleList' },
|
|
1948
|
+
errors: [],
|
|
1949
|
+
pagination: {
|
|
1950
|
+
strategy: 'cursor' as const,
|
|
1951
|
+
param: 'after',
|
|
1952
|
+
itemType: { kind: 'model' as const, name: 'RoleList' },
|
|
1953
|
+
},
|
|
1954
|
+
injectIdempotencyKey: false,
|
|
1955
|
+
};
|
|
1675
1956
|
const services: Service[] = [
|
|
1676
1957
|
{
|
|
1677
1958
|
name: 'Authorization',
|
|
1678
|
-
operations: [
|
|
1679
|
-
{
|
|
1680
|
-
name: 'listRolesOrganizations',
|
|
1681
|
-
httpMethod: 'get',
|
|
1682
|
-
path: '/authorization/organizations/{organizationId}/roles',
|
|
1683
|
-
pathParams: [
|
|
1684
|
-
{
|
|
1685
|
-
name: 'organizationId',
|
|
1686
|
-
type: { kind: 'primitive', type: 'string' },
|
|
1687
|
-
required: true,
|
|
1688
|
-
},
|
|
1689
|
-
],
|
|
1690
|
-
queryParams: [],
|
|
1691
|
-
headerParams: [],
|
|
1692
|
-
response: { kind: 'model', name: 'RoleList' },
|
|
1693
|
-
errors: [],
|
|
1694
|
-
pagination: {
|
|
1695
|
-
strategy: 'cursor' as const,
|
|
1696
|
-
param: 'after',
|
|
1697
|
-
itemType: { kind: 'model' as const, name: 'RoleList' },
|
|
1698
|
-
},
|
|
1699
|
-
injectIdempotencyKey: false,
|
|
1700
|
-
},
|
|
1701
|
-
],
|
|
1959
|
+
operations: [op],
|
|
1702
1960
|
},
|
|
1703
1961
|
];
|
|
1704
1962
|
|
|
1705
|
-
const
|
|
1963
|
+
const ctxResolved: EmitterContext = {
|
|
1706
1964
|
...ctx,
|
|
1707
1965
|
spec: {
|
|
1708
1966
|
...emptySpec,
|
|
1709
1967
|
services,
|
|
1710
1968
|
models: [{ name: 'RoleList', fields: [] }],
|
|
1711
1969
|
},
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
},
|
|
1721
|
-
apiSurface: {
|
|
1722
|
-
language: 'node',
|
|
1723
|
-
extractedFrom: 'test',
|
|
1724
|
-
extractedAt: '2024-01-01',
|
|
1725
|
-
classes: {
|
|
1726
|
-
Authorization: {
|
|
1727
|
-
name: 'Authorization',
|
|
1728
|
-
methods: {
|
|
1729
|
-
listOrganizationRoles: [
|
|
1730
|
-
{
|
|
1731
|
-
name: 'listOrganizationRoles',
|
|
1732
|
-
params: [],
|
|
1733
|
-
returnType: 'void',
|
|
1734
|
-
async: true,
|
|
1735
|
-
},
|
|
1736
|
-
],
|
|
1737
|
-
},
|
|
1738
|
-
properties: {},
|
|
1739
|
-
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1740
|
-
},
|
|
1741
|
-
},
|
|
1742
|
-
interfaces: {},
|
|
1743
|
-
typeAliases: {},
|
|
1744
|
-
enums: {},
|
|
1745
|
-
exports: {},
|
|
1746
|
-
},
|
|
1970
|
+
resolvedOperations: [
|
|
1971
|
+
{
|
|
1972
|
+
operation: op,
|
|
1973
|
+
service: services[0],
|
|
1974
|
+
methodName: 'list_organization_roles',
|
|
1975
|
+
mountOn: 'Authorization',
|
|
1976
|
+
} as any,
|
|
1977
|
+
],
|
|
1747
1978
|
};
|
|
1748
1979
|
|
|
1749
|
-
const files = generateResources(services,
|
|
1980
|
+
const files = generateResources(services, ctxResolved);
|
|
1750
1981
|
expect(files.length).toBe(1);
|
|
1751
1982
|
const content = files[0].content;
|
|
1752
|
-
// Should use
|
|
1983
|
+
// Should use the resolved operation name (converted to camelCase)
|
|
1753
1984
|
expect(content).toContain('async listOrganizationRoles');
|
|
1754
1985
|
expect(content).not.toContain('async listRolesOrganizations');
|
|
1755
1986
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { generateSerializers } from '../../src/node/
|
|
2
|
+
import { generateSerializers } from '../../src/node/models.js';
|
|
3
3
|
import type { EmitterContext, ApiSpec, Model, Service } from '@workos/oagen';
|
|
4
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
5
|
|
|
5
6
|
const emptySpec: ApiSpec = {
|
|
6
7
|
name: 'Test',
|
|
@@ -9,6 +10,7 @@ const emptySpec: ApiSpec = {
|
|
|
9
10
|
services: [],
|
|
10
11
|
models: [],
|
|
11
12
|
enums: [],
|
|
13
|
+
sdk: defaultSdkBehavior(),
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
const ctx: EmitterContext = {
|
|
@@ -60,6 +60,17 @@ describe('mapTypeRef', () => {
|
|
|
60
60
|
expect(mapTypeRef(ref)).toBe('string | number');
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it('deduplicates union variants', () => {
|
|
64
|
+
const ref: TypeRef = {
|
|
65
|
+
kind: 'union',
|
|
66
|
+
variants: [
|
|
67
|
+
{ kind: 'model', name: 'AuthenticationFactorTotp' },
|
|
68
|
+
{ kind: 'model', name: 'AuthenticationFactorTotp' },
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
expect(mapTypeRef(ref)).toBe('AuthenticationFactorTotp');
|
|
72
|
+
});
|
|
73
|
+
|
|
63
74
|
it('maps map type', () => {
|
|
64
75
|
const ref: TypeRef = {
|
|
65
76
|
kind: 'map',
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
|
|
3
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
+
import { generateClient } from '../../src/php/client.js';
|
|
5
|
+
|
|
6
|
+
const models: Model[] = [
|
|
7
|
+
{
|
|
8
|
+
name: 'Organization',
|
|
9
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
10
|
+
},
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const services: Service[] = [
|
|
14
|
+
{
|
|
15
|
+
name: 'Organizations',
|
|
16
|
+
operations: [
|
|
17
|
+
{
|
|
18
|
+
name: 'listOrganizations',
|
|
19
|
+
httpMethod: 'get',
|
|
20
|
+
path: '/organizations',
|
|
21
|
+
pathParams: [],
|
|
22
|
+
queryParams: [],
|
|
23
|
+
headerParams: [],
|
|
24
|
+
response: { kind: 'model', name: 'Organization' },
|
|
25
|
+
errors: [],
|
|
26
|
+
injectIdempotencyKey: false,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const emptySpec: ApiSpec = {
|
|
33
|
+
name: 'Test',
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
baseUrl: 'https://api.example.com',
|
|
36
|
+
services,
|
|
37
|
+
models,
|
|
38
|
+
enums: [],
|
|
39
|
+
sdk: defaultSdkBehavior(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ctx: EmitterContext = {
|
|
43
|
+
namespace: 'workos',
|
|
44
|
+
namespacePascal: 'WorkOS',
|
|
45
|
+
spec: emptySpec,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe('generateClient', () => {
|
|
49
|
+
it('only generates the main client file', () => {
|
|
50
|
+
const result = generateClient(emptySpec, ctx);
|
|
51
|
+
|
|
52
|
+
expect(result).toHaveLength(1);
|
|
53
|
+
expect(result[0].path).toBe('lib/WorkOS.php');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('generates main client class with namespace', () => {
|
|
57
|
+
const result = generateClient(emptySpec, ctx);
|
|
58
|
+
|
|
59
|
+
expect(result[0].content).toContain('class WorkOS');
|
|
60
|
+
expect(result[0].content).toContain('namespace WorkOS;');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('generates resource accessor methods', () => {
|
|
64
|
+
const result = generateClient(emptySpec, ctx);
|
|
65
|
+
|
|
66
|
+
expect(result[0].content).toContain('public function organizations(): Organizations');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('includes constructor with config options', () => {
|
|
70
|
+
const result = generateClient(emptySpec, ctx);
|
|
71
|
+
|
|
72
|
+
expect(result[0].content).toContain('?string $apiKey = null');
|
|
73
|
+
expect(result[0].content).toContain('?string $clientId = null');
|
|
74
|
+
expect(result[0].content).toContain("string $baseUrl = 'https://api.example.com'");
|
|
75
|
+
expect(result[0].content).toContain('int $timeout = 60');
|
|
76
|
+
expect(result[0].content).toContain('int $maxRetries = 3');
|
|
77
|
+
expect(result[0].content).toContain('?string $userAgent = null');
|
|
78
|
+
expect(result[0].content).toContain(
|
|
79
|
+
'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent)',
|
|
80
|
+
);
|
|
81
|
+
expect(result[0].content).not.toContain('self::$apiKey = $apiKey;');
|
|
82
|
+
expect(result[0].content).not.toContain('self::$clientId = $clientId;');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('includes non-spec service accessors', () => {
|
|
86
|
+
const result = generateClient(emptySpec, ctx);
|
|
87
|
+
|
|
88
|
+
expect(result[0].content).toContain('public function passwordless(): Passwordless');
|
|
89
|
+
expect(result[0].content).toContain('public function vault(): Vault');
|
|
90
|
+
expect(result[0].content).toContain('public function webhookVerification(): WebhookVerification');
|
|
91
|
+
expect(result[0].content).toContain('public function actions(): Actions');
|
|
92
|
+
expect(result[0].content).toContain('public function sessionManager(): SessionManager');
|
|
93
|
+
expect(result[0].content).toContain('public function pkce(): PKCEHelper');
|
|
94
|
+
});
|
|
95
|
+
});
|