@workos/oagen-emitters 0.16.0 → 0.17.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.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-DuB1UozS.mjs → plugin-BLnR-FMi.mjs} +3687 -2393
- package/dist/plugin-BLnR-FMi.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/go/index.ts +6 -1
- package/src/kotlin/index.ts +9 -3
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +271 -5
- package/src/node/live-surface.ts +309 -0
- package/src/node/models.ts +69 -3
- package/src/node/naming.ts +204 -23
- package/src/node/resources.ts +166 -3
- package/src/node/utils.ts +140 -22
- package/src/rust/resources.ts +78 -29
- package/src/rust/tests.ts +15 -4
- package/src/shared/union-flatten.ts +201 -0
- package/test/node/enums.test.ts +239 -2
- package/test/node/live-surface.test.ts +771 -1
- package/test/node/models.test.ts +738 -3
- package/test/node/naming.test.ts +159 -0
- package/test/node/resources.test.ts +611 -0
- package/test/node/utils.test.ts +157 -2
- package/test/rust/resources.test.ts +143 -3
- package/test/shared/union-flatten.test.ts +174 -0
- package/dist/plugin-DuB1UozS.mjs.map +0 -1
|
@@ -186,6 +186,153 @@ describe('generateResources', () => {
|
|
|
186
186
|
expect(resourceFile!.content).toContain('async listGroupsForOrganizationMembership');
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
+
it('urlBuilder: emits a synchronous string method that builds the URL via toQueryString', () => {
|
|
190
|
+
// Operations marked `urlBuilder` (e.g. GET /sso/authorize) are client-side
|
|
191
|
+
// URL constructors: the generated method must return a string synchronously,
|
|
192
|
+
// serialize visible query params + defaults + inferred client fields via
|
|
193
|
+
// toQueryString, and concatenate onto the client base URL — no HTTP call.
|
|
194
|
+
const operation = {
|
|
195
|
+
name: 'getAuthorizationUrl',
|
|
196
|
+
httpMethod: 'get' as const,
|
|
197
|
+
path: '/sso/authorize',
|
|
198
|
+
pathParams: [],
|
|
199
|
+
queryParams: [
|
|
200
|
+
{ name: 'connection', type: { kind: 'primitive' as const, type: 'string' as const }, required: false },
|
|
201
|
+
{ name: 'organization', type: { kind: 'primitive' as const, type: 'string' as const }, required: false },
|
|
202
|
+
],
|
|
203
|
+
headerParams: [],
|
|
204
|
+
response: { kind: 'primitive' as const, type: 'unknown' as const },
|
|
205
|
+
errors: [],
|
|
206
|
+
injectIdempotencyKey: false,
|
|
207
|
+
};
|
|
208
|
+
const service: Service = { name: 'Sso', operations: [operation] };
|
|
209
|
+
const spec: ApiSpec = { ...emptySpec, services: [service] };
|
|
210
|
+
const ctxWithResolved: EmitterContext = {
|
|
211
|
+
...ctx,
|
|
212
|
+
spec,
|
|
213
|
+
emitterOptions: { ownedServices: ['Sso'] },
|
|
214
|
+
resolvedOperations: [
|
|
215
|
+
{
|
|
216
|
+
operation,
|
|
217
|
+
service,
|
|
218
|
+
methodName: 'get_authorization_url',
|
|
219
|
+
mountOn: 'Sso',
|
|
220
|
+
defaults: { response_type: 'code' },
|
|
221
|
+
inferFromClient: ['client_id'],
|
|
222
|
+
urlBuilder: true,
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
|
|
228
|
+
const resourceFile = result.find((f) => f.path === 'src/sso/sso.ts');
|
|
229
|
+
expect(resourceFile).toBeDefined();
|
|
230
|
+
const content = resourceFile!.content;
|
|
231
|
+
|
|
232
|
+
// Synchronous, string-returning — not an async HTTP wrapper.
|
|
233
|
+
expect(content).toMatch(/getAuthorizationUrl\(options\??: [^)]*\): string \{/);
|
|
234
|
+
expect(content).not.toContain('async getAuthorizationUrl');
|
|
235
|
+
expect(content).not.toContain('this.workos.get(');
|
|
236
|
+
// Query assembled client-side: visible params + constant default + inferred field.
|
|
237
|
+
expect(content).toContain('const query = toQueryString(');
|
|
238
|
+
expect(content).toContain("response_type: 'code'");
|
|
239
|
+
expect(content).toContain('client_id: this.workos.options.clientId');
|
|
240
|
+
// URL is base URL + path + query.
|
|
241
|
+
expect(content).toContain('return `${this.workos.baseURL}/sso/authorize?${query}`;');
|
|
242
|
+
// The serializer helper is imported.
|
|
243
|
+
expect(content).toContain("import { toQueryString } from '../common/utils/query-string';");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('urlBuilder: positional convention emits a no-arg method when only injected fields supply the query', () => {
|
|
247
|
+
// A url builder with no path params and no visible query params takes the
|
|
248
|
+
// positional branch (operationHasOptionsInput is false), so the signature
|
|
249
|
+
// is argument-less; the query is assembled purely from inferFromClient
|
|
250
|
+
// (and defaults) rather than a options object.
|
|
251
|
+
const operation = {
|
|
252
|
+
name: 'getLogoutUrl',
|
|
253
|
+
httpMethod: 'get' as const,
|
|
254
|
+
path: '/sso/logout',
|
|
255
|
+
pathParams: [],
|
|
256
|
+
queryParams: [],
|
|
257
|
+
headerParams: [],
|
|
258
|
+
response: { kind: 'primitive' as const, type: 'unknown' as const },
|
|
259
|
+
errors: [],
|
|
260
|
+
injectIdempotencyKey: false,
|
|
261
|
+
};
|
|
262
|
+
const service: Service = { name: 'Sso', operations: [operation] };
|
|
263
|
+
const spec: ApiSpec = { ...emptySpec, services: [service] };
|
|
264
|
+
const ctxWithResolved: EmitterContext = {
|
|
265
|
+
...ctx,
|
|
266
|
+
spec,
|
|
267
|
+
emitterOptions: { ownedServices: ['Sso'] },
|
|
268
|
+
resolvedOperations: [
|
|
269
|
+
{
|
|
270
|
+
operation,
|
|
271
|
+
service,
|
|
272
|
+
methodName: 'get_logout_url',
|
|
273
|
+
mountOn: 'Sso',
|
|
274
|
+
defaults: {},
|
|
275
|
+
inferFromClient: ['client_id'],
|
|
276
|
+
urlBuilder: true,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
|
|
282
|
+
const content = result.find((f) => f.path === 'src/sso/sso.ts')!.content;
|
|
283
|
+
|
|
284
|
+
// No options object and no path params: the signature takes no arguments.
|
|
285
|
+
expect(content).toMatch(/getLogoutUrl\(\): string \{/);
|
|
286
|
+
expect(content).not.toContain('async getLogoutUrl');
|
|
287
|
+
// Query built entirely from the injected client field.
|
|
288
|
+
expect(content).toContain('const query = toQueryString(');
|
|
289
|
+
expect(content).toContain('client_id: this.workos.options.clientId');
|
|
290
|
+
expect(content).toContain('return `${this.workos.baseURL}/sso/logout?${query}`;');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('urlBuilder: with no query at all returns the bare base URL + path and skips the toQueryString import', () => {
|
|
294
|
+
// hasQuery is false (no visible params, defaults, or inferFromClient), so
|
|
295
|
+
// the method returns base URL + path with no `?${query}` segment, and the
|
|
296
|
+
// serializer import must not appear when nothing in the service uses it.
|
|
297
|
+
const operation = {
|
|
298
|
+
name: 'getJwksUrl',
|
|
299
|
+
httpMethod: 'get' as const,
|
|
300
|
+
path: '/sso/jwks',
|
|
301
|
+
pathParams: [],
|
|
302
|
+
queryParams: [],
|
|
303
|
+
headerParams: [],
|
|
304
|
+
response: { kind: 'primitive' as const, type: 'unknown' as const },
|
|
305
|
+
errors: [],
|
|
306
|
+
injectIdempotencyKey: false,
|
|
307
|
+
};
|
|
308
|
+
const service: Service = { name: 'Sso', operations: [operation] };
|
|
309
|
+
const spec: ApiSpec = { ...emptySpec, services: [service] };
|
|
310
|
+
const ctxWithResolved: EmitterContext = {
|
|
311
|
+
...ctx,
|
|
312
|
+
spec,
|
|
313
|
+
emitterOptions: { ownedServices: ['Sso'] },
|
|
314
|
+
resolvedOperations: [
|
|
315
|
+
{
|
|
316
|
+
operation,
|
|
317
|
+
service,
|
|
318
|
+
methodName: 'get_jwks_url',
|
|
319
|
+
mountOn: 'Sso',
|
|
320
|
+
defaults: {},
|
|
321
|
+
inferFromClient: [],
|
|
322
|
+
urlBuilder: true,
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const result = nodeEmitter.generateResources(spec.services, ctxWithResolved);
|
|
328
|
+
const content = result.find((f) => f.path === 'src/sso/sso.ts')!.content;
|
|
329
|
+
|
|
330
|
+
expect(content).toMatch(/getJwksUrl\(\): string \{/);
|
|
331
|
+
expect(content).toContain('return `${this.workos.baseURL}/sso/jwks`;');
|
|
332
|
+
expect(content).not.toContain('toQueryString');
|
|
333
|
+
expect(content).not.toContain("import { toQueryString } from '../common/utils/query-string';");
|
|
334
|
+
});
|
|
335
|
+
|
|
189
336
|
it('options-object: URL template binds to the SDK field name, not the spec path-param name', () => {
|
|
190
337
|
// When the spec uses `omId` as a path-param name but the baseline options
|
|
191
338
|
// interface exposes `organizationMembershipId`, both the destructure and
|
|
@@ -466,6 +613,470 @@ describe('generateResources', () => {
|
|
|
466
613
|
});
|
|
467
614
|
});
|
|
468
615
|
|
|
616
|
+
describe('body-less POST/PUT operations', () => {
|
|
617
|
+
// The WorkOS client's `post(path, entity, options?)` and `put(path, entity, options?)`
|
|
618
|
+
// REQUIRE the entity argument. Operations with no request body must still pass `{}`
|
|
619
|
+
// or the generated call fails with TS2554 "Expected 2-3 arguments, but got 1".
|
|
620
|
+
const domainModel: Model = {
|
|
621
|
+
name: 'OrganizationDomain',
|
|
622
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
it('passes an empty object body for a body-less POST with a response model', () => {
|
|
626
|
+
const services: Service[] = [
|
|
627
|
+
{
|
|
628
|
+
name: 'OrganizationDomains',
|
|
629
|
+
operations: [
|
|
630
|
+
{
|
|
631
|
+
name: 'verifyOrganizationDomain',
|
|
632
|
+
httpMethod: 'post',
|
|
633
|
+
path: '/organization_domains/{id}/verify',
|
|
634
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
635
|
+
queryParams: [],
|
|
636
|
+
headerParams: [],
|
|
637
|
+
response: { kind: 'model', name: 'OrganizationDomain' },
|
|
638
|
+
errors: [],
|
|
639
|
+
injectIdempotencyKey: false,
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
},
|
|
643
|
+
];
|
|
644
|
+
|
|
645
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [domainModel] };
|
|
646
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
647
|
+
const resourceFile = result.find((f) => f.path.includes('organization-domains.ts'));
|
|
648
|
+
expect(resourceFile).toBeDefined();
|
|
649
|
+
// The post() call must pass `{}` as the required entity argument.
|
|
650
|
+
expect(resourceFile!.content).toMatch(/await this\.workos\.post<[^>]+>\(`[^`]+`, \{\}\);/);
|
|
651
|
+
expect(resourceFile!.content).not.toMatch(/await this\.workos\.post<[^>]+>\(`[^`]+`\);/);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('passes an empty object body for a body-less PUT with a response model', () => {
|
|
655
|
+
const flagModel: Model = {
|
|
656
|
+
name: 'FeatureFlag',
|
|
657
|
+
fields: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
658
|
+
};
|
|
659
|
+
const services: Service[] = [
|
|
660
|
+
{
|
|
661
|
+
name: 'FeatureFlags',
|
|
662
|
+
operations: [
|
|
663
|
+
{
|
|
664
|
+
name: 'enableFeatureFlag',
|
|
665
|
+
httpMethod: 'put',
|
|
666
|
+
path: '/feature_flags/{slug}/enable',
|
|
667
|
+
pathParams: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
668
|
+
queryParams: [],
|
|
669
|
+
headerParams: [],
|
|
670
|
+
response: { kind: 'model', name: 'FeatureFlag' },
|
|
671
|
+
errors: [],
|
|
672
|
+
injectIdempotencyKey: false,
|
|
673
|
+
},
|
|
674
|
+
],
|
|
675
|
+
},
|
|
676
|
+
];
|
|
677
|
+
|
|
678
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [flagModel] };
|
|
679
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
680
|
+
const resourceFile = result.find((f) => f.path.includes('feature-flags.ts'));
|
|
681
|
+
expect(resourceFile).toBeDefined();
|
|
682
|
+
expect(resourceFile!.content).toMatch(/await this\.workos\.put<[^>]+>\(`[^`]+`, \{\}\);/);
|
|
683
|
+
expect(resourceFile!.content).not.toMatch(/await this\.workos\.put<[^>]+>\(`[^`]+`\);/);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('does not add a body argument to body-less GET calls', () => {
|
|
687
|
+
const services: Service[] = [
|
|
688
|
+
{
|
|
689
|
+
name: 'OrganizationDomains',
|
|
690
|
+
operations: [
|
|
691
|
+
{
|
|
692
|
+
name: 'getOrganizationDomain',
|
|
693
|
+
httpMethod: 'get',
|
|
694
|
+
path: '/organization_domains/{id}',
|
|
695
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
696
|
+
queryParams: [],
|
|
697
|
+
headerParams: [],
|
|
698
|
+
response: { kind: 'model', name: 'OrganizationDomain' },
|
|
699
|
+
errors: [],
|
|
700
|
+
injectIdempotencyKey: false,
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
},
|
|
704
|
+
];
|
|
705
|
+
|
|
706
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [domainModel] };
|
|
707
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
708
|
+
const resourceFile = result.find((f) => f.path.includes('organization-domains.ts'));
|
|
709
|
+
expect(resourceFile).toBeDefined();
|
|
710
|
+
expect(resourceFile!.content).toMatch(/await this\.workos\.get<[^>]+>\(`[^`]+`\);/);
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe('paginated list methods and path params (AutoPaginatable typing)', () => {
|
|
715
|
+
// List methods with PATH parameters destructure those params out of the
|
|
716
|
+
// options object (`const { actionName, ...paginationOptions } = options;`)
|
|
717
|
+
// and pass the REST object to AutoPaginatable/fetchAndDeserialize. The
|
|
718
|
+
// declared second type argument must therefore be the rest type
|
|
719
|
+
// (Omit<FullOptions, pathFields>) — declaring the full options interface
|
|
720
|
+
// fails TS2322 because the rest object lacks the required path-param fields.
|
|
721
|
+
const schemaModel: Model = {
|
|
722
|
+
name: 'AuditLogSchema',
|
|
723
|
+
fields: [{ name: 'version', type: { kind: 'primitive', type: 'number' }, required: true }],
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const paginationQueryParams = [
|
|
727
|
+
{ name: 'limit', type: { kind: 'primitive' as const, type: 'number' as const }, required: false },
|
|
728
|
+
{ name: 'after', type: { kind: 'primitive' as const, type: 'string' as const }, required: false },
|
|
729
|
+
];
|
|
730
|
+
|
|
731
|
+
const cursorPagination = {
|
|
732
|
+
strategy: 'cursor' as const,
|
|
733
|
+
param: 'after',
|
|
734
|
+
itemType: { kind: 'model' as const, name: 'AuditLogSchema' },
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
it('types AutoPaginatable over the rest options when one path param is destructured', () => {
|
|
738
|
+
const services: Service[] = [
|
|
739
|
+
{
|
|
740
|
+
name: 'AuditLogs',
|
|
741
|
+
operations: [
|
|
742
|
+
{
|
|
743
|
+
name: 'listActionSchemas',
|
|
744
|
+
httpMethod: 'get',
|
|
745
|
+
path: '/audit_logs/actions/{actionName}/schemas',
|
|
746
|
+
pathParams: [{ name: 'actionName', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
747
|
+
queryParams: paginationQueryParams,
|
|
748
|
+
headerParams: [],
|
|
749
|
+
response: { kind: 'array', items: { kind: 'model', name: 'AuditLogSchema' } },
|
|
750
|
+
pagination: cursorPagination,
|
|
751
|
+
errors: [],
|
|
752
|
+
injectIdempotencyKey: false,
|
|
753
|
+
},
|
|
754
|
+
],
|
|
755
|
+
},
|
|
756
|
+
];
|
|
757
|
+
|
|
758
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [schemaModel] };
|
|
759
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
760
|
+
const resourceFile = result.find((f) => f.path.includes('audit-logs.ts'));
|
|
761
|
+
expect(resourceFile).toBeDefined();
|
|
762
|
+
const content = resourceFile!.content;
|
|
763
|
+
|
|
764
|
+
// The declaration, the constructed value, and the re-fetch lambda must all
|
|
765
|
+
// agree on the rest type actually passed (paginationOptions).
|
|
766
|
+
const expectedMethod = [
|
|
767
|
+
" async listActionSchemas(options: ListActionSchemasOptions): Promise<AutoPaginatable<AuditLogSchema, Omit<ListActionSchemasOptions, 'actionName'>>> {",
|
|
768
|
+
' const { actionName, ...paginationOptions } = options;',
|
|
769
|
+
' return new AutoPaginatable(',
|
|
770
|
+
' await fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
|
|
771
|
+
' this.workos,',
|
|
772
|
+
' `/audit_logs/actions/${encodeURIComponent(actionName)}/schemas`,',
|
|
773
|
+
' deserializeAuditLogSchema,',
|
|
774
|
+
' paginationOptions,',
|
|
775
|
+
' ),',
|
|
776
|
+
' (params) =>',
|
|
777
|
+
' fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
|
|
778
|
+
' this.workos,',
|
|
779
|
+
' `/audit_logs/actions/${encodeURIComponent(actionName)}/schemas`,',
|
|
780
|
+
' deserializeAuditLogSchema,',
|
|
781
|
+
' params,',
|
|
782
|
+
' ),',
|
|
783
|
+
' paginationOptions,',
|
|
784
|
+
' );',
|
|
785
|
+
' }',
|
|
786
|
+
].join('\n');
|
|
787
|
+
expect(content).toContain(expectedMethod);
|
|
788
|
+
// The full options interface (which requires actionName) must never be the
|
|
789
|
+
// second AutoPaginatable type argument — that is the TS2322 shape.
|
|
790
|
+
expect(content).not.toContain('AutoPaginatable<AuditLogSchema, ListActionSchemasOptions>');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('keeps the full options type when no path params are destructured (regression)', () => {
|
|
794
|
+
const services: Service[] = [
|
|
795
|
+
{
|
|
796
|
+
name: 'AuditLogs',
|
|
797
|
+
operations: [
|
|
798
|
+
{
|
|
799
|
+
name: 'listActions',
|
|
800
|
+
httpMethod: 'get',
|
|
801
|
+
path: '/audit_logs/actions',
|
|
802
|
+
pathParams: [],
|
|
803
|
+
queryParams: paginationQueryParams,
|
|
804
|
+
headerParams: [],
|
|
805
|
+
response: { kind: 'array', items: { kind: 'model', name: 'AuditLogSchema' } },
|
|
806
|
+
pagination: cursorPagination,
|
|
807
|
+
errors: [],
|
|
808
|
+
injectIdempotencyKey: false,
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
},
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [schemaModel] };
|
|
815
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
816
|
+
const resourceFile = result.find((f) => f.path.includes('audit-logs.ts'));
|
|
817
|
+
expect(resourceFile).toBeDefined();
|
|
818
|
+
|
|
819
|
+
// Byte-identical to the pre-fix output: no Omit, no path destructure.
|
|
820
|
+
const expectedMethod = [
|
|
821
|
+
' async listActions(options?: ListActionsOptions): Promise<AutoPaginatable<AuditLogSchema, ListActionsOptions>> {',
|
|
822
|
+
' const paginationOptions = options;',
|
|
823
|
+
' return new AutoPaginatable(',
|
|
824
|
+
' await fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
|
|
825
|
+
' this.workos,',
|
|
826
|
+
" '/audit_logs/actions',",
|
|
827
|
+
' deserializeAuditLogSchema,',
|
|
828
|
+
' paginationOptions,',
|
|
829
|
+
' ),',
|
|
830
|
+
' (params) =>',
|
|
831
|
+
' fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
|
|
832
|
+
' this.workos,',
|
|
833
|
+
" '/audit_logs/actions',",
|
|
834
|
+
' deserializeAuditLogSchema,',
|
|
835
|
+
' params,',
|
|
836
|
+
' ),',
|
|
837
|
+
' paginationOptions,',
|
|
838
|
+
' );',
|
|
839
|
+
' }',
|
|
840
|
+
].join('\n');
|
|
841
|
+
expect(resourceFile!.content).toContain(expectedMethod);
|
|
842
|
+
expect(resourceFile!.content).not.toContain('Omit<');
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('omits every destructured path param when there are multiple', () => {
|
|
846
|
+
const memberModel: Model = {
|
|
847
|
+
name: 'GroupMember',
|
|
848
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
849
|
+
};
|
|
850
|
+
const services: Service[] = [
|
|
851
|
+
{
|
|
852
|
+
name: 'Groups',
|
|
853
|
+
operations: [
|
|
854
|
+
{
|
|
855
|
+
name: 'listGroupMembers',
|
|
856
|
+
httpMethod: 'get',
|
|
857
|
+
path: '/organizations/{organizationId}/groups/{groupId}/members',
|
|
858
|
+
pathParams: [
|
|
859
|
+
{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
860
|
+
{ name: 'groupId', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
861
|
+
],
|
|
862
|
+
queryParams: paginationQueryParams,
|
|
863
|
+
headerParams: [],
|
|
864
|
+
response: { kind: 'array', items: { kind: 'model', name: 'GroupMember' } },
|
|
865
|
+
pagination: {
|
|
866
|
+
strategy: 'cursor',
|
|
867
|
+
param: 'after',
|
|
868
|
+
itemType: { kind: 'model', name: 'GroupMember' },
|
|
869
|
+
},
|
|
870
|
+
errors: [],
|
|
871
|
+
injectIdempotencyKey: false,
|
|
872
|
+
},
|
|
873
|
+
],
|
|
874
|
+
},
|
|
875
|
+
];
|
|
876
|
+
|
|
877
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [memberModel] };
|
|
878
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
879
|
+
const resourceFile = result.find((f) => f.path.includes('groups.ts'));
|
|
880
|
+
expect(resourceFile).toBeDefined();
|
|
881
|
+
const content = resourceFile!.content;
|
|
882
|
+
|
|
883
|
+
expect(content).toContain(
|
|
884
|
+
'async listGroupMembers(options: ListGroupMembersOptions): ' +
|
|
885
|
+
"Promise<AutoPaginatable<GroupMember, Omit<ListGroupMembersOptions, 'organizationId' | 'groupId'>>> {",
|
|
886
|
+
);
|
|
887
|
+
expect(content).toContain('const { organizationId, groupId, ...paginationOptions } = options;');
|
|
888
|
+
expect(content).toContain(
|
|
889
|
+
'`/organizations/${encodeURIComponent(organizationId)}/groups/${encodeURIComponent(groupId)}/members`',
|
|
890
|
+
);
|
|
891
|
+
expect(content).not.toContain('AutoPaginatable<GroupMember, ListGroupMembersOptions>');
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
describe('inline object-literal baseline parameter types', () => {
|
|
896
|
+
// The hand-written workos-node AdminPortal method uses an inline object-literal
|
|
897
|
+
// parameter TYPE (`generateLink({ ... }: { intent: GenerateLinkIntent; ... })`).
|
|
898
|
+
// When the baseline surface reports that literal text as the param "type name",
|
|
899
|
+
// the emitter must keep it inline in the signature and must NOT slugify it into
|
|
900
|
+
// an interface filename or emit a named import of a brace-expression.
|
|
901
|
+
it('keeps the literal type inline and never imports it', () => {
|
|
902
|
+
const literalType = '{ intent: GenerateLinkIntent; organization: string; returnUrl?: string }';
|
|
903
|
+
const service: Service = {
|
|
904
|
+
name: 'AdminPortal',
|
|
905
|
+
operations: [
|
|
906
|
+
{
|
|
907
|
+
name: 'generateLink',
|
|
908
|
+
httpMethod: 'post',
|
|
909
|
+
path: '/portal/generate_link',
|
|
910
|
+
pathParams: [],
|
|
911
|
+
queryParams: [],
|
|
912
|
+
headerParams: [],
|
|
913
|
+
requestBody: { kind: 'model', name: 'GenerateLinkBody' },
|
|
914
|
+
response: { kind: 'model', name: 'PortalLink' },
|
|
915
|
+
errors: [],
|
|
916
|
+
injectIdempotencyKey: false,
|
|
917
|
+
},
|
|
918
|
+
],
|
|
919
|
+
};
|
|
920
|
+
const spec: ApiSpec = {
|
|
921
|
+
...emptySpec,
|
|
922
|
+
services: [service],
|
|
923
|
+
models: [
|
|
924
|
+
{
|
|
925
|
+
name: 'GenerateLinkBody',
|
|
926
|
+
fields: [
|
|
927
|
+
{ name: 'intent', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
928
|
+
{ name: 'organization', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
929
|
+
],
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
name: 'PortalLink',
|
|
933
|
+
fields: [{ name: 'link', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
934
|
+
},
|
|
935
|
+
],
|
|
936
|
+
};
|
|
937
|
+
const ctxWithBaseline: EmitterContext = {
|
|
938
|
+
...ctx,
|
|
939
|
+
spec,
|
|
940
|
+
emitterOptions: { ownedServices: ['AdminPortal'] },
|
|
941
|
+
apiSurface: {
|
|
942
|
+
classes: {
|
|
943
|
+
AdminPortal: {
|
|
944
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
945
|
+
methods: {
|
|
946
|
+
generateLink: [
|
|
947
|
+
{
|
|
948
|
+
name: 'generateLink',
|
|
949
|
+
params: [{ name: 'options', type: literalType, passingStyle: 'options_object' }],
|
|
950
|
+
returnType: 'Promise<{ link: string }>',
|
|
951
|
+
async: true,
|
|
952
|
+
},
|
|
953
|
+
],
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
} as any,
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const result = generateResources([service], ctxWithBaseline);
|
|
961
|
+
const resourceFile = result.find((f) => f.path === 'src/admin-portal/admin-portal.ts');
|
|
962
|
+
expect(resourceFile).toBeDefined();
|
|
963
|
+
const content = resourceFile!.content;
|
|
964
|
+
|
|
965
|
+
// The literal type stays inline in the method signature.
|
|
966
|
+
expect(content).toContain(`async generateLink(options: ${literalType})`);
|
|
967
|
+
// No named import of a brace-expression…
|
|
968
|
+
expect(content).not.toContain('import type { {');
|
|
969
|
+
// …and no import path derived from slugifying the literal type's text.
|
|
970
|
+
expect(content).not.toContain('intent-generate-link-intent');
|
|
971
|
+
// No interface file is emitted for the literal type either.
|
|
972
|
+
expect(result.some((f) => f.path.includes('intent-generate-link-intent'))).toBe(false);
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
describe('@oagen-ignore region method filtering', () => {
|
|
977
|
+
// `ignoredResourceMethodNames` scans @oagen-ignore-start/end regions in the
|
|
978
|
+
// existing on-disk resource file and the plan filter drops matching method
|
|
979
|
+
// names so user-preserved legacy methods are not re-emitted as duplicates.
|
|
980
|
+
// Generic methods (`name<T>(...)`, including multi-line type-parameter lists
|
|
981
|
+
// with constraints/defaults and nested angle brackets) must be caught too —
|
|
982
|
+
// on the SSO pass, region-protected getProfile<T>/getProfileAndToken<T> were
|
|
983
|
+
// re-appended as duplicates on every regen.
|
|
984
|
+
const ssoOp = (name: string, opPath: string) =>
|
|
985
|
+
({
|
|
986
|
+
name,
|
|
987
|
+
httpMethod: 'get',
|
|
988
|
+
path: opPath,
|
|
989
|
+
pathParams: [],
|
|
990
|
+
queryParams: [],
|
|
991
|
+
headerParams: [],
|
|
992
|
+
response: { kind: 'model', name: 'Profile' },
|
|
993
|
+
errors: [],
|
|
994
|
+
injectIdempotencyKey: false,
|
|
995
|
+
}) as Service['operations'][number];
|
|
996
|
+
|
|
997
|
+
it('filters region-protected generic methods (single-line and multi-line type params)', () => {
|
|
998
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-ignore-region-'));
|
|
999
|
+
try {
|
|
1000
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'sso'), { recursive: true });
|
|
1001
|
+
fs.writeFileSync(
|
|
1002
|
+
path.join(tmpRoot, 'src', 'sso', 'sso.ts'),
|
|
1003
|
+
[
|
|
1004
|
+
"import type { WorkOS } from '../workos';",
|
|
1005
|
+
'',
|
|
1006
|
+
'export class Sso {',
|
|
1007
|
+
' constructor(private readonly workos: WorkOS) {}',
|
|
1008
|
+
'',
|
|
1009
|
+
' // @oagen-ignore-start',
|
|
1010
|
+
' async getProfile<T extends Record<string, unknown> = Record<string, unknown>>(accessToken: string): Promise<T> {',
|
|
1011
|
+
' return {} as T;',
|
|
1012
|
+
' }',
|
|
1013
|
+
' // @oagen-ignore-end',
|
|
1014
|
+
'',
|
|
1015
|
+
' // @oagen-ignore-start',
|
|
1016
|
+
' async getProfileAndToken<',
|
|
1017
|
+
' T extends Record<string, unknown> = Record<string, unknown>,',
|
|
1018
|
+
' >(payload: { code: string }): Promise<T> {',
|
|
1019
|
+
' return {} as T;',
|
|
1020
|
+
' }',
|
|
1021
|
+
' // @oagen-ignore-end',
|
|
1022
|
+
'',
|
|
1023
|
+
' // @oagen-ignore-start',
|
|
1024
|
+
' getAuthorizationUrl(options: { provider: string }): string {',
|
|
1025
|
+
" return '';",
|
|
1026
|
+
' }',
|
|
1027
|
+
' // @oagen-ignore-end',
|
|
1028
|
+
'}',
|
|
1029
|
+
'',
|
|
1030
|
+
].join('\n'),
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
const service: Service = {
|
|
1034
|
+
name: 'Sso',
|
|
1035
|
+
operations: [
|
|
1036
|
+
ssoOp('getProfile', '/sso/profile'),
|
|
1037
|
+
ssoOp('getProfileAndToken', '/sso/token'),
|
|
1038
|
+
ssoOp('getAuthorizationUrl', '/sso/authorize'),
|
|
1039
|
+
{
|
|
1040
|
+
name: 'deleteConnection',
|
|
1041
|
+
httpMethod: 'delete',
|
|
1042
|
+
path: '/connections/{id}',
|
|
1043
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
1044
|
+
queryParams: [],
|
|
1045
|
+
headerParams: [],
|
|
1046
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
1047
|
+
errors: [],
|
|
1048
|
+
injectIdempotencyKey: false,
|
|
1049
|
+
},
|
|
1050
|
+
],
|
|
1051
|
+
};
|
|
1052
|
+
const profileModel: Model = {
|
|
1053
|
+
name: 'Profile',
|
|
1054
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
1055
|
+
};
|
|
1056
|
+
const spec: ApiSpec = { ...emptySpec, services: [service], models: [profileModel] };
|
|
1057
|
+
|
|
1058
|
+
const result = generateResources([service], {
|
|
1059
|
+
...ctx,
|
|
1060
|
+
spec,
|
|
1061
|
+
outputDir: tmpRoot,
|
|
1062
|
+
emitterOptions: { ownedServices: ['Sso'] },
|
|
1063
|
+
} as EmitterContext);
|
|
1064
|
+
|
|
1065
|
+
const resourceFile = result.find((f) => f.path === 'src/sso/sso.ts');
|
|
1066
|
+
expect(resourceFile).toBeDefined();
|
|
1067
|
+
const content = resourceFile!.content;
|
|
1068
|
+
// The non-protected method is still emitted…
|
|
1069
|
+
expect(content).toContain('async deleteConnection');
|
|
1070
|
+
// …but region-protected methods are not re-emitted, generic or not.
|
|
1071
|
+
expect(content).not.toContain('async getProfile(');
|
|
1072
|
+
expect(content).not.toContain('async getProfileAndToken(');
|
|
1073
|
+
expect(content).not.toContain('getAuthorizationUrl(');
|
|
1074
|
+
} finally {
|
|
1075
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
|
|
469
1080
|
describe('resolveResourceClassName', () => {
|
|
470
1081
|
it('uses overlay name when baseline has compatible constructor', () => {
|
|
471
1082
|
const service: Service = { name: 'Organizations', operations: [] };
|