@workos/oagen-emitters 0.3.0 → 0.5.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/.github/workflows/ci.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/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- 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 +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- 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 +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
|
@@ -99,16 +99,98 @@ describe('generateResources', () => {
|
|
|
99
99
|
const files = generateResources(services, ctx);
|
|
100
100
|
const content = files[0].content;
|
|
101
101
|
|
|
102
|
-
// Should have AutoPaginatable
|
|
103
|
-
expect(content).toContain("import
|
|
104
|
-
expect(content).toContain("import {
|
|
102
|
+
// Should have AutoPaginatable value import and fetchAndDeserialize import
|
|
103
|
+
expect(content).toContain("import { AutoPaginatable } from '../common/utils/pagination'");
|
|
104
|
+
expect(content).toContain("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'");
|
|
105
|
+
// Options interface lives in its own file under interfaces/ so the
|
|
106
|
+
// per-service barrel picks it up.
|
|
107
|
+
expect(content).toContain(
|
|
108
|
+
"import type { ListOrganizationsOptions } from './interfaces/list-organizations-options.interface';",
|
|
109
|
+
);
|
|
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; options are passed directly.
|
|
124
|
+
expect(content).not.toContain('serializeListOrganizationsOptions');
|
|
125
|
+
expect(content).toContain('new AutoPaginatable(');
|
|
126
|
+
expect(content).toContain('fetchAndDeserialize<OrganizationResponse, Organization>');
|
|
127
|
+
expect(content).toContain('options,');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('emits wire-options serializer for paginated list with camelCase filter fields', () => {
|
|
131
|
+
// Regression test for PR #1535 reviewer comment r3075477146: list
|
|
132
|
+
// methods whose extended filter fields have divergent camelCase/snake_case
|
|
133
|
+
// spellings (e.g. `organizationId` ↔ `organization_id`) must translate
|
|
134
|
+
// keys before hitting the wire, otherwise the API silently ignores them.
|
|
135
|
+
const services: Service[] = [
|
|
136
|
+
{
|
|
137
|
+
name: 'Applications',
|
|
138
|
+
operations: [
|
|
139
|
+
{
|
|
140
|
+
name: 'listApplications',
|
|
141
|
+
httpMethod: 'get',
|
|
142
|
+
path: '/connect/applications',
|
|
143
|
+
pathParams: [],
|
|
144
|
+
queryParams: [
|
|
145
|
+
{
|
|
146
|
+
name: 'organization_id',
|
|
147
|
+
type: { kind: 'primitive', type: 'string' },
|
|
148
|
+
required: false,
|
|
149
|
+
description: 'Filter by organization ID.',
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
headerParams: [],
|
|
153
|
+
response: { kind: 'model', name: 'ConnectApplication' },
|
|
154
|
+
errors: [],
|
|
155
|
+
pagination: {
|
|
156
|
+
strategy: 'cursor',
|
|
157
|
+
param: 'after',
|
|
158
|
+
dataPath: 'data',
|
|
159
|
+
itemType: { kind: 'model', name: 'ConnectApplication' },
|
|
160
|
+
},
|
|
161
|
+
injectIdempotencyKey: false,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const files = generateResources(services, ctx);
|
|
168
|
+
const content = files[0].content;
|
|
169
|
+
|
|
170
|
+
// Options interface uses camelCase (user-facing) and lives in its own file.
|
|
171
|
+
const optionsFile = files.find(
|
|
172
|
+
(f) => f.path === 'src/applications/interfaces/list-applications-options.interface.ts',
|
|
173
|
+
);
|
|
174
|
+
expect(optionsFile).toBeDefined();
|
|
175
|
+
expect(optionsFile!.content).toContain('export interface ListApplicationsOptions extends PaginationOptions {');
|
|
176
|
+
expect(optionsFile!.content).toContain('organizationId?: string;');
|
|
177
|
+
|
|
178
|
+
// Resource class imports the options type from the interface file.
|
|
179
|
+
expect(content).toContain(
|
|
180
|
+
"import type { ListApplicationsOptions } from './interfaces/list-applications-options.interface';",
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Wire-options serializer emits snake_case key for the extension field
|
|
184
|
+
// and leaves standard pagination fields unchanged.
|
|
185
|
+
expect(content).toContain(
|
|
186
|
+
'const serializeListApplicationsOptions = (options: ListApplicationsOptions): PaginationOptions => {',
|
|
187
|
+
);
|
|
188
|
+
expect(content).toContain('wire.organization_id = options.organizationId');
|
|
189
|
+
expect(content).not.toContain('wire.organizationId');
|
|
190
|
+
|
|
191
|
+
// fetchAndDeserialize is invoked with the serialized options.
|
|
192
|
+
expect(content).toContain('serializeListApplicationsOptions(options)');
|
|
193
|
+
expect(content).toContain('new AutoPaginatable(');
|
|
112
194
|
});
|
|
113
195
|
|
|
114
196
|
it('uses item type not list wrapper type for paginated methods', () => {
|
|
@@ -150,7 +232,7 @@ describe('generateResources', () => {
|
|
|
150
232
|
const content = files[0].content;
|
|
151
233
|
|
|
152
234
|
// Should use item type (Connection) not list wrapper (ConnectionList)
|
|
153
|
-
expect(content).toContain('
|
|
235
|
+
expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
|
|
154
236
|
expect(content).toContain('deserializeConnection');
|
|
155
237
|
expect(content).toContain('Promise<AutoPaginatable<Connection,');
|
|
156
238
|
|
|
@@ -191,6 +273,47 @@ describe('generateResources', () => {
|
|
|
191
273
|
expect(content).toContain('await this.workos.delete(');
|
|
192
274
|
});
|
|
193
275
|
|
|
276
|
+
it('generates unpaginated GET returning an array of models', () => {
|
|
277
|
+
// Regression test for PR #1535 reviewer comment (r3074705330): endpoints
|
|
278
|
+
// whose OpenAPI response is `type: array` must return `Model[]` and map
|
|
279
|
+
// the deserializer over each element, not treat the array as a single
|
|
280
|
+
// object (which silently produces garbage at runtime).
|
|
281
|
+
const services: Service[] = [
|
|
282
|
+
{
|
|
283
|
+
name: 'Secrets',
|
|
284
|
+
operations: [
|
|
285
|
+
{
|
|
286
|
+
name: 'listSecrets',
|
|
287
|
+
httpMethod: 'get',
|
|
288
|
+
path: '/applications/{id}/secrets',
|
|
289
|
+
pathParams: [
|
|
290
|
+
{
|
|
291
|
+
name: 'id',
|
|
292
|
+
type: { kind: 'primitive', type: 'string' },
|
|
293
|
+
required: true,
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
queryParams: [],
|
|
297
|
+
headerParams: [],
|
|
298
|
+
response: { kind: 'array', items: { kind: 'model', name: 'Secret' } },
|
|
299
|
+
errors: [],
|
|
300
|
+
injectIdempotencyKey: false,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
const files = generateResources(services, ctx);
|
|
307
|
+
const content = files[0].content;
|
|
308
|
+
|
|
309
|
+
expect(content).toContain('async listSecrets(id: string): Promise<Secret[]>');
|
|
310
|
+
expect(content).toContain('this.workos.get<SecretResponse[]>');
|
|
311
|
+
expect(content).toContain('return data.map(deserializeSecret);');
|
|
312
|
+
// Should NOT produce the single-object form — that was the bug.
|
|
313
|
+
expect(content).not.toMatch(/Promise<Secret>\s*\{/);
|
|
314
|
+
expect(content).not.toContain('return deserializeSecret(data);');
|
|
315
|
+
});
|
|
316
|
+
|
|
194
317
|
it('generates POST method with body and idempotency', () => {
|
|
195
318
|
const services: Service[] = [
|
|
196
319
|
{
|
|
@@ -891,14 +1014,20 @@ describe('generateResources', () => {
|
|
|
891
1014
|
// Should use the union type for the payload parameter
|
|
892
1015
|
expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
|
|
893
1016
|
|
|
894
|
-
// Should dispatch to the correct serializer based on the discriminator
|
|
895
|
-
|
|
896
|
-
expect(content).toContain(
|
|
897
|
-
expect(content).toContain("case '
|
|
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)");
|
|
898
1022
|
expect(content).toContain(
|
|
899
|
-
"case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload
|
|
1023
|
+
"case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload)",
|
|
900
1024
|
);
|
|
901
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
|
+
|
|
902
1031
|
// Should import serializers for all union variants
|
|
903
1032
|
expect(content).toContain('serializeAuthByPassword');
|
|
904
1033
|
expect(content).toContain('serializeAuthByCode');
|
|
@@ -906,6 +1035,13 @@ describe('generateResources', () => {
|
|
|
906
1035
|
|
|
907
1036
|
// Should NOT pass payload directly without serialization
|
|
908
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/);
|
|
909
1045
|
});
|
|
910
1046
|
|
|
911
1047
|
it('generates discriminated union serializer dispatch for void method', () => {
|
|
@@ -945,13 +1081,13 @@ describe('generateResources', () => {
|
|
|
945
1081
|
const files = generateResources(services, ctx);
|
|
946
1082
|
const content = files[0].content;
|
|
947
1083
|
|
|
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
|
|
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)");
|
|
952
1088
|
});
|
|
953
1089
|
|
|
954
|
-
it('uses
|
|
1090
|
+
it('uses AutoPaginatable pattern in paginated methods', () => {
|
|
955
1091
|
const services: Service[] = [
|
|
956
1092
|
{
|
|
957
1093
|
name: 'Connections',
|
|
@@ -980,9 +1116,9 @@ describe('generateResources', () => {
|
|
|
980
1116
|
const files = generateResources(services, ctx);
|
|
981
1117
|
const content = files[0].content;
|
|
982
1118
|
|
|
983
|
-
// Should use
|
|
984
|
-
expect(content).toContain('
|
|
985
|
-
expect(content).toContain('
|
|
1119
|
+
// Should use AutoPaginatable + fetchAndDeserialize pattern for paginated methods
|
|
1120
|
+
expect(content).toContain('new AutoPaginatable(');
|
|
1121
|
+
expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
|
|
986
1122
|
expect(content).toContain('deserializeConnection');
|
|
987
1123
|
});
|
|
988
1124
|
|
|
@@ -1048,10 +1184,13 @@ describe('generateResources', () => {
|
|
|
1048
1184
|
const content = files[0].content;
|
|
1049
1185
|
|
|
1050
1186
|
// Should use service-prefixed options name instead of generic "ListOptions"
|
|
1051
|
-
|
|
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 {');
|
|
1052
1190
|
expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
|
|
1053
1191
|
// Should NOT use the generic "ListOptions"
|
|
1054
1192
|
expect(content).not.toContain('export interface ListOptions ');
|
|
1193
|
+
expect(files.every((f) => !f.path.endsWith('/list-options.interface.ts'))).toBe(true);
|
|
1055
1194
|
});
|
|
1056
1195
|
|
|
1057
1196
|
it('does not prefix ListOptions when method is not "list"', () => {
|
|
@@ -1090,10 +1229,11 @@ describe('generateResources', () => {
|
|
|
1090
1229
|
];
|
|
1091
1230
|
|
|
1092
1231
|
const files = generateResources(services, ctx);
|
|
1093
|
-
const content = files[0].content;
|
|
1094
1232
|
|
|
1095
1233
|
// Method is "listOrganizations", not "list", so options name should be normal
|
|
1096
|
-
|
|
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 {');
|
|
1097
1237
|
});
|
|
1098
1238
|
|
|
1099
1239
|
it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
|
|
@@ -1268,11 +1408,72 @@ describe('generateResources', () => {
|
|
|
1268
1408
|
},
|
|
1269
1409
|
};
|
|
1270
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
|
+
|
|
1271
1472
|
const files = generateResources(services, overlayCtx);
|
|
1272
1473
|
expect(files.length).toBe(1);
|
|
1273
1474
|
|
|
1274
|
-
// skipIfExists
|
|
1275
|
-
expect(files[0].skipIfExists).
|
|
1475
|
+
// skipIfExists must be removed so emitter improvements always overwrite.
|
|
1476
|
+
expect(files[0].skipIfExists).toBeUndefined();
|
|
1276
1477
|
});
|
|
1277
1478
|
});
|
|
1278
1479
|
|
|
@@ -1647,7 +1848,7 @@ describe('partial service coverage', () => {
|
|
|
1647
1848
|
expect(content).toContain('async createEvent');
|
|
1648
1849
|
});
|
|
1649
1850
|
|
|
1650
|
-
it('
|
|
1851
|
+
it('skips fully covered services with no new methods', () => {
|
|
1651
1852
|
const services: Service[] = [
|
|
1652
1853
|
{
|
|
1653
1854
|
name: 'Permissions',
|
|
@@ -1719,14 +1920,9 @@ describe('partial service coverage', () => {
|
|
|
1719
1920
|
};
|
|
1720
1921
|
|
|
1721
1922
|
const files = generateResources(services, ctxCovered);
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
// and handles @deprecated preservation, so the emitter always provides
|
|
1726
|
-
// docstrings for the merger to work with.
|
|
1727
|
-
expect(content).toContain('List all permissions.');
|
|
1728
|
-
// skipIfExists should remain true for covered services
|
|
1729
|
-
expect(files[0].skipIfExists).toBe(true);
|
|
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);
|
|
1730
1926
|
});
|
|
1731
1927
|
|
|
1732
1928
|
it('uses resolved operation method names when provided', () => {
|
|
@@ -1846,4 +2042,117 @@ describe('partial service coverage', () => {
|
|
|
1846
2042
|
const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
|
|
1847
2043
|
expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
|
|
1848
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
|
+
},
|
|
2148
|
+
};
|
|
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');
|
|
2157
|
+
});
|
|
1849
2158
|
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { modelHasNewFields } from '../../src/node/utils.js';
|
|
3
|
+
import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
|
|
4
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
5
|
+
|
|
6
|
+
const emptySpec: ApiSpec = {
|
|
7
|
+
name: 'Test',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
baseUrl: '',
|
|
10
|
+
services: [],
|
|
11
|
+
models: [],
|
|
12
|
+
enums: [],
|
|
13
|
+
sdk: defaultSdkBehavior(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ctx: EmitterContext = {
|
|
17
|
+
namespace: 'workos',
|
|
18
|
+
namespacePascal: 'WorkOS',
|
|
19
|
+
spec: emptySpec,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('modelHasNewFields', () => {
|
|
23
|
+
const model: Model = {
|
|
24
|
+
name: 'Organization',
|
|
25
|
+
fields: [
|
|
26
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
27
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
it('returns true when no apiSurface exists (Scenario B)', () => {
|
|
32
|
+
expect(modelHasNewFields(model, ctx)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns true when model has no baseline entry (new model)', () => {
|
|
36
|
+
const ctxWithSurface: EmitterContext = {
|
|
37
|
+
...ctx,
|
|
38
|
+
apiSurface: {
|
|
39
|
+
language: 'node',
|
|
40
|
+
extractedFrom: 'test',
|
|
41
|
+
extractedAt: '2024-01-01',
|
|
42
|
+
classes: {},
|
|
43
|
+
interfaces: {},
|
|
44
|
+
typeAliases: {},
|
|
45
|
+
enums: {},
|
|
46
|
+
exports: {},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
expect(modelHasNewFields(model, ctxWithSurface)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns false when all fields exist in baseline', () => {
|
|
53
|
+
const ctxWithBaseline: EmitterContext = {
|
|
54
|
+
...ctx,
|
|
55
|
+
apiSurface: {
|
|
56
|
+
language: 'node',
|
|
57
|
+
extractedFrom: 'test',
|
|
58
|
+
extractedAt: '2024-01-01',
|
|
59
|
+
classes: {},
|
|
60
|
+
typeAliases: {},
|
|
61
|
+
enums: {},
|
|
62
|
+
exports: {},
|
|
63
|
+
interfaces: {
|
|
64
|
+
Organization: {
|
|
65
|
+
name: 'Organization',
|
|
66
|
+
fields: {
|
|
67
|
+
id: { name: 'id', type: 'string', optional: false },
|
|
68
|
+
name: { name: 'name', type: 'string', optional: false },
|
|
69
|
+
},
|
|
70
|
+
extends: [],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
expect(modelHasNewFields(model, ctxWithBaseline)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns true when model has one new field not in baseline', () => {
|
|
79
|
+
const modelWithNewField: Model = {
|
|
80
|
+
name: 'Organization',
|
|
81
|
+
fields: [
|
|
82
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
83
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
84
|
+
{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const ctxWithBaseline: EmitterContext = {
|
|
88
|
+
...ctx,
|
|
89
|
+
apiSurface: {
|
|
90
|
+
language: 'node',
|
|
91
|
+
extractedFrom: 'test',
|
|
92
|
+
extractedAt: '2024-01-01',
|
|
93
|
+
classes: {},
|
|
94
|
+
typeAliases: {},
|
|
95
|
+
enums: {},
|
|
96
|
+
exports: {},
|
|
97
|
+
interfaces: {
|
|
98
|
+
Organization: {
|
|
99
|
+
name: 'Organization',
|
|
100
|
+
fields: {
|
|
101
|
+
id: { name: 'id', type: 'string', optional: false },
|
|
102
|
+
name: { name: 'name', type: 'string', optional: false },
|
|
103
|
+
},
|
|
104
|
+
extends: [],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
expect(modelHasNewFields(modelWithNewField, ctxWithBaseline)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('converts snake_case IR field names to camelCase for baseline comparison', () => {
|
|
113
|
+
const snakeModel: Model = {
|
|
114
|
+
name: 'Organization',
|
|
115
|
+
fields: [{ name: 'organization_id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
116
|
+
};
|
|
117
|
+
const ctxWithBaseline: EmitterContext = {
|
|
118
|
+
...ctx,
|
|
119
|
+
apiSurface: {
|
|
120
|
+
language: 'node',
|
|
121
|
+
extractedFrom: 'test',
|
|
122
|
+
extractedAt: '2024-01-01',
|
|
123
|
+
classes: {},
|
|
124
|
+
typeAliases: {},
|
|
125
|
+
enums: {},
|
|
126
|
+
exports: {},
|
|
127
|
+
interfaces: {
|
|
128
|
+
Organization: {
|
|
129
|
+
name: 'Organization',
|
|
130
|
+
fields: {
|
|
131
|
+
organizationId: { name: 'organizationId', type: 'string', optional: false },
|
|
132
|
+
},
|
|
133
|
+
extends: [],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
expect(modelHasNewFields(snakeModel, ctxWithBaseline)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
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;');
|
package/test/php/models.test.ts
CHANGED
|
@@ -436,7 +436,7 @@ describe('generateModels', () => {
|
|
|
436
436
|
expect(file!.content).not.toContain('instanceof \\BackedEnum');
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
-
it('
|
|
439
|
+
it('emits all structurally identical models as full classes', () => {
|
|
440
440
|
const models: Model[] = [
|
|
441
441
|
{
|
|
442
442
|
name: 'FlagCreatedContextActor',
|
|
@@ -464,11 +464,12 @@ describe('generateModels', () => {
|
|
|
464
464
|
const specWithModels = { ...emptySpec, models };
|
|
465
465
|
const result = generateModels(models, { ...ctx, spec: specWithModels });
|
|
466
466
|
|
|
467
|
-
//
|
|
467
|
+
// PHP readonly classes cannot be aliased, so all models are emitted as full classes
|
|
468
468
|
const modelFiles = result.filter((f) => !f.path.includes('Trait'));
|
|
469
|
-
expect(modelFiles).toHaveLength(
|
|
470
|
-
// Shortest class name wins as canonical
|
|
469
|
+
expect(modelFiles).toHaveLength(3);
|
|
471
470
|
expect(modelFiles[0].path).toContain('FlagCreatedContextActor');
|
|
471
|
+
expect(modelFiles[1].path).toContain('FlagUpdatedContextActor');
|
|
472
|
+
expect(modelFiles[2].path).toContain('FlagDeletedContextActor');
|
|
472
473
|
});
|
|
473
474
|
|
|
474
475
|
it('does not produce double |null in @var for nullable optional arrays', () => {
|