@workos/oagen-emitters 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-DuB1UozS.mjs → plugin-CpO8rePT.mjs} +1164 -490
- package/dist/plugin-CpO8rePT.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +264 -4
- 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 +39 -3
- package/src/node/utils.ts +140 -22
- 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 +464 -0
- package/test/node/utils.test.ts +157 -2
- package/dist/plugin-DuB1UozS.mjs.map +0 -1
package/test/node/naming.test.ts
CHANGED
|
@@ -84,6 +84,165 @@ describe('resolveInterfaceName', () => {
|
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Structural name resolution must be INJECTIVE: a live-surface name may be
|
|
89
|
+
// claimed by at most one IR model per run. Reconstructs the workos-node
|
|
90
|
+
// AuditLogs incident: the spec has two near-identical models
|
|
91
|
+
// (AuditLogEventActor / AuditLogEventTarget) and the live SDK declares a
|
|
92
|
+
// hand-written AuditLogActor with the same shape. The structural fallback
|
|
93
|
+
// mapped BOTH IR models onto AuditLogActor, so
|
|
94
|
+
// audit-log-event-target.interface.ts was emitted declaring
|
|
95
|
+
// `export interface AuditLogActor` (file stem and declaration disagree),
|
|
96
|
+
// with duplicate imports/describe blocks and two serializeAuditLogActor
|
|
97
|
+
// definitions downstream.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
describe('resolveInterfaceName structural injectivity', () => {
|
|
100
|
+
const field = (name: string, required = false) => ({
|
|
101
|
+
name,
|
|
102
|
+
type: { kind: 'primitive', type: 'string' },
|
|
103
|
+
required,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Shape ~ { id?, name, type?, metadata? } — matches the live AuditLogActor.
|
|
107
|
+
const eventShape = (extra: string) => [
|
|
108
|
+
field('id'),
|
|
109
|
+
field('name', true),
|
|
110
|
+
field('type'),
|
|
111
|
+
field('metadata'),
|
|
112
|
+
field(extra),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const liveActorFields = {
|
|
116
|
+
id: { type: 'string', optional: true },
|
|
117
|
+
name: { type: 'string', optional: false },
|
|
118
|
+
type: { type: 'string', optional: true },
|
|
119
|
+
metadata: { type: 'string', optional: true },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function auditCtx(opts: {
|
|
123
|
+
models?: { name: string; fields: unknown[] }[];
|
|
124
|
+
modelNameByIR: [string, string][];
|
|
125
|
+
interfaceByName?: [string, string][];
|
|
126
|
+
extraInterfaces?: Record<string, unknown>;
|
|
127
|
+
}): EmitterContext {
|
|
128
|
+
const models = opts.models ?? [
|
|
129
|
+
{ name: 'AuditLogEventActor', fields: eventShape('ip_address') },
|
|
130
|
+
{ name: 'AuditLogEventTarget', fields: eventShape('domain') },
|
|
131
|
+
];
|
|
132
|
+
return {
|
|
133
|
+
...ctx,
|
|
134
|
+
spec: { ...ctx.spec, models },
|
|
135
|
+
apiSurface: {
|
|
136
|
+
language: 'node',
|
|
137
|
+
extractedFrom: '/tmp/workos-node',
|
|
138
|
+
extractedAt: '2026-06-10T00:00:00Z',
|
|
139
|
+
classes: {},
|
|
140
|
+
interfaces: {
|
|
141
|
+
AuditLogActor: { fields: liveActorFields },
|
|
142
|
+
...opts.extraInterfaces,
|
|
143
|
+
},
|
|
144
|
+
typeAliases: {},
|
|
145
|
+
enums: {},
|
|
146
|
+
exports: {},
|
|
147
|
+
},
|
|
148
|
+
overlayLookup: {
|
|
149
|
+
methodByOperation: new Map(),
|
|
150
|
+
interfaceByName: new Map(opts.interfaceByName ?? []),
|
|
151
|
+
modelNameByIR: new Map(opts.modelNameByIR),
|
|
152
|
+
},
|
|
153
|
+
} as unknown as EmitterContext;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
it('never lets two IR models collapse onto one live name', () => {
|
|
157
|
+
const c = auditCtx({
|
|
158
|
+
modelNameByIR: [
|
|
159
|
+
['AuditLogEventActor', 'AuditLogActor'],
|
|
160
|
+
['AuditLogEventTarget', 'AuditLogActor'],
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const actor = resolveInterfaceName('AuditLogEventActor', c);
|
|
165
|
+
const target = resolveInterfaceName('AuditLogEventTarget', c);
|
|
166
|
+
|
|
167
|
+
// The closer name wins the contested live name; the loser keeps its
|
|
168
|
+
// canonical IR-derived name — it must NEVER unify onto AuditLogActor.
|
|
169
|
+
expect(actor).toBe('AuditLogActor');
|
|
170
|
+
expect(target).toBe('AuditLogEventTarget');
|
|
171
|
+
expect(actor).not.toBe(target);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('awards a contested name independently of overlay insertion order', () => {
|
|
175
|
+
const c = auditCtx({
|
|
176
|
+
models: [
|
|
177
|
+
{ name: 'AuditLogEventTarget', fields: eventShape('domain') },
|
|
178
|
+
{ name: 'AuditLogEventActor', fields: eventShape('ip_address') },
|
|
179
|
+
],
|
|
180
|
+
modelNameByIR: [
|
|
181
|
+
['AuditLogEventTarget', 'AuditLogActor'],
|
|
182
|
+
['AuditLogEventActor', 'AuditLogActor'],
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogActor');
|
|
187
|
+
expect(resolveInterfaceName('AuditLogEventTarget', c)).toBe('AuditLogEventTarget');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('stays injective when Serialized* normalization collapses two distinct raw matches', () => {
|
|
191
|
+
// The engine overlay itself is injective on raw names (actor →
|
|
192
|
+
// AuditLogActor, target → SerializedAuditLogActor), but the resolver
|
|
193
|
+
// normalizes Serialized* down to the bare name — that post-processing
|
|
194
|
+
// must not re-introduce a collision.
|
|
195
|
+
const c = auditCtx({
|
|
196
|
+
modelNameByIR: [
|
|
197
|
+
['AuditLogEventActor', 'AuditLogActor'],
|
|
198
|
+
['AuditLogEventTarget', 'SerializedAuditLogActor'],
|
|
199
|
+
],
|
|
200
|
+
extraInterfaces: { SerializedAuditLogActor: { fields: liveActorFields } },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogActor');
|
|
204
|
+
expect(resolveInterfaceName('AuditLogEventTarget', c)).toBe('AuditLogEventTarget');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('prefers the structurally closer claimant over the closer name', () => {
|
|
208
|
+
// Target matches the live shape exactly; actor only shares two fields.
|
|
209
|
+
// Similarity outranks name distance, so target wins even though
|
|
210
|
+
// "AuditLogEventActor" is the closer name.
|
|
211
|
+
const c = auditCtx({
|
|
212
|
+
models: [
|
|
213
|
+
{ name: 'AuditLogEventActor', fields: [field('id'), field('name', true), field('ip'), field('agent')] },
|
|
214
|
+
{ name: 'AuditLogEventTarget', fields: [field('id'), field('name', true), field('type'), field('metadata')] },
|
|
215
|
+
],
|
|
216
|
+
modelNameByIR: [
|
|
217
|
+
['AuditLogEventActor', 'AuditLogActor'],
|
|
218
|
+
['AuditLogEventTarget', 'AuditLogActor'],
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(resolveInterfaceName('AuditLogEventTarget', c)).toBe('AuditLogActor');
|
|
223
|
+
expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogEventActor');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('blocks structural claims on names already claimed by an exact-name override', () => {
|
|
227
|
+
const c = auditCtx({
|
|
228
|
+
interfaceByName: [['AuditLogEventActor', 'AuditLogActor']],
|
|
229
|
+
modelNameByIR: [['AuditLogEventTarget', 'AuditLogActor']],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogActor');
|
|
233
|
+
expect(resolveInterfaceName('AuditLogEventTarget', c)).toBe('AuditLogEventTarget');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('still applies a single-model structural rename (the legitimate overlay case)', () => {
|
|
237
|
+
const c = auditCtx({
|
|
238
|
+
models: [{ name: 'AuditLogEventActor', fields: eventShape('ip_address') }],
|
|
239
|
+
modelNameByIR: [['AuditLogEventActor', 'AuditLogActor']],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogActor');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
87
246
|
describe('wireInterfaceName', () => {
|
|
88
247
|
it('emits *Wire for a fresh `*Response`-named IR model with an empty baseline', () => {
|
|
89
248
|
expect(wireInterfaceName('CreateDataKeyResponse')).toBe('CreateDataKeyResponseWire');
|
|
@@ -466,6 +466,470 @@ describe('generateResources', () => {
|
|
|
466
466
|
});
|
|
467
467
|
});
|
|
468
468
|
|
|
469
|
+
describe('body-less POST/PUT operations', () => {
|
|
470
|
+
// The WorkOS client's `post(path, entity, options?)` and `put(path, entity, options?)`
|
|
471
|
+
// REQUIRE the entity argument. Operations with no request body must still pass `{}`
|
|
472
|
+
// or the generated call fails with TS2554 "Expected 2-3 arguments, but got 1".
|
|
473
|
+
const domainModel: Model = {
|
|
474
|
+
name: 'OrganizationDomain',
|
|
475
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
it('passes an empty object body for a body-less POST with a response model', () => {
|
|
479
|
+
const services: Service[] = [
|
|
480
|
+
{
|
|
481
|
+
name: 'OrganizationDomains',
|
|
482
|
+
operations: [
|
|
483
|
+
{
|
|
484
|
+
name: 'verifyOrganizationDomain',
|
|
485
|
+
httpMethod: 'post',
|
|
486
|
+
path: '/organization_domains/{id}/verify',
|
|
487
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
488
|
+
queryParams: [],
|
|
489
|
+
headerParams: [],
|
|
490
|
+
response: { kind: 'model', name: 'OrganizationDomain' },
|
|
491
|
+
errors: [],
|
|
492
|
+
injectIdempotencyKey: false,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [domainModel] };
|
|
499
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
500
|
+
const resourceFile = result.find((f) => f.path.includes('organization-domains.ts'));
|
|
501
|
+
expect(resourceFile).toBeDefined();
|
|
502
|
+
// The post() call must pass `{}` as the required entity argument.
|
|
503
|
+
expect(resourceFile!.content).toMatch(/await this\.workos\.post<[^>]+>\(`[^`]+`, \{\}\);/);
|
|
504
|
+
expect(resourceFile!.content).not.toMatch(/await this\.workos\.post<[^>]+>\(`[^`]+`\);/);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('passes an empty object body for a body-less PUT with a response model', () => {
|
|
508
|
+
const flagModel: Model = {
|
|
509
|
+
name: 'FeatureFlag',
|
|
510
|
+
fields: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
511
|
+
};
|
|
512
|
+
const services: Service[] = [
|
|
513
|
+
{
|
|
514
|
+
name: 'FeatureFlags',
|
|
515
|
+
operations: [
|
|
516
|
+
{
|
|
517
|
+
name: 'enableFeatureFlag',
|
|
518
|
+
httpMethod: 'put',
|
|
519
|
+
path: '/feature_flags/{slug}/enable',
|
|
520
|
+
pathParams: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
521
|
+
queryParams: [],
|
|
522
|
+
headerParams: [],
|
|
523
|
+
response: { kind: 'model', name: 'FeatureFlag' },
|
|
524
|
+
errors: [],
|
|
525
|
+
injectIdempotencyKey: false,
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
},
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [flagModel] };
|
|
532
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
533
|
+
const resourceFile = result.find((f) => f.path.includes('feature-flags.ts'));
|
|
534
|
+
expect(resourceFile).toBeDefined();
|
|
535
|
+
expect(resourceFile!.content).toMatch(/await this\.workos\.put<[^>]+>\(`[^`]+`, \{\}\);/);
|
|
536
|
+
expect(resourceFile!.content).not.toMatch(/await this\.workos\.put<[^>]+>\(`[^`]+`\);/);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('does not add a body argument to body-less GET calls', () => {
|
|
540
|
+
const services: Service[] = [
|
|
541
|
+
{
|
|
542
|
+
name: 'OrganizationDomains',
|
|
543
|
+
operations: [
|
|
544
|
+
{
|
|
545
|
+
name: 'getOrganizationDomain',
|
|
546
|
+
httpMethod: 'get',
|
|
547
|
+
path: '/organization_domains/{id}',
|
|
548
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
549
|
+
queryParams: [],
|
|
550
|
+
headerParams: [],
|
|
551
|
+
response: { kind: 'model', name: 'OrganizationDomain' },
|
|
552
|
+
errors: [],
|
|
553
|
+
injectIdempotencyKey: false,
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
];
|
|
558
|
+
|
|
559
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [domainModel] };
|
|
560
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
561
|
+
const resourceFile = result.find((f) => f.path.includes('organization-domains.ts'));
|
|
562
|
+
expect(resourceFile).toBeDefined();
|
|
563
|
+
expect(resourceFile!.content).toMatch(/await this\.workos\.get<[^>]+>\(`[^`]+`\);/);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
describe('paginated list methods and path params (AutoPaginatable typing)', () => {
|
|
568
|
+
// List methods with PATH parameters destructure those params out of the
|
|
569
|
+
// options object (`const { actionName, ...paginationOptions } = options;`)
|
|
570
|
+
// and pass the REST object to AutoPaginatable/fetchAndDeserialize. The
|
|
571
|
+
// declared second type argument must therefore be the rest type
|
|
572
|
+
// (Omit<FullOptions, pathFields>) — declaring the full options interface
|
|
573
|
+
// fails TS2322 because the rest object lacks the required path-param fields.
|
|
574
|
+
const schemaModel: Model = {
|
|
575
|
+
name: 'AuditLogSchema',
|
|
576
|
+
fields: [{ name: 'version', type: { kind: 'primitive', type: 'number' }, required: true }],
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const paginationQueryParams = [
|
|
580
|
+
{ name: 'limit', type: { kind: 'primitive' as const, type: 'number' as const }, required: false },
|
|
581
|
+
{ name: 'after', type: { kind: 'primitive' as const, type: 'string' as const }, required: false },
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
const cursorPagination = {
|
|
585
|
+
strategy: 'cursor' as const,
|
|
586
|
+
param: 'after',
|
|
587
|
+
itemType: { kind: 'model' as const, name: 'AuditLogSchema' },
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
it('types AutoPaginatable over the rest options when one path param is destructured', () => {
|
|
591
|
+
const services: Service[] = [
|
|
592
|
+
{
|
|
593
|
+
name: 'AuditLogs',
|
|
594
|
+
operations: [
|
|
595
|
+
{
|
|
596
|
+
name: 'listActionSchemas',
|
|
597
|
+
httpMethod: 'get',
|
|
598
|
+
path: '/audit_logs/actions/{actionName}/schemas',
|
|
599
|
+
pathParams: [{ name: 'actionName', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
600
|
+
queryParams: paginationQueryParams,
|
|
601
|
+
headerParams: [],
|
|
602
|
+
response: { kind: 'array', items: { kind: 'model', name: 'AuditLogSchema' } },
|
|
603
|
+
pagination: cursorPagination,
|
|
604
|
+
errors: [],
|
|
605
|
+
injectIdempotencyKey: false,
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
},
|
|
609
|
+
];
|
|
610
|
+
|
|
611
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [schemaModel] };
|
|
612
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
613
|
+
const resourceFile = result.find((f) => f.path.includes('audit-logs.ts'));
|
|
614
|
+
expect(resourceFile).toBeDefined();
|
|
615
|
+
const content = resourceFile!.content;
|
|
616
|
+
|
|
617
|
+
// The declaration, the constructed value, and the re-fetch lambda must all
|
|
618
|
+
// agree on the rest type actually passed (paginationOptions).
|
|
619
|
+
const expectedMethod = [
|
|
620
|
+
" async listActionSchemas(options: ListActionSchemasOptions): Promise<AutoPaginatable<AuditLogSchema, Omit<ListActionSchemasOptions, 'actionName'>>> {",
|
|
621
|
+
' const { actionName, ...paginationOptions } = options;',
|
|
622
|
+
' return new AutoPaginatable(',
|
|
623
|
+
' await fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
|
|
624
|
+
' this.workos,',
|
|
625
|
+
' `/audit_logs/actions/${encodeURIComponent(actionName)}/schemas`,',
|
|
626
|
+
' deserializeAuditLogSchema,',
|
|
627
|
+
' paginationOptions,',
|
|
628
|
+
' ),',
|
|
629
|
+
' (params) =>',
|
|
630
|
+
' fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
|
|
631
|
+
' this.workos,',
|
|
632
|
+
' `/audit_logs/actions/${encodeURIComponent(actionName)}/schemas`,',
|
|
633
|
+
' deserializeAuditLogSchema,',
|
|
634
|
+
' params,',
|
|
635
|
+
' ),',
|
|
636
|
+
' paginationOptions,',
|
|
637
|
+
' );',
|
|
638
|
+
' }',
|
|
639
|
+
].join('\n');
|
|
640
|
+
expect(content).toContain(expectedMethod);
|
|
641
|
+
// The full options interface (which requires actionName) must never be the
|
|
642
|
+
// second AutoPaginatable type argument — that is the TS2322 shape.
|
|
643
|
+
expect(content).not.toContain('AutoPaginatable<AuditLogSchema, ListActionSchemasOptions>');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('keeps the full options type when no path params are destructured (regression)', () => {
|
|
647
|
+
const services: Service[] = [
|
|
648
|
+
{
|
|
649
|
+
name: 'AuditLogs',
|
|
650
|
+
operations: [
|
|
651
|
+
{
|
|
652
|
+
name: 'listActions',
|
|
653
|
+
httpMethod: 'get',
|
|
654
|
+
path: '/audit_logs/actions',
|
|
655
|
+
pathParams: [],
|
|
656
|
+
queryParams: paginationQueryParams,
|
|
657
|
+
headerParams: [],
|
|
658
|
+
response: { kind: 'array', items: { kind: 'model', name: 'AuditLogSchema' } },
|
|
659
|
+
pagination: cursorPagination,
|
|
660
|
+
errors: [],
|
|
661
|
+
injectIdempotencyKey: false,
|
|
662
|
+
},
|
|
663
|
+
],
|
|
664
|
+
},
|
|
665
|
+
];
|
|
666
|
+
|
|
667
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [schemaModel] };
|
|
668
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
669
|
+
const resourceFile = result.find((f) => f.path.includes('audit-logs.ts'));
|
|
670
|
+
expect(resourceFile).toBeDefined();
|
|
671
|
+
|
|
672
|
+
// Byte-identical to the pre-fix output: no Omit, no path destructure.
|
|
673
|
+
const expectedMethod = [
|
|
674
|
+
' async listActions(options?: ListActionsOptions): Promise<AutoPaginatable<AuditLogSchema, ListActionsOptions>> {',
|
|
675
|
+
' const paginationOptions = options;',
|
|
676
|
+
' return new AutoPaginatable(',
|
|
677
|
+
' await fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
|
|
678
|
+
' this.workos,',
|
|
679
|
+
" '/audit_logs/actions',",
|
|
680
|
+
' deserializeAuditLogSchema,',
|
|
681
|
+
' paginationOptions,',
|
|
682
|
+
' ),',
|
|
683
|
+
' (params) =>',
|
|
684
|
+
' fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
|
|
685
|
+
' this.workos,',
|
|
686
|
+
" '/audit_logs/actions',",
|
|
687
|
+
' deserializeAuditLogSchema,',
|
|
688
|
+
' params,',
|
|
689
|
+
' ),',
|
|
690
|
+
' paginationOptions,',
|
|
691
|
+
' );',
|
|
692
|
+
' }',
|
|
693
|
+
].join('\n');
|
|
694
|
+
expect(resourceFile!.content).toContain(expectedMethod);
|
|
695
|
+
expect(resourceFile!.content).not.toContain('Omit<');
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('omits every destructured path param when there are multiple', () => {
|
|
699
|
+
const memberModel: Model = {
|
|
700
|
+
name: 'GroupMember',
|
|
701
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
702
|
+
};
|
|
703
|
+
const services: Service[] = [
|
|
704
|
+
{
|
|
705
|
+
name: 'Groups',
|
|
706
|
+
operations: [
|
|
707
|
+
{
|
|
708
|
+
name: 'listGroupMembers',
|
|
709
|
+
httpMethod: 'get',
|
|
710
|
+
path: '/organizations/{organizationId}/groups/{groupId}/members',
|
|
711
|
+
pathParams: [
|
|
712
|
+
{ name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
713
|
+
{ name: 'groupId', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
714
|
+
],
|
|
715
|
+
queryParams: paginationQueryParams,
|
|
716
|
+
headerParams: [],
|
|
717
|
+
response: { kind: 'array', items: { kind: 'model', name: 'GroupMember' } },
|
|
718
|
+
pagination: {
|
|
719
|
+
strategy: 'cursor',
|
|
720
|
+
param: 'after',
|
|
721
|
+
itemType: { kind: 'model', name: 'GroupMember' },
|
|
722
|
+
},
|
|
723
|
+
errors: [],
|
|
724
|
+
injectIdempotencyKey: false,
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
},
|
|
728
|
+
];
|
|
729
|
+
|
|
730
|
+
const spec: ApiSpec = { ...emptySpec, services, models: [memberModel] };
|
|
731
|
+
const result = generateResources(services, { ...ctx, spec });
|
|
732
|
+
const resourceFile = result.find((f) => f.path.includes('groups.ts'));
|
|
733
|
+
expect(resourceFile).toBeDefined();
|
|
734
|
+
const content = resourceFile!.content;
|
|
735
|
+
|
|
736
|
+
expect(content).toContain(
|
|
737
|
+
'async listGroupMembers(options: ListGroupMembersOptions): ' +
|
|
738
|
+
"Promise<AutoPaginatable<GroupMember, Omit<ListGroupMembersOptions, 'organizationId' | 'groupId'>>> {",
|
|
739
|
+
);
|
|
740
|
+
expect(content).toContain('const { organizationId, groupId, ...paginationOptions } = options;');
|
|
741
|
+
expect(content).toContain(
|
|
742
|
+
'`/organizations/${encodeURIComponent(organizationId)}/groups/${encodeURIComponent(groupId)}/members`',
|
|
743
|
+
);
|
|
744
|
+
expect(content).not.toContain('AutoPaginatable<GroupMember, ListGroupMembersOptions>');
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
describe('inline object-literal baseline parameter types', () => {
|
|
749
|
+
// The hand-written workos-node AdminPortal method uses an inline object-literal
|
|
750
|
+
// parameter TYPE (`generateLink({ ... }: { intent: GenerateLinkIntent; ... })`).
|
|
751
|
+
// When the baseline surface reports that literal text as the param "type name",
|
|
752
|
+
// the emitter must keep it inline in the signature and must NOT slugify it into
|
|
753
|
+
// an interface filename or emit a named import of a brace-expression.
|
|
754
|
+
it('keeps the literal type inline and never imports it', () => {
|
|
755
|
+
const literalType = '{ intent: GenerateLinkIntent; organization: string; returnUrl?: string }';
|
|
756
|
+
const service: Service = {
|
|
757
|
+
name: 'AdminPortal',
|
|
758
|
+
operations: [
|
|
759
|
+
{
|
|
760
|
+
name: 'generateLink',
|
|
761
|
+
httpMethod: 'post',
|
|
762
|
+
path: '/portal/generate_link',
|
|
763
|
+
pathParams: [],
|
|
764
|
+
queryParams: [],
|
|
765
|
+
headerParams: [],
|
|
766
|
+
requestBody: { kind: 'model', name: 'GenerateLinkBody' },
|
|
767
|
+
response: { kind: 'model', name: 'PortalLink' },
|
|
768
|
+
errors: [],
|
|
769
|
+
injectIdempotencyKey: false,
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
};
|
|
773
|
+
const spec: ApiSpec = {
|
|
774
|
+
...emptySpec,
|
|
775
|
+
services: [service],
|
|
776
|
+
models: [
|
|
777
|
+
{
|
|
778
|
+
name: 'GenerateLinkBody',
|
|
779
|
+
fields: [
|
|
780
|
+
{ name: 'intent', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
781
|
+
{ name: 'organization', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
782
|
+
],
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
name: 'PortalLink',
|
|
786
|
+
fields: [{ name: 'link', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
787
|
+
},
|
|
788
|
+
],
|
|
789
|
+
};
|
|
790
|
+
const ctxWithBaseline: EmitterContext = {
|
|
791
|
+
...ctx,
|
|
792
|
+
spec,
|
|
793
|
+
emitterOptions: { ownedServices: ['AdminPortal'] },
|
|
794
|
+
apiSurface: {
|
|
795
|
+
classes: {
|
|
796
|
+
AdminPortal: {
|
|
797
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS' }],
|
|
798
|
+
methods: {
|
|
799
|
+
generateLink: [
|
|
800
|
+
{
|
|
801
|
+
name: 'generateLink',
|
|
802
|
+
params: [{ name: 'options', type: literalType, passingStyle: 'options_object' }],
|
|
803
|
+
returnType: 'Promise<{ link: string }>',
|
|
804
|
+
async: true,
|
|
805
|
+
},
|
|
806
|
+
],
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
} as any,
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
const result = generateResources([service], ctxWithBaseline);
|
|
814
|
+
const resourceFile = result.find((f) => f.path === 'src/admin-portal/admin-portal.ts');
|
|
815
|
+
expect(resourceFile).toBeDefined();
|
|
816
|
+
const content = resourceFile!.content;
|
|
817
|
+
|
|
818
|
+
// The literal type stays inline in the method signature.
|
|
819
|
+
expect(content).toContain(`async generateLink(options: ${literalType})`);
|
|
820
|
+
// No named import of a brace-expression…
|
|
821
|
+
expect(content).not.toContain('import type { {');
|
|
822
|
+
// …and no import path derived from slugifying the literal type's text.
|
|
823
|
+
expect(content).not.toContain('intent-generate-link-intent');
|
|
824
|
+
// No interface file is emitted for the literal type either.
|
|
825
|
+
expect(result.some((f) => f.path.includes('intent-generate-link-intent'))).toBe(false);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
describe('@oagen-ignore region method filtering', () => {
|
|
830
|
+
// `ignoredResourceMethodNames` scans @oagen-ignore-start/end regions in the
|
|
831
|
+
// existing on-disk resource file and the plan filter drops matching method
|
|
832
|
+
// names so user-preserved legacy methods are not re-emitted as duplicates.
|
|
833
|
+
// Generic methods (`name<T>(...)`, including multi-line type-parameter lists
|
|
834
|
+
// with constraints/defaults and nested angle brackets) must be caught too —
|
|
835
|
+
// on the SSO pass, region-protected getProfile<T>/getProfileAndToken<T> were
|
|
836
|
+
// re-appended as duplicates on every regen.
|
|
837
|
+
const ssoOp = (name: string, opPath: string) =>
|
|
838
|
+
({
|
|
839
|
+
name,
|
|
840
|
+
httpMethod: 'get',
|
|
841
|
+
path: opPath,
|
|
842
|
+
pathParams: [],
|
|
843
|
+
queryParams: [],
|
|
844
|
+
headerParams: [],
|
|
845
|
+
response: { kind: 'model', name: 'Profile' },
|
|
846
|
+
errors: [],
|
|
847
|
+
injectIdempotencyKey: false,
|
|
848
|
+
}) as Service['operations'][number];
|
|
849
|
+
|
|
850
|
+
it('filters region-protected generic methods (single-line and multi-line type params)', () => {
|
|
851
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-ignore-region-'));
|
|
852
|
+
try {
|
|
853
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'sso'), { recursive: true });
|
|
854
|
+
fs.writeFileSync(
|
|
855
|
+
path.join(tmpRoot, 'src', 'sso', 'sso.ts'),
|
|
856
|
+
[
|
|
857
|
+
"import type { WorkOS } from '../workos';",
|
|
858
|
+
'',
|
|
859
|
+
'export class Sso {',
|
|
860
|
+
' constructor(private readonly workos: WorkOS) {}',
|
|
861
|
+
'',
|
|
862
|
+
' // @oagen-ignore-start',
|
|
863
|
+
' async getProfile<T extends Record<string, unknown> = Record<string, unknown>>(accessToken: string): Promise<T> {',
|
|
864
|
+
' return {} as T;',
|
|
865
|
+
' }',
|
|
866
|
+
' // @oagen-ignore-end',
|
|
867
|
+
'',
|
|
868
|
+
' // @oagen-ignore-start',
|
|
869
|
+
' async getProfileAndToken<',
|
|
870
|
+
' T extends Record<string, unknown> = Record<string, unknown>,',
|
|
871
|
+
' >(payload: { code: string }): Promise<T> {',
|
|
872
|
+
' return {} as T;',
|
|
873
|
+
' }',
|
|
874
|
+
' // @oagen-ignore-end',
|
|
875
|
+
'',
|
|
876
|
+
' // @oagen-ignore-start',
|
|
877
|
+
' getAuthorizationUrl(options: { provider: string }): string {',
|
|
878
|
+
" return '';",
|
|
879
|
+
' }',
|
|
880
|
+
' // @oagen-ignore-end',
|
|
881
|
+
'}',
|
|
882
|
+
'',
|
|
883
|
+
].join('\n'),
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
const service: Service = {
|
|
887
|
+
name: 'Sso',
|
|
888
|
+
operations: [
|
|
889
|
+
ssoOp('getProfile', '/sso/profile'),
|
|
890
|
+
ssoOp('getProfileAndToken', '/sso/token'),
|
|
891
|
+
ssoOp('getAuthorizationUrl', '/sso/authorize'),
|
|
892
|
+
{
|
|
893
|
+
name: 'deleteConnection',
|
|
894
|
+
httpMethod: 'delete',
|
|
895
|
+
path: '/connections/{id}',
|
|
896
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
897
|
+
queryParams: [],
|
|
898
|
+
headerParams: [],
|
|
899
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
900
|
+
errors: [],
|
|
901
|
+
injectIdempotencyKey: false,
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
};
|
|
905
|
+
const profileModel: Model = {
|
|
906
|
+
name: 'Profile',
|
|
907
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
908
|
+
};
|
|
909
|
+
const spec: ApiSpec = { ...emptySpec, services: [service], models: [profileModel] };
|
|
910
|
+
|
|
911
|
+
const result = generateResources([service], {
|
|
912
|
+
...ctx,
|
|
913
|
+
spec,
|
|
914
|
+
outputDir: tmpRoot,
|
|
915
|
+
emitterOptions: { ownedServices: ['Sso'] },
|
|
916
|
+
} as EmitterContext);
|
|
917
|
+
|
|
918
|
+
const resourceFile = result.find((f) => f.path === 'src/sso/sso.ts');
|
|
919
|
+
expect(resourceFile).toBeDefined();
|
|
920
|
+
const content = resourceFile!.content;
|
|
921
|
+
// The non-protected method is still emitted…
|
|
922
|
+
expect(content).toContain('async deleteConnection');
|
|
923
|
+
// …but region-protected methods are not re-emitted, generic or not.
|
|
924
|
+
expect(content).not.toContain('async getProfile(');
|
|
925
|
+
expect(content).not.toContain('async getProfileAndToken(');
|
|
926
|
+
expect(content).not.toContain('getAuthorizationUrl(');
|
|
927
|
+
} finally {
|
|
928
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
|
|
469
933
|
describe('resolveResourceClassName', () => {
|
|
470
934
|
it('uses overlay name when baseline has compatible constructor', () => {
|
|
471
935
|
const service: Service = { name: 'Organizations', operations: [] };
|