@workos/oagen-emitters 0.18.3 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-1ckLMpgo.mjs → plugin-BXDPA9pJ.mjs} +581 -172
- package/dist/plugin-BXDPA9pJ.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +2 -2
- package/package.json +5 -5
- package/src/dotnet/enums.ts +11 -5
- package/src/dotnet/fixtures.ts +5 -2
- package/src/dotnet/index.ts +2 -1
- package/src/dotnet/models.ts +41 -10
- package/src/dotnet/naming.ts +10 -0
- package/src/dotnet/resources.ts +3 -3
- package/src/dotnet/tests.ts +8 -4
- package/src/go/fixtures.ts +4 -2
- package/src/go/index.ts +4 -0
- package/src/go/models.ts +4 -2
- package/src/go/naming.ts +10 -0
- package/src/go/resources.ts +22 -9
- package/src/go/tests.ts +3 -3
- package/src/kotlin/enums.ts +21 -11
- package/src/kotlin/index.ts +2 -1
- package/src/kotlin/models.ts +24 -9
- package/src/kotlin/naming.ts +11 -0
- package/src/kotlin/resources.ts +2 -2
- package/src/kotlin/tests.ts +7 -3
- package/src/node/enums.ts +8 -5
- package/src/node/field-plan.ts +3 -3
- package/src/node/index.ts +2 -1
- package/src/node/models.ts +69 -22
- package/src/node/naming.ts +10 -0
- package/src/node/options.ts +45 -1
- package/src/node/resources.ts +67 -18
- package/src/node/tests.ts +302 -31
- package/src/php/enums.ts +18 -5
- package/src/php/index.ts +13 -4
- package/src/php/models.ts +22 -10
- package/src/php/naming.ts +10 -0
- package/src/php/resources.ts +6 -4
- package/src/php/tests.ts +17 -5
- package/src/python/enums.ts +39 -28
- package/src/python/fixtures.ts +4 -3
- package/src/python/index.ts +2 -1
- package/src/python/models.ts +39 -24
- package/src/python/naming.ts +10 -0
- package/src/python/resources.ts +3 -3
- package/src/python/tests.ts +14 -9
- package/src/ruby/enums.ts +28 -19
- package/src/ruby/index.ts +2 -1
- package/src/ruby/models.ts +33 -19
- package/src/ruby/naming.ts +10 -0
- package/src/ruby/rbi.ts +20 -7
- package/src/ruby/resources.ts +2 -2
- package/src/ruby/tests.ts +6 -3
- package/src/rust/enums.ts +9 -1
- package/src/rust/index.ts +2 -1
- package/src/rust/models.ts +100 -15
- package/src/rust/naming.ts +10 -0
- package/src/rust/resources.ts +14 -3
- package/src/rust/tests.ts +2 -2
- package/src/shared/file-header.ts +13 -0
- package/src/shared/resolved-ops.ts +47 -0
- package/test/rust/models.test.ts +49 -0
- package/test/shared/synthetic-enum-seed.test.ts +79 -0
- package/dist/plugin-1ckLMpgo.mjs.map +0 -1
package/src/kotlin/resources.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
import {
|
|
35
35
|
buildResolvedLookup,
|
|
36
36
|
lookupResolved,
|
|
37
|
-
|
|
37
|
+
scopedMountGroups,
|
|
38
38
|
buildHiddenParams,
|
|
39
39
|
getOpDefaults,
|
|
40
40
|
getOpInferFromClient,
|
|
@@ -97,7 +97,7 @@ function promoteFieldType(f: Field): Field {
|
|
|
97
97
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
98
98
|
if (services.length === 0) return [];
|
|
99
99
|
|
|
100
|
-
const mountGroups =
|
|
100
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
101
101
|
if (mountGroups.size === 0) return [];
|
|
102
102
|
|
|
103
103
|
const files: GeneratedFile[] = [];
|
package/src/kotlin/tests.ts
CHANGED
|
@@ -17,11 +17,12 @@ import {
|
|
|
17
17
|
ktStringLiteral,
|
|
18
18
|
className,
|
|
19
19
|
propertyName,
|
|
20
|
+
domainPropertyName,
|
|
20
21
|
buildExportedClassNameSet,
|
|
21
22
|
} from './naming.js';
|
|
22
23
|
import { mapTypeRef } from './type-map.js';
|
|
23
24
|
import {
|
|
24
|
-
|
|
25
|
+
scopedMountGroups,
|
|
25
26
|
lookupResolved,
|
|
26
27
|
buildResolvedLookup,
|
|
27
28
|
buildHiddenParams,
|
|
@@ -75,7 +76,7 @@ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined):
|
|
|
75
76
|
*/
|
|
76
77
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
77
78
|
const files: GeneratedFile[] = [];
|
|
78
|
-
const mountGroups =
|
|
79
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
79
80
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
80
81
|
|
|
81
82
|
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
@@ -713,7 +714,10 @@ function buildResponseAssertions(
|
|
|
713
714
|
for (const field of model.fields) {
|
|
714
715
|
if (!field.required) continue;
|
|
715
716
|
if (assertions.length >= MAX_RESPONSE_ASSERTIONS) break;
|
|
716
|
-
|
|
717
|
+
// DOMAIN identifier: the property accessor on the deserialized model.
|
|
718
|
+
// Honors a `domainName` override; the synthesized JSON above keys off
|
|
719
|
+
// `field.name` (the wire key).
|
|
720
|
+
const ktProp = domainPropertyName(field);
|
|
717
721
|
const type = field.type;
|
|
718
722
|
if (type.kind === 'primitive') {
|
|
719
723
|
if (type.format === 'date-time') continue;
|
package/src/node/enums.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { docComment, assignModelsToEmittableServices } from './utils.js';
|
|
|
5
5
|
import { isInlineEnum } from './type-map.js';
|
|
6
6
|
import { isNodeOwnedService } from './options.js';
|
|
7
7
|
import { liveSurfaceConstEnumMembers, liveSurfaceInterfacePath } from './live-surface.js';
|
|
8
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
8
9
|
|
|
9
10
|
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
10
11
|
if (enums.length === 0) return [];
|
|
@@ -120,11 +121,13 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
120
121
|
lines.push(` (typeof ${enumDef.name})[keyof typeof ${enumDef.name}];`);
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
if (isEnumInScope(enumDef.name, ctx)) {
|
|
125
|
+
files.push({
|
|
126
|
+
path: `src/${dirName}/interfaces/${fileName(enumDef.name)}.interface.ts`,
|
|
127
|
+
content: lines.join('\n'),
|
|
128
|
+
skipIfExists: !hasNewValues,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
return files;
|
package/src/node/field-plan.ts
CHANGED
|
@@ -388,7 +388,7 @@ export function serializerHasBaselineIncompatibility(
|
|
|
388
388
|
const irDomainFields = new Set<string>();
|
|
389
389
|
for (const field of model.fields) {
|
|
390
390
|
irWireFields.add(wireFieldName(field.name));
|
|
391
|
-
irDomainFields.add(fieldName(field.name));
|
|
391
|
+
irDomainFields.add(fieldName(field.domainName ?? field.name));
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
for (const [wireField2, fieldDef] of Object.entries(baselineResponse.fields)) {
|
|
@@ -463,7 +463,7 @@ export function planDeserializeField(
|
|
|
463
463
|
skipFormatFields: Set<string>,
|
|
464
464
|
ctx: EmitterContext,
|
|
465
465
|
): { line: string; skip: boolean } {
|
|
466
|
-
const domain = fieldName(field.name);
|
|
466
|
+
const domain = fieldName(field.domainName ?? field.name);
|
|
467
467
|
const wire = wireFieldName(field.name);
|
|
468
468
|
const wireAccess = `response.${wire}`;
|
|
469
469
|
const skip = skipFormatFields.has(field.name);
|
|
@@ -543,7 +543,7 @@ export function planSerializeField(
|
|
|
543
543
|
ctx: EmitterContext,
|
|
544
544
|
): { line: string; skip: boolean } {
|
|
545
545
|
const wire = wireFieldName(field.name);
|
|
546
|
-
const domain = fieldName(field.name);
|
|
546
|
+
const domain = fieldName(field.domainName ?? field.name);
|
|
547
547
|
const domainAccess = `model.${domain}`;
|
|
548
548
|
const skip = skipFormatFields.has(field.name);
|
|
549
549
|
|
package/src/node/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { withNodeOperationOverrides } from './node-overrides.js';
|
|
|
40
40
|
import { isNodeOwnedService, nodeOptions } from './options.js';
|
|
41
41
|
import { setInlineEnumUnions, setDomainNameResolver } from './type-map.js';
|
|
42
42
|
import { groupByMount } from '../shared/resolved-ops.js';
|
|
43
|
+
import { AUTOGEN_NOTICE } from '../shared/file-header.js';
|
|
43
44
|
import { assignModelsToServices, createServiceDirResolver, relativeImport } from './utils.js';
|
|
44
45
|
import { fileName } from './naming.js';
|
|
45
46
|
|
|
@@ -869,7 +870,7 @@ export const nodeEmitter: Emitter = {
|
|
|
869
870
|
},
|
|
870
871
|
|
|
871
872
|
fileHeader(): string {
|
|
872
|
-
return
|
|
873
|
+
return `// ${AUTOGEN_NOTICE}`;
|
|
873
874
|
},
|
|
874
875
|
|
|
875
876
|
formatCommand(targetDir: string): FormatCommand | null {
|
package/src/node/models.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
|
|
46
46
|
import { isNodeOwnedService, isHandOwnedType } from './options.js';
|
|
47
47
|
import { unwrapListModel } from './fixtures.js';
|
|
48
|
-
import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
48
|
+
import { groupByMount, buildResolvedLookup, lookupResolved, isModelInScope } from '../shared/resolved-ops.js';
|
|
49
49
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
50
50
|
import { collectWrapperResponseModels } from './wrappers.js';
|
|
51
51
|
import { resolveResourceClassName } from './resources.js';
|
|
@@ -308,11 +308,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
308
308
|
const aliasLines = importSymbols
|
|
309
309
|
? [`import type { ${importSymbols} } from '${canonRelPath}';`, '', ...aliasExports]
|
|
310
310
|
: [...aliasExports];
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
311
|
+
if (isModelInScope(model.name, ctx)) {
|
|
312
|
+
files.push({
|
|
313
|
+
path: aliasPath,
|
|
314
|
+
content: aliasLines.join('\n'),
|
|
315
|
+
overwriteExisting: true,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
316
318
|
continue;
|
|
317
319
|
}
|
|
318
320
|
|
|
@@ -527,7 +529,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
527
529
|
} else {
|
|
528
530
|
lines.push(`export interface ${domainName}${typeParams} {`);
|
|
529
531
|
for (const field of model.fields) {
|
|
530
|
-
|
|
532
|
+
// Domain identifier honors a `fieldHints` override (e.g. connection_type
|
|
533
|
+
// → type); the wire name below still derives from `field.name`.
|
|
534
|
+
const domainFieldName = fieldName(field.domainName ?? field.name);
|
|
531
535
|
if (seenDomainFields.has(domainFieldName)) continue;
|
|
532
536
|
seenDomainFields.add(domainFieldName);
|
|
533
537
|
if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
|
|
@@ -546,6 +550,19 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
546
550
|
baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
|
|
547
551
|
const readonlyPrefix = field.readOnly ? 'readonly ' : '';
|
|
548
552
|
if (
|
|
553
|
+
genericDefaults.has(model.name) &&
|
|
554
|
+
baselineField &&
|
|
555
|
+
typeReferencesUnresolvable(baselineField.type, unresolvableNames)
|
|
556
|
+
) {
|
|
557
|
+
// Baseline typed this field with the model's generic param (e.g.
|
|
558
|
+
// `customAttributes?: CustomAttributesType`). The emitted interface
|
|
559
|
+
// renames the param to `GenericType`; remap the field so the param is
|
|
560
|
+
// actually used — otherwise it trips TS6133 (declared, never read).
|
|
561
|
+
const opt = baselineField.optional ? '?' : '';
|
|
562
|
+
lines.push(
|
|
563
|
+
` ${readonlyPrefix}${domainFieldName}${opt}: ${substituteGenericParam(baselineField.type, unresolvableNames)};`,
|
|
564
|
+
);
|
|
565
|
+
} else if (
|
|
549
566
|
baselineField &&
|
|
550
567
|
!domainResponseOptionalMismatch &&
|
|
551
568
|
!hasDateTimeConversion(field.type) &&
|
|
@@ -597,6 +614,15 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
597
614
|
seenWireFields.add(wireField);
|
|
598
615
|
const baselineField = baselineResponse?.fields?.[wireField];
|
|
599
616
|
if (
|
|
617
|
+
genericDefaults.has(model.name) &&
|
|
618
|
+
baselineField &&
|
|
619
|
+
typeReferencesUnresolvable(baselineField.type, unresolvableNames)
|
|
620
|
+
) {
|
|
621
|
+
// Mirror the domain side: keep the generic param wired on the
|
|
622
|
+
// response interface so `GenericType` is used, not orphaned.
|
|
623
|
+
const opt = baselineField.optional ? '?' : '';
|
|
624
|
+
lines.push(` ${wireField}${opt}: ${substituteGenericParam(baselineField.type, unresolvableNames)};`);
|
|
625
|
+
} else if (
|
|
600
626
|
baselineField &&
|
|
601
627
|
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
602
628
|
baselineFieldCompatible(baselineField, field)
|
|
@@ -721,11 +747,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
721
747
|
}
|
|
722
748
|
}
|
|
723
749
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
750
|
+
if (isModelInScope(model.name, ctx)) {
|
|
751
|
+
files.push({
|
|
752
|
+
path: filePath,
|
|
753
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
754
|
+
overwriteExisting: true,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
729
757
|
}
|
|
730
758
|
|
|
731
759
|
return files;
|
|
@@ -919,11 +947,13 @@ export function generateSerializers(
|
|
|
919
947
|
parts.push(`serialize${canonDomainName} as serialize${domainName}`);
|
|
920
948
|
}
|
|
921
949
|
const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
950
|
+
if (isModelInScope(model.name, ctx)) {
|
|
951
|
+
files.push({
|
|
952
|
+
path: serializerPath,
|
|
953
|
+
content: reexportContent,
|
|
954
|
+
overwriteExisting: true,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
927
957
|
continue;
|
|
928
958
|
}
|
|
929
959
|
// The alias is response-reachable, but the canonical model is
|
|
@@ -973,11 +1003,13 @@ export function generateSerializers(
|
|
|
973
1003
|
),
|
|
974
1004
|
];
|
|
975
1005
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1006
|
+
if (isModelInScope(model.name, ctx)) {
|
|
1007
|
+
files.push({
|
|
1008
|
+
path: serializerPath,
|
|
1009
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
1010
|
+
overwriteExisting: true,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
981
1013
|
}
|
|
982
1014
|
|
|
983
1015
|
(ctx as any)._skippedSerializeModels = skippedSerializeModels;
|
|
@@ -1301,6 +1333,21 @@ function hasSpecificIRType(ref: TypeRef): boolean {
|
|
|
1301
1333
|
}
|
|
1302
1334
|
}
|
|
1303
1335
|
|
|
1336
|
+
/** True when a baseline field type references one of the model's generic
|
|
1337
|
+
* param identifiers (collected as `unresolvableNames` — bare type names that
|
|
1338
|
+
* resolve to nothing importable, which for a generic model are its params). */
|
|
1339
|
+
function typeReferencesUnresolvable(type: string, unresolvable: Set<string>): boolean {
|
|
1340
|
+
if (unresolvable.size === 0) return false;
|
|
1341
|
+
const names = type.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
|
|
1342
|
+
return !!names && names.some((n) => unresolvable.has(n));
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Rewrite a baseline field type so references to the model's (renamed)
|
|
1346
|
+
* generic param resolve to the emitted `GenericType` param. */
|
|
1347
|
+
function substituteGenericParam(type: string, unresolvable: Set<string>): string {
|
|
1348
|
+
return type.replace(/\b[A-Z][a-zA-Z0-9]*\b/g, (name) => (unresolvable.has(name) ? 'GenericType' : name));
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1304
1351
|
function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
|
|
1305
1352
|
if (!model.typeParams?.length) {
|
|
1306
1353
|
if (genericDefaults?.has(model.name)) {
|
package/src/node/naming.ts
CHANGED
|
@@ -32,6 +32,16 @@ export function fieldName(name: string): string {
|
|
|
32
32
|
return toCamelCase(name);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* camelCase domain field name for a model field, honoring a `domainName`
|
|
37
|
+
* override (set via the `fieldHints` config) so a wire field can surface under
|
|
38
|
+
* a friendlier name. The wire name (see {@link wireFieldName}) still derives
|
|
39
|
+
* from `field.name`.
|
|
40
|
+
*/
|
|
41
|
+
export function domainFieldName(field: { name: string; domainName?: string }): string {
|
|
42
|
+
return toCamelCase(field.domainName ?? field.name);
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
/** snake_case field name for wire/response interfaces. */
|
|
36
46
|
export function wireFieldName(name: string): string {
|
|
37
47
|
return toSnakeCase(name);
|
package/src/node/options.ts
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
import type { EmitterContext } from '@workos/oagen';
|
|
1
|
+
import type { EmitterContext, Operation, OperationPlan } from '@workos/oagen';
|
|
2
|
+
import { planOperation } from '@workos/oagen';
|
|
2
3
|
|
|
3
4
|
export interface OperationOverride {
|
|
4
5
|
methodName?: string;
|
|
5
6
|
mountOn?: string;
|
|
6
7
|
optionsType?: string;
|
|
7
8
|
bodyFieldMap?: Record<string, string>;
|
|
9
|
+
/**
|
|
10
|
+
* Rename spec path parameters to the SDK options-object field they should be
|
|
11
|
+
* exposed as. Keys are the camelCase identifier the param resolves to via
|
|
12
|
+
* `fieldName(param.name)` — NOT the raw (possibly snake_case) spec key — since
|
|
13
|
+
* the lookup is keyed on that camelCase form (e.g. `{ resourceId: 'targetId' }`,
|
|
14
|
+
* even for a `resource_id` spec param). Applied to the destructure, the URL
|
|
15
|
+
* template binding, and generated tests so a published SDK field name can
|
|
16
|
+
* diverge from the spec path-param name without a global spec rewrite (which
|
|
17
|
+
* would ripple across every language).
|
|
18
|
+
*/
|
|
19
|
+
pathFieldMap?: Record<string, string>;
|
|
8
20
|
returnType?: string;
|
|
9
21
|
returnDataProperty?: string;
|
|
10
22
|
returnTypeImports?: string[];
|
|
11
23
|
returnExpression?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Override the response model the operation deserializes, by model name.
|
|
26
|
+
* Replaces the spec-derived response model so the resource (and its test)
|
|
27
|
+
* reference a different wire type / deserializer — e.g. mapping
|
|
28
|
+
* Authorization's role responses to the full `OrganizationRole` instead of
|
|
29
|
+
* the slim `Role`/`RoleResponse` shape shared with SSO and UserManagement.
|
|
30
|
+
* Node-only; never affects the global spec or other SDKs.
|
|
31
|
+
*/
|
|
32
|
+
responseModel?: string;
|
|
12
33
|
}
|
|
13
34
|
|
|
14
35
|
export interface NodeEmitterOptions {
|
|
@@ -90,3 +111,26 @@ export function isHandOwnedType(ctx: EmitterContext, name: string | undefined):
|
|
|
90
111
|
if (!configured || configured.length === 0) return false;
|
|
91
112
|
return configured.includes(name);
|
|
92
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the Node operation override for an operation, keyed by "METHOD /path".
|
|
117
|
+
*/
|
|
118
|
+
export function operationOverrideFor(ctx: EmitterContext, op: Operation): OperationOverride | undefined {
|
|
119
|
+
return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `planOperation` plus the Node `responseModel` override. When an operation
|
|
124
|
+
* override supplies `responseModel`, the resolved response model name is
|
|
125
|
+
* replaced so the resource and its generated test reference the desired wire
|
|
126
|
+
* type and deserializer. Use this everywhere the Node emitter would otherwise
|
|
127
|
+
* call `planOperation(op)` directly so resource and test stay in lockstep.
|
|
128
|
+
*/
|
|
129
|
+
export function planOperationFor(op: Operation, ctx: EmitterContext): OperationPlan {
|
|
130
|
+
const plan = planOperation(op);
|
|
131
|
+
const responseModel = operationOverrideFor(ctx, op)?.responseModel;
|
|
132
|
+
if (responseModel && responseModel !== plan.responseModelName) {
|
|
133
|
+
return { ...plan, responseModelName: responseModel, isModelResponse: true };
|
|
134
|
+
}
|
|
135
|
+
return plan;
|
|
136
|
+
}
|
package/src/node/resources.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
Model,
|
|
12
12
|
ResolvedOperation,
|
|
13
13
|
} from '@workos/oagen';
|
|
14
|
-
import {
|
|
14
|
+
import { toPascalCase, toCamelCase } from '@workos/oagen';
|
|
15
15
|
import type { OperationPlan } from '@workos/oagen';
|
|
16
16
|
import { mapTypeRef, isInlineEnum } from './type-map.js';
|
|
17
17
|
import {
|
|
@@ -78,13 +78,14 @@ import {
|
|
|
78
78
|
buildResolvedLookup,
|
|
79
79
|
lookupResolved,
|
|
80
80
|
groupByMount,
|
|
81
|
+
isMountInScope,
|
|
81
82
|
getOpDefaults,
|
|
82
83
|
getOpInferFromClient,
|
|
83
84
|
} from '../shared/resolved-ops.js';
|
|
84
85
|
import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
|
|
85
86
|
import { buildNodePathExpression } from './path-expression.js';
|
|
86
87
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
87
|
-
import { isNodeOwnedService,
|
|
88
|
+
import { isNodeOwnedService, operationOverrideFor, planOperationFor } from './options.js';
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
91
|
* Check whether the baseline (hand-written) class has a constructor compatible
|
|
@@ -202,16 +203,12 @@ function existingInterfaceBarrelExports(ctx: EmitterContext, serviceDir: string,
|
|
|
202
203
|
return new RegExp(`export\\s+(?:type\\s+)?(?:\\*|\\{[^}]+\\})\\s+from\\s+['"]\\./${escapedStem}['"]`).test(content);
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
function operationOverrideFor(ctx: EmitterContext, op: Operation) {
|
|
206
|
-
return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
|
|
207
|
-
}
|
|
208
|
-
|
|
209
206
|
function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
|
|
210
207
|
const serviceClass = resolveResourceClassName(service, ctx);
|
|
211
208
|
return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
|
|
212
209
|
}
|
|
213
210
|
|
|
214
|
-
function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string): Set<string> {
|
|
211
|
+
export function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string): Set<string> {
|
|
215
212
|
const root = ctx.outputDir ?? ctx.targetDir;
|
|
216
213
|
if (!root) return new Set();
|
|
217
214
|
|
|
@@ -242,8 +239,13 @@ function optionsObjectParam(method: BaselineMethod | undefined): OptionsObjectPa
|
|
|
242
239
|
const [param] = method.params;
|
|
243
240
|
if (param.name !== 'options') return undefined;
|
|
244
241
|
if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
|
|
245
|
-
|
|
246
|
-
|
|
242
|
+
// An optional param's surface type is `Options | undefined`; the optionality
|
|
243
|
+
// is carried by `param.optional`, so strip the nullable arm to recover the
|
|
244
|
+
// bare type NAME (used for `serialize${type}` + imports). Leaving it in emits
|
|
245
|
+
// `serializeOptions | undefined` — a syntax error.
|
|
246
|
+
const type = param.type?.replace(/(?:\s*\|\s*(?:undefined|null))+\s*$/, '').trim();
|
|
247
|
+
if (!type || /^(Record|object|any|unknown)\b/.test(type)) return undefined;
|
|
248
|
+
return { name: 'options', type, optional: param.optional === true, generated: false };
|
|
247
249
|
}
|
|
248
250
|
|
|
249
251
|
function methodOptionsName(method: string, resolvedServiceName: string): string {
|
|
@@ -568,7 +570,7 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
568
570
|
|
|
569
571
|
const plans = service.operations.map((op) => ({
|
|
570
572
|
op,
|
|
571
|
-
plan:
|
|
573
|
+
plan: planOperationFor(op, ctx),
|
|
572
574
|
method: resolveMethodName(op, service, ctx),
|
|
573
575
|
}));
|
|
574
576
|
|
|
@@ -577,7 +579,13 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
577
579
|
const baselineMethod = baselineMethodFor(service, method, ctx);
|
|
578
580
|
const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolvedOp);
|
|
579
581
|
if (!optionInfo?.generated) continue;
|
|
580
|
-
|
|
582
|
+
// A baseline type of the same name normally means "hand-owned / preserved —
|
|
583
|
+
// do not regenerate" (guards the alias-feedback loop). But when the op has
|
|
584
|
+
// path params, the modernized resource folds them INTO the options object
|
|
585
|
+
// (`const { organizationId, ...payload } = options`), so a body-only
|
|
586
|
+
// baseline interface (from a legacy `(pathParam, options)` signature) is
|
|
587
|
+
// definitionally incompatible. Regenerate it to include the path params.
|
|
588
|
+
if (op.pathParams.length === 0 && baselineTypeSourceFile(ctx, optionInfo.type)) continue;
|
|
581
589
|
|
|
582
590
|
const optionsName = optionInfo.type;
|
|
583
591
|
const optionFileStem = `${fileName(optionsName)}.interface`;
|
|
@@ -645,9 +653,10 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
|
|
|
645
653
|
headerParts.push(` ${name}${opt}: ${type};`);
|
|
646
654
|
};
|
|
647
655
|
|
|
656
|
+
const optionsPathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
648
657
|
for (const param of op.pathParams) {
|
|
649
658
|
pushField(
|
|
650
|
-
fieldName(param.name),
|
|
659
|
+
optionsPathFieldMap?.[fieldName(param.name)] ?? fieldName(param.name),
|
|
651
660
|
true,
|
|
652
661
|
mapParamType(param.type, specEnumNames),
|
|
653
662
|
param.description,
|
|
@@ -710,11 +719,18 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
710
719
|
// multiple IR services mount to the same resource class.
|
|
711
720
|
const mountGroups = groupByMount(ctx);
|
|
712
721
|
const mergedServices: Service[] =
|
|
713
|
-
mountGroups.size > 0
|
|
722
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
723
|
+
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
724
|
+
: services;
|
|
714
725
|
|
|
715
726
|
const topLevelEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
|
|
716
727
|
|
|
717
728
|
for (const service of mergedServices) {
|
|
729
|
+
// Scope gate: in a scoped (`--services`) run, only emit per-service resource
|
|
730
|
+
// files for the selected post-mount names. `service.name` is the mount-group
|
|
731
|
+
// key (the POST-MOUNT name that matches `ctx.scopedServices`). Applied as an
|
|
732
|
+
// additional early continue ahead of the node-owned/coverage skip logic.
|
|
733
|
+
if (!isMountInScope(service.name, ctx)) continue;
|
|
718
734
|
const isOwnedService = isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx));
|
|
719
735
|
if (!isOwnedService && isServiceCoveredByExisting(service, ctx)) {
|
|
720
736
|
if (!hasMethodsAbsentFromBaseline(service, ctx)) {
|
|
@@ -762,6 +778,9 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
762
778
|
// stable. Placing them under `interfaces/` lets the per-service barrel
|
|
763
779
|
// pick them up automatically.
|
|
764
780
|
for (const service of mergedServices) {
|
|
781
|
+
// Scope gate: keep the per-service options interfaces aligned with the
|
|
782
|
+
// resource files emitted above — only the selected post-mount names.
|
|
783
|
+
if (!isMountInScope(service.name, ctx)) continue;
|
|
765
784
|
const isOwnedService = isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx));
|
|
766
785
|
if (!isOwnedService && isServiceCoveredByExisting(service, ctx) && !hasMethodsAbsentFromBaseline(service, ctx))
|
|
767
786
|
continue;
|
|
@@ -779,7 +798,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
779
798
|
|
|
780
799
|
let plans = service.operations.map((op) => ({
|
|
781
800
|
op,
|
|
782
|
-
plan:
|
|
801
|
+
plan: planOperationFor(op, ctx),
|
|
783
802
|
method: resolveMethodName(op, service, ctx),
|
|
784
803
|
}));
|
|
785
804
|
|
|
@@ -1038,6 +1057,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
1038
1057
|
}
|
|
1039
1058
|
|
|
1040
1059
|
const importedTypeNames = new Set<string>();
|
|
1060
|
+
// `PaginationOptions` is already imported once above when any method
|
|
1061
|
+
// paginates; a method whose options type IS `PaginationOptions` would
|
|
1062
|
+
// otherwise re-import it here (TS2300 duplicate identifier).
|
|
1063
|
+
if (needsPaginationOptionsImport) importedTypeNames.add('PaginationOptions');
|
|
1041
1064
|
for (const optionType of optionObjectTypes) {
|
|
1042
1065
|
if (isValidTypeIdentifier(optionType)) {
|
|
1043
1066
|
if (importedTypeNames.has(optionType)) continue;
|
|
@@ -1954,7 +1977,20 @@ function renderOptionsObjectMethod(
|
|
|
1954
1977
|
return true;
|
|
1955
1978
|
}
|
|
1956
1979
|
|
|
1957
|
-
|
|
1980
|
+
// Body-less, response-less mutation with path params and/or query folded into
|
|
1981
|
+
// the options object (e.g. POST /feature-flags/{slug}/targets/{targetId} -> 204).
|
|
1982
|
+
// The DELETE equivalent is handled above; this covers POST/PUT/PATCH (and any
|
|
1983
|
+
// other verb) that returns no content. Without this branch such operations
|
|
1984
|
+
// fall through to the positional renderVoidMethod, which is inconsistent with
|
|
1985
|
+
// the options-object surface the rest of an owned service uses.
|
|
1986
|
+
{
|
|
1987
|
+
lines.push(` async ${method}(${renderOptionsParam(optionParam)}): Promise<void> {`);
|
|
1988
|
+
renderOptionsObjectDestructure(lines, pathBindings);
|
|
1989
|
+
const emptyBodyArg = httpMethodNeedsBody(op.httpMethod) ? ', {}' : '';
|
|
1990
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}${emptyBodyArg}${queryOptionsArg});`);
|
|
1991
|
+
lines.push(' }');
|
|
1992
|
+
return true;
|
|
1993
|
+
}
|
|
1958
1994
|
}
|
|
1959
1995
|
|
|
1960
1996
|
function renderOptionsObjectDestructure(lines: string[], pathBindings: string[], restName?: string): void {
|
|
@@ -1988,7 +2024,8 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
|
|
|
1988
2024
|
// Return resolved SDK field names directly — the URL template uses these
|
|
1989
2025
|
// names too (via the param-name map threaded into buildNodePathExpression),
|
|
1990
2026
|
// so the destructure no longer needs `optionField: localName` renames.
|
|
1991
|
-
|
|
2027
|
+
const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
2028
|
+
return op.pathParams.map((param) => resolveOptionsObjectField(fieldName(param.name), optionType, ctx, pathFieldMap));
|
|
1992
2029
|
}
|
|
1993
2030
|
|
|
1994
2031
|
/**
|
|
@@ -2000,15 +2037,27 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
|
|
|
2000
2037
|
*/
|
|
2001
2038
|
function buildOptionsObjectPathParamMap(op: Operation, optionType: string, ctx: EmitterContext): Map<string, string> {
|
|
2002
2039
|
const map = new Map<string, string>();
|
|
2040
|
+
const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
|
|
2003
2041
|
for (const param of op.pathParams) {
|
|
2004
2042
|
const localName = fieldName(param.name);
|
|
2005
|
-
const sdkField = resolveOptionsObjectField(localName, optionType, ctx);
|
|
2043
|
+
const sdkField = resolveOptionsObjectField(localName, optionType, ctx, pathFieldMap);
|
|
2006
2044
|
if (sdkField !== localName) map.set(param.name, sdkField);
|
|
2007
2045
|
}
|
|
2008
2046
|
return map;
|
|
2009
2047
|
}
|
|
2010
2048
|
|
|
2011
|
-
function resolveOptionsObjectField(
|
|
2049
|
+
function resolveOptionsObjectField(
|
|
2050
|
+
localName: string,
|
|
2051
|
+
optionType: string,
|
|
2052
|
+
ctx: EmitterContext,
|
|
2053
|
+
pathFieldMap?: Record<string, string>,
|
|
2054
|
+
): string {
|
|
2055
|
+
// Operation-override rename (Node-scoped) wins unconditionally: an explicit
|
|
2056
|
+
// pathFieldMap is honored even when the (freshly generated) options interface
|
|
2057
|
+
// isn't in the baseline surface yet — generateOptionsInterfaces applies the
|
|
2058
|
+
// same map to the emitted field, so destructure and interface stay in lockstep.
|
|
2059
|
+
const mapped = pathFieldMap?.[localName];
|
|
2060
|
+
if (mapped) return mapped;
|
|
2012
2061
|
const fields = ctx.apiSurface?.interfaces?.[optionType]?.fields;
|
|
2013
2062
|
if (!fields) return localName;
|
|
2014
2063
|
if (fields[localName]) return localName;
|