@workos/oagen-emitters 0.2.0 → 0.2.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/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +633 -85
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +21 -4
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/node/client.ts +94 -12
- package/src/node/common.ts +1 -1
- package/src/node/enums.ts +4 -4
- package/src/node/errors.ts +5 -1
- package/src/node/fixtures.ts +6 -4
- package/src/node/index.ts +65 -9
- package/src/node/models.ts +86 -75
- package/src/node/naming.ts +91 -2
- package/src/node/resources.ts +462 -23
- package/src/node/serializers.ts +3 -1
- package/src/node/tests.ts +39 -15
- package/src/node/utils.ts +52 -2
- package/test/node/client.test.ts +181 -82
- package/test/node/enums.test.ts +73 -3
- package/test/node/models.test.ts +107 -20
- package/test/node/naming.test.ts +14 -4
- package/test/node/resources.test.ts +627 -25
- package/test/node/serializers.test.ts +33 -6
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import { assignModelsToServices, assignModelsToServices as assignModelsToServices$1, collectFieldDependencies, mapTypeRef, planOperation, toCamelCase, toKebabCase, toPascalCase, toSnakeCase, walkTypeRef } from "@workos/oagen";
|
|
2
4
|
//#region src/node/naming.ts
|
|
3
5
|
/** kebab-case file name (without extension). */
|
|
@@ -45,9 +47,84 @@ function buildServiceNameMap(services, ctx) {
|
|
|
45
47
|
for (const service of services) map.set(service.name, resolveServiceName(service, ctx));
|
|
46
48
|
return map;
|
|
47
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Explicit method name overrides for operations where the spec's operationId
|
|
52
|
+
* does not match the desired SDK method name and the spec cannot be changed.
|
|
53
|
+
* Key: "HTTP_METHOD /path", Value: camelCase method name.
|
|
54
|
+
*/
|
|
55
|
+
const METHOD_NAME_OVERRIDES = { "POST /portal/generate_link": "generatePortalLink" };
|
|
56
|
+
/**
|
|
57
|
+
* Explicit service directory overrides. Maps a resolved PascalCase service name
|
|
58
|
+
* to a target directory (kebab-case). Use this when the spec's tag grouping
|
|
59
|
+
* does not match the desired SDK directory layout and the spec cannot be changed.
|
|
60
|
+
*/
|
|
61
|
+
const SERVICE_DIR_OVERRIDES = {
|
|
62
|
+
ApplicationClientSecrets: "workos-connect",
|
|
63
|
+
Applications: "workos-connect",
|
|
64
|
+
Connections: "sso",
|
|
65
|
+
Directories: "directory-sync",
|
|
66
|
+
DirectoryGroups: "directory-sync",
|
|
67
|
+
DirectoryUsers: "directory-sync",
|
|
68
|
+
FeatureFlagsTargets: "feature-flags",
|
|
69
|
+
MultiFactorAuth: "mfa",
|
|
70
|
+
MultiFactorAuthChallenges: "mfa",
|
|
71
|
+
OrganizationsApiKeys: "organizations",
|
|
72
|
+
WebhooksEndpoints: "webhooks",
|
|
73
|
+
UserManagementAuthentication: "user-management",
|
|
74
|
+
UserManagementCorsOrigins: "user-management",
|
|
75
|
+
UserManagementDataProviders: "user-management",
|
|
76
|
+
UserManagementInvitations: "user-management",
|
|
77
|
+
UserManagementJWTTemplate: "user-management",
|
|
78
|
+
UserManagementMagicAuth: "user-management",
|
|
79
|
+
UserManagementMultiFactorAuthentication: "user-management",
|
|
80
|
+
UserManagementOrganizationMembership: "user-management",
|
|
81
|
+
UserManagementRedirectUris: "user-management",
|
|
82
|
+
UserManagementSessionTokens: "user-management",
|
|
83
|
+
UserManagementUsers: "user-management",
|
|
84
|
+
UserManagementUsersAuthorizedApplications: "user-management",
|
|
85
|
+
WorkOSConnect: "workos-connect"
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Maps a service (by PascalCase name) to the existing hand-written class that
|
|
89
|
+
* already covers its endpoints. When a service appears here:
|
|
90
|
+
* - `resolveClassName` returns the target class (so generated code merges in)
|
|
91
|
+
* - `isServiceCoveredByExisting` returns true
|
|
92
|
+
* - `hasMethodsAbsentFromBaseline` checks the target class for missing methods,
|
|
93
|
+
* so new endpoints are added to the existing class rather than silently dropped
|
|
94
|
+
*/
|
|
95
|
+
const SERVICE_COVERED_BY = {
|
|
96
|
+
Connections: "SSO",
|
|
97
|
+
Directories: "DirectorySync",
|
|
98
|
+
DirectoryGroups: "DirectorySync",
|
|
99
|
+
DirectoryUsers: "DirectorySync",
|
|
100
|
+
FeatureFlagsTargets: "FeatureFlags",
|
|
101
|
+
MultiFactorAuth: "Mfa",
|
|
102
|
+
MultiFactorAuthChallenges: "Mfa",
|
|
103
|
+
OrganizationsApiKeys: "Organizations",
|
|
104
|
+
UserManagementAuthentication: "UserManagement",
|
|
105
|
+
UserManagementInvitations: "UserManagement",
|
|
106
|
+
UserManagementMagicAuth: "UserManagement",
|
|
107
|
+
UserManagementMultiFactorAuthentication: "UserManagement",
|
|
108
|
+
UserManagementOrganizationMembership: "UserManagement",
|
|
109
|
+
UserManagementUsers: "UserManagement"
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Explicit class name overrides. Maps the default PascalCase service name
|
|
113
|
+
* to the desired SDK class name when toPascalCase produces the wrong casing.
|
|
114
|
+
*/
|
|
115
|
+
const CLASS_NAME_OVERRIDES = { WorkosConnect: "WorkOSConnect" };
|
|
116
|
+
/**
|
|
117
|
+
* Resolve the output directory for a service, checking overrides first.
|
|
118
|
+
* Falls back to the standard kebab-case conversion.
|
|
119
|
+
*/
|
|
120
|
+
function resolveServiceDir(resolvedServiceName) {
|
|
121
|
+
return SERVICE_DIR_OVERRIDES[resolvedServiceName] ?? serviceDirName(resolvedServiceName);
|
|
122
|
+
}
|
|
48
123
|
/** Resolve the SDK method name for an operation, checking overlay first. */
|
|
49
124
|
function resolveMethodName(op, _service, ctx) {
|
|
50
125
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
126
|
+
const override = METHOD_NAME_OVERRIDES[httpKey];
|
|
127
|
+
if (override) return override;
|
|
51
128
|
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
52
129
|
if (existing) {
|
|
53
130
|
if (/\/\{[^}]+\}$/.test(op.path) && existing.methodName.endsWith("s") && !existing.methodName.endsWith("ss")) {
|
|
@@ -61,12 +138,15 @@ function resolveMethodName(op, _service, ctx) {
|
|
|
61
138
|
}
|
|
62
139
|
/** Resolve the SDK class name for a service, checking overlay for existing names. */
|
|
63
140
|
function resolveClassName(service, ctx) {
|
|
141
|
+
const coveredBy = SERVICE_COVERED_BY[toPascalCase(service.name)];
|
|
142
|
+
if (coveredBy) return coveredBy;
|
|
64
143
|
if (ctx.overlayLookup?.methodByOperation) for (const op of service.operations) {
|
|
65
144
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
66
145
|
const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
|
|
67
|
-
if (existing) return existing.className;
|
|
146
|
+
if (existing) return CLASS_NAME_OVERRIDES[existing.className] ?? existing.className;
|
|
68
147
|
}
|
|
69
|
-
|
|
148
|
+
const defaultName = toPascalCase(service.name);
|
|
149
|
+
return CLASS_NAME_OVERRIDES[defaultName] ?? defaultName;
|
|
70
150
|
}
|
|
71
151
|
/** Resolve the interface name for a model, checking overlay first. */
|
|
72
152
|
function resolveInterfaceName(name, ctx) {
|
|
@@ -330,7 +410,7 @@ function buildKnownTypeNames(models, ctx) {
|
|
|
330
410
|
function createServiceDirResolver(models, services, ctx) {
|
|
331
411
|
const modelToService = assignModelsToServices(models, services);
|
|
332
412
|
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
333
|
-
const resolveDir = (irService) => irService ?
|
|
413
|
+
const resolveDir = (irService) => irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : "common";
|
|
334
414
|
return {
|
|
335
415
|
modelToService,
|
|
336
416
|
serviceNameMap,
|
|
@@ -458,6 +538,7 @@ function buildDeduplicationMap(models, ctx) {
|
|
|
458
538
|
* endpoints (e.g., `GET /connections`).
|
|
459
539
|
*/
|
|
460
540
|
function isServiceCoveredByExisting(service, ctx) {
|
|
541
|
+
if (SERVICE_COVERED_BY[toPascalCase(service.name)]) return true;
|
|
461
542
|
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
462
543
|
if (!overlay || overlay.size === 0) return false;
|
|
463
544
|
if (service.operations.length === 0) return false;
|
|
@@ -472,6 +553,37 @@ function isServiceCoveredByExisting(service, ctx) {
|
|
|
472
553
|
});
|
|
473
554
|
}
|
|
474
555
|
/**
|
|
556
|
+
* Check whether a fully-covered service has operations whose overlay-mapped
|
|
557
|
+
* methods are missing from the baseline class. Returns true when at least
|
|
558
|
+
* one operation maps to a method name that the baseline class does not have,
|
|
559
|
+
* meaning the merger needs to add new methods (skipIfExists must be removed).
|
|
560
|
+
*/
|
|
561
|
+
function hasMethodsAbsentFromBaseline(service, ctx) {
|
|
562
|
+
const baselineClasses = ctx.apiSurface?.classes;
|
|
563
|
+
if (!baselineClasses) return false;
|
|
564
|
+
const targetClassName = SERVICE_COVERED_BY[toPascalCase(service.name)];
|
|
565
|
+
if (targetClassName) {
|
|
566
|
+
const cls = baselineClasses[targetClassName];
|
|
567
|
+
if (!cls) return true;
|
|
568
|
+
for (const op of service.operations) {
|
|
569
|
+
const method = resolveMethodName(op, service, ctx);
|
|
570
|
+
if (!cls.methods?.[method]) return true;
|
|
571
|
+
}
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
575
|
+
if (!overlay) return false;
|
|
576
|
+
for (const op of service.operations) {
|
|
577
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
578
|
+
const match = overlay.get(httpKey);
|
|
579
|
+
if (!match) continue;
|
|
580
|
+
const cls = baselineClasses[match.className];
|
|
581
|
+
if (!cls) continue;
|
|
582
|
+
if (!cls.methods?.[match.methodName]) return true;
|
|
583
|
+
}
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
475
587
|
* Return operations in a service that are NOT covered by existing hand-written
|
|
476
588
|
* service classes. For fully uncovered services, returns all operations.
|
|
477
589
|
* For partially covered services, returns only the uncovered operations.
|
|
@@ -495,7 +607,7 @@ function generateEnums(enums, ctx) {
|
|
|
495
607
|
if (enums.length === 0) return [];
|
|
496
608
|
const enumToService = assignEnumsToServices(enums, ctx.spec.services);
|
|
497
609
|
const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
|
|
498
|
-
const resolveDir = (irService) => irService ?
|
|
610
|
+
const resolveDir = (irService) => irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : "common";
|
|
499
611
|
const files = [];
|
|
500
612
|
for (const enumDef of enums) {
|
|
501
613
|
const dirName = resolveDir(enumToService.get(enumDef.name));
|
|
@@ -513,7 +625,7 @@ function generateEnums(enums, ctx) {
|
|
|
513
625
|
lines.push(` ${memberName} = ${valueStr},`);
|
|
514
626
|
}
|
|
515
627
|
for (const val of missingValues) {
|
|
516
|
-
const memberName = val
|
|
628
|
+
const memberName = toPascalCase(val);
|
|
517
629
|
lines.push(` ${memberName} = '${val}',`);
|
|
518
630
|
}
|
|
519
631
|
lines.push("}");
|
|
@@ -725,54 +837,60 @@ function generateModels(models, ctx) {
|
|
|
725
837
|
const typeParams = renderTypeParams(model, genericDefaults);
|
|
726
838
|
const seenDomainFields = /* @__PURE__ */ new Set();
|
|
727
839
|
if (model.description) lines.push(...docComment(model.description));
|
|
728
|
-
lines.push(`export
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (field.description
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
840
|
+
if (model.fields.length === 0) lines.push(`export type ${domainName}${typeParams} = object;`);
|
|
841
|
+
else {
|
|
842
|
+
lines.push(`export interface ${domainName}${typeParams} {`);
|
|
843
|
+
for (const field of model.fields) {
|
|
844
|
+
const domainFieldName = fieldName(field.name);
|
|
845
|
+
if (seenDomainFields.has(domainFieldName)) continue;
|
|
846
|
+
seenDomainFields.add(domainFieldName);
|
|
847
|
+
if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== void 0) {
|
|
848
|
+
const parts = [];
|
|
849
|
+
if (field.description) parts.push(field.description);
|
|
850
|
+
if (field.readOnly) parts.push("@readonly");
|
|
851
|
+
if (field.writeOnly) parts.push("@writeonly");
|
|
852
|
+
if (field.default !== void 0) parts.push(`@default ${JSON.stringify(field.default)}`);
|
|
853
|
+
if (field.deprecated) parts.push("@deprecated");
|
|
854
|
+
lines.push(...docComment(parts.join("\n"), 2));
|
|
855
|
+
}
|
|
856
|
+
const baselineField = baselineDomain?.fields?.[domainFieldName];
|
|
857
|
+
const domainWireField = wireFieldName(field.name);
|
|
858
|
+
const responseBaselineField = baselineResponse?.fields?.[domainWireField];
|
|
859
|
+
const domainResponseOptionalMismatch = baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
|
|
860
|
+
const readonlyPrefix = field.readOnly ? "readonly " : "";
|
|
861
|
+
if (baselineField && !domainResponseOptionalMismatch && baselineTypeResolvable(baselineField.type, importableNames) && baselineFieldCompatible(baselineField, field)) {
|
|
862
|
+
const opt = baselineField.optional ? "?" : "";
|
|
863
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
|
|
864
|
+
} else {
|
|
865
|
+
const isNewFieldOnExistingModel = baselineDomain && !baselineField;
|
|
866
|
+
const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
|
|
867
|
+
const opt = !field.required || isNewFieldOnExistingModel || domainResponseOptionalMismatch || isNewFieldOnExistingResponse ? "?" : "";
|
|
868
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef$1(field.type, modelTypeRefOpts)};`);
|
|
869
|
+
}
|
|
755
870
|
}
|
|
871
|
+
lines.push("}");
|
|
756
872
|
}
|
|
757
|
-
lines.push("}");
|
|
758
873
|
lines.push("");
|
|
759
874
|
const seenWireFields = /* @__PURE__ */ new Set();
|
|
760
|
-
lines.push(`export
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
875
|
+
if (model.fields.length === 0) lines.push(`export type ${responseName}${typeParams} = object;`);
|
|
876
|
+
else {
|
|
877
|
+
lines.push(`export interface ${responseName}${typeParams} {`);
|
|
878
|
+
for (const field of model.fields) {
|
|
879
|
+
const wireField = wireFieldName(field.name);
|
|
880
|
+
if (seenWireFields.has(wireField)) continue;
|
|
881
|
+
seenWireFields.add(wireField);
|
|
882
|
+
const baselineField = baselineResponse?.fields?.[wireField];
|
|
883
|
+
if (baselineField && baselineTypeResolvable(baselineField.type, importableNames) && baselineFieldCompatible(baselineField, field)) {
|
|
884
|
+
const opt = baselineField.optional ? "?" : "";
|
|
885
|
+
lines.push(` ${wireField}${opt}: ${baselineField.type};`);
|
|
886
|
+
} else {
|
|
887
|
+
const isNewFieldOnExistingModel = baselineResponse && !baselineField;
|
|
888
|
+
const opt = !field.required || isNewFieldOnExistingModel ? "?" : "";
|
|
889
|
+
lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
|
|
890
|
+
}
|
|
773
891
|
}
|
|
892
|
+
lines.push("}");
|
|
774
893
|
}
|
|
775
|
-
lines.push("}");
|
|
776
894
|
files.push({
|
|
777
895
|
path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
|
|
778
896
|
content: pruneUnusedImports(lines).join("\n"),
|
|
@@ -1280,7 +1398,7 @@ function generateFixtures(spec, ctx) {
|
|
|
1280
1398
|
if (spec.models.length === 0) return [];
|
|
1281
1399
|
const { modelToService, resolveDir } = ctx ? createServiceDirResolver(spec.models, ctx.spec.services, ctx) : {
|
|
1282
1400
|
modelToService: assignModelsToServices$1(spec.models, spec.services),
|
|
1283
|
-
resolveDir: (irService) => irService ?
|
|
1401
|
+
resolveDir: (irService) => irService ? resolveServiceDir(irService) : "common"
|
|
1284
1402
|
};
|
|
1285
1403
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
1286
1404
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
@@ -1296,7 +1414,7 @@ function generateFixtures(spec, ctx) {
|
|
|
1296
1414
|
});
|
|
1297
1415
|
}
|
|
1298
1416
|
for (const service of spec.services) {
|
|
1299
|
-
const serviceDir =
|
|
1417
|
+
const serviceDir = resolveServiceDir(ctx ? resolveResourceClassName(service, ctx) : service.name);
|
|
1300
1418
|
for (const op of service.operations) if (op.pagination) {
|
|
1301
1419
|
let itemModel = op.pagination.itemType.kind === "model" ? modelMap.get(op.pagination.itemType.name) : null;
|
|
1302
1420
|
if (itemModel) {
|
|
@@ -1444,18 +1562,261 @@ function paginatedOptionsName(method, resolvedServiceName) {
|
|
|
1444
1562
|
function httpMethodNeedsBody(method) {
|
|
1445
1563
|
return method === "post" || method === "put" || method === "patch";
|
|
1446
1564
|
}
|
|
1565
|
+
/** Map HTTP methods to expected CRUD verb prefixes for method name matching. */
|
|
1566
|
+
const HTTP_VERB_PREFIXES = {
|
|
1567
|
+
get: [
|
|
1568
|
+
"list",
|
|
1569
|
+
"get",
|
|
1570
|
+
"fetch",
|
|
1571
|
+
"retrieve",
|
|
1572
|
+
"find"
|
|
1573
|
+
],
|
|
1574
|
+
post: [
|
|
1575
|
+
"create",
|
|
1576
|
+
"add",
|
|
1577
|
+
"insert",
|
|
1578
|
+
"send"
|
|
1579
|
+
],
|
|
1580
|
+
put: [
|
|
1581
|
+
"set",
|
|
1582
|
+
"update",
|
|
1583
|
+
"replace",
|
|
1584
|
+
"put"
|
|
1585
|
+
],
|
|
1586
|
+
patch: [
|
|
1587
|
+
"update",
|
|
1588
|
+
"patch",
|
|
1589
|
+
"modify"
|
|
1590
|
+
],
|
|
1591
|
+
delete: [
|
|
1592
|
+
"delete",
|
|
1593
|
+
"remove",
|
|
1594
|
+
"revoke"
|
|
1595
|
+
]
|
|
1596
|
+
};
|
|
1597
|
+
/** Split a camelCase/PascalCase name into lowercase word parts. */
|
|
1598
|
+
function splitCamelWords(name) {
|
|
1599
|
+
const parts = [];
|
|
1600
|
+
let start = 0;
|
|
1601
|
+
for (let i = 1; i < name.length; i++) if (name[i] >= "A" && name[i] <= "Z") {
|
|
1602
|
+
parts.push(name.slice(start, i).toLowerCase());
|
|
1603
|
+
start = i;
|
|
1604
|
+
}
|
|
1605
|
+
parts.push(name.slice(start).toLowerCase());
|
|
1606
|
+
return parts;
|
|
1607
|
+
}
|
|
1608
|
+
/** Naive singularize: strip trailing 's' unless it ends in 'ss'. */
|
|
1609
|
+
function singularize(word) {
|
|
1610
|
+
return word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word;
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Batch-reconcile generated method names against the api-surface class methods.
|
|
1614
|
+
*
|
|
1615
|
+
* When the overlay doesn't map an operation (missing from previous manifest or
|
|
1616
|
+
* fuzzy-matching failed), the emitter falls back to the spec-derived name which
|
|
1617
|
+
* often doesn't match the hand-written SDK. This function uses three passes
|
|
1618
|
+
* with progressive exclusion to find the best surface match:
|
|
1619
|
+
*
|
|
1620
|
+
* 1. **Overlay-resolved** — already correct, mark as taken.
|
|
1621
|
+
* 2. **Word-set match** — same content words regardless of order (handles
|
|
1622
|
+
* `listRolesOrganizations` ↔ `listOrganizationRoles`).
|
|
1623
|
+
* 3. **Path-context match** — all surface-method content words appear in the
|
|
1624
|
+
* operation's URL path segments (handles `findById` ↔ `getResource`).
|
|
1625
|
+
*
|
|
1626
|
+
* After each pass, matched surface methods are removed from the pool so that
|
|
1627
|
+
* ambiguous cases (e.g., `listEnvironmentRoles` vs `listOrganizationRoles`)
|
|
1628
|
+
* resolve by elimination.
|
|
1629
|
+
*/
|
|
1630
|
+
function reconcileMethodNames(plans, service, ctx) {
|
|
1631
|
+
const className = resolveResourceClassName(service, ctx);
|
|
1632
|
+
const classMethods = ctx.apiSurface?.classes?.[className]?.methods;
|
|
1633
|
+
if (!classMethods) return;
|
|
1634
|
+
const available = new Set(Object.keys(classMethods));
|
|
1635
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1636
|
+
const thisServicePaths = new Set(service.operations.map((op) => op.path));
|
|
1637
|
+
if (ctx.overlayLookup?.methodByOperation) for (const [httpKey, info] of ctx.overlayLookup.methodByOperation) {
|
|
1638
|
+
if (info.className !== className) continue;
|
|
1639
|
+
const path = httpKey.split(" ")[1];
|
|
1640
|
+
if (thisServicePaths.has(path)) continue;
|
|
1641
|
+
available.delete(info.methodName);
|
|
1642
|
+
}
|
|
1643
|
+
const overlayResolved = /* @__PURE__ */ new Set();
|
|
1644
|
+
for (const plan of plans) {
|
|
1645
|
+
const httpKey = `${plan.op.httpMethod.toUpperCase()} ${plan.op.path}`;
|
|
1646
|
+
if (ctx.overlayLookup?.methodByOperation?.get(httpKey)) {
|
|
1647
|
+
overlayResolved.add(plan);
|
|
1648
|
+
if (available.has(plan.method)) {
|
|
1649
|
+
resolved.set(plan, plan.method);
|
|
1650
|
+
available.delete(plan.method);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
const verbMatches = (methodName, httpMethod, specVerb) => {
|
|
1655
|
+
const prefixes = HTTP_VERB_PREFIXES[httpMethod] ?? [];
|
|
1656
|
+
const lower = methodName.toLowerCase();
|
|
1657
|
+
if (!prefixes.some((p) => lower.startsWith(p))) return false;
|
|
1658
|
+
if (specVerb) {
|
|
1659
|
+
const surfaceVerb = splitCamelWords(methodName)[0];
|
|
1660
|
+
if (specVerb === "list" && surfaceVerb !== "list") return false;
|
|
1661
|
+
if (specVerb !== "list" && surfaceVerb === "list") return false;
|
|
1662
|
+
}
|
|
1663
|
+
return true;
|
|
1664
|
+
};
|
|
1665
|
+
for (const plan of plans) {
|
|
1666
|
+
if (resolved.has(plan)) continue;
|
|
1667
|
+
const specVerb = splitCamelWords(plan.method)[0];
|
|
1668
|
+
const specWords = splitCamelWords(plan.method).slice(1).map(singularize);
|
|
1669
|
+
const specSet = new Set(specWords);
|
|
1670
|
+
if (specSet.size === 0) continue;
|
|
1671
|
+
let match = null;
|
|
1672
|
+
for (const name of available) {
|
|
1673
|
+
if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
|
|
1674
|
+
const methodWords = splitCamelWords(name).slice(1).map(singularize);
|
|
1675
|
+
if (methodWords.length !== specWords.length) continue;
|
|
1676
|
+
const methodSet = new Set(methodWords);
|
|
1677
|
+
if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
|
|
1678
|
+
if (match !== null) {
|
|
1679
|
+
match = null;
|
|
1680
|
+
break;
|
|
1681
|
+
}
|
|
1682
|
+
match = name;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
if (match) {
|
|
1686
|
+
resolved.set(plan, match);
|
|
1687
|
+
available.delete(match);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
for (const plan of plans) {
|
|
1691
|
+
if (resolved.has(plan)) continue;
|
|
1692
|
+
const specVerb = splitCamelWords(plan.method)[0];
|
|
1693
|
+
const pathSegments = plan.op.path.split("/").filter((s) => s && !s.startsWith("{"));
|
|
1694
|
+
const pathWords = new Set(pathSegments.flatMap((s) => s.split("_")).map(singularize));
|
|
1695
|
+
let bestMatch = null;
|
|
1696
|
+
let bestLen = 0;
|
|
1697
|
+
let ambiguous = false;
|
|
1698
|
+
for (const name of available) {
|
|
1699
|
+
if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
|
|
1700
|
+
const methodWords = splitCamelWords(name).slice(1).map(singularize);
|
|
1701
|
+
if (methodWords.length === 0) continue;
|
|
1702
|
+
if (!methodWords.every((w) => pathWords.has(w))) continue;
|
|
1703
|
+
if (methodWords.length < 2 && pathSegments.length > 2) continue;
|
|
1704
|
+
if (methodWords.length > bestLen) {
|
|
1705
|
+
bestMatch = name;
|
|
1706
|
+
bestLen = methodWords.length;
|
|
1707
|
+
ambiguous = false;
|
|
1708
|
+
} else if (methodWords.length === bestLen) ambiguous = true;
|
|
1709
|
+
}
|
|
1710
|
+
if (bestMatch && !ambiguous) {
|
|
1711
|
+
resolved.set(plan, bestMatch);
|
|
1712
|
+
available.delete(bestMatch);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
for (const plan of plans) {
|
|
1716
|
+
if (resolved.has(plan)) continue;
|
|
1717
|
+
const specVerb = splitCamelWords(plan.method)[0];
|
|
1718
|
+
const specWords = splitCamelWords(plan.method).slice(1).map(singularize);
|
|
1719
|
+
const specSet = new Set(specWords);
|
|
1720
|
+
let match = null;
|
|
1721
|
+
for (const name of available) {
|
|
1722
|
+
if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
|
|
1723
|
+
const methodWords = splitCamelWords(name).slice(1).map(singularize);
|
|
1724
|
+
if (methodWords.length !== specWords.length) continue;
|
|
1725
|
+
const methodSet = new Set(methodWords);
|
|
1726
|
+
if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
|
|
1727
|
+
if (match !== null) {
|
|
1728
|
+
match = null;
|
|
1729
|
+
break;
|
|
1730
|
+
}
|
|
1731
|
+
match = name;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
if (match) {
|
|
1735
|
+
resolved.set(plan, match);
|
|
1736
|
+
available.delete(match);
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
const pathSegments = plan.op.path.split("/").filter((s) => s && !s.startsWith("{"));
|
|
1740
|
+
const pathWords = new Set(pathSegments.flatMap((s) => s.split("_")).map(singularize));
|
|
1741
|
+
let bestMatch = null;
|
|
1742
|
+
let bestLen = 0;
|
|
1743
|
+
let ambiguous = false;
|
|
1744
|
+
for (const name of available) {
|
|
1745
|
+
if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
|
|
1746
|
+
const methodWords = splitCamelWords(name).slice(1).map(singularize);
|
|
1747
|
+
if (methodWords.length === 0) continue;
|
|
1748
|
+
if (!methodWords.every((w) => pathWords.has(w))) continue;
|
|
1749
|
+
if (methodWords.length < 2 && pathSegments.length > 2) continue;
|
|
1750
|
+
if (methodWords.length > bestLen) {
|
|
1751
|
+
bestMatch = name;
|
|
1752
|
+
bestLen = methodWords.length;
|
|
1753
|
+
ambiguous = false;
|
|
1754
|
+
} else if (methodWords.length === bestLen) ambiguous = true;
|
|
1755
|
+
}
|
|
1756
|
+
if (bestMatch && !ambiguous) {
|
|
1757
|
+
resolved.set(plan, bestMatch);
|
|
1758
|
+
available.delete(bestMatch);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
for (const plan of plans) {
|
|
1762
|
+
if (overlayResolved.has(plan)) continue;
|
|
1763
|
+
const name = resolved.get(plan);
|
|
1764
|
+
if (name) plan.method = name;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Deduplicate method names within the plans array.
|
|
1769
|
+
*
|
|
1770
|
+
* When `disambiguateOperationNames()` in `@workos/oagen` fails (e.g., for
|
|
1771
|
+
* single-segment paths like `/organizations`), two operations can resolve to
|
|
1772
|
+
* the same method name. Disambiguate by appending a path-derived suffix.
|
|
1773
|
+
*/
|
|
1774
|
+
function deduplicateMethodNames(plans, _ctx) {
|
|
1775
|
+
const nameCount = /* @__PURE__ */ new Map();
|
|
1776
|
+
for (const p of plans) nameCount.set(p.method, (nameCount.get(p.method) ?? 0) + 1);
|
|
1777
|
+
for (const [name, count] of nameCount) {
|
|
1778
|
+
if (count <= 1) continue;
|
|
1779
|
+
const dupes = plans.filter((p) => p.method === name);
|
|
1780
|
+
if (new Set(dupes.map((d) => d.op.path.replace(/\/\{[^}]+\}$/, ""))).size <= 1) continue;
|
|
1781
|
+
const nameWords = new Set(splitCamelWords(name).map(singularize));
|
|
1782
|
+
const scored = dupes.map((d) => {
|
|
1783
|
+
return {
|
|
1784
|
+
plan: d,
|
|
1785
|
+
score: d.op.path.split("/").filter((s) => s && !s.startsWith("{")).flatMap((s) => s.split("_")).map(singularize).filter((w) => nameWords.has(w)).length
|
|
1786
|
+
};
|
|
1787
|
+
});
|
|
1788
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1789
|
+
for (let i = 1; i < scored.length; i++) {
|
|
1790
|
+
const dupe = scored[i].plan;
|
|
1791
|
+
const suffix = dupe.op.path.split("/").filter((s) => s && !s.startsWith("{"))[0] ?? "";
|
|
1792
|
+
if (suffix) dupe.method = toCamelCase(`${name}_${suffix}`);
|
|
1793
|
+
}
|
|
1794
|
+
const stillDuped = /* @__PURE__ */ new Map();
|
|
1795
|
+
for (const dupe of dupes) {
|
|
1796
|
+
const group = stillDuped.get(dupe.method) ?? [];
|
|
1797
|
+
group.push(dupe);
|
|
1798
|
+
stillDuped.set(dupe.method, group);
|
|
1799
|
+
}
|
|
1800
|
+
for (const [, group] of stillDuped) {
|
|
1801
|
+
if (group.length <= 1) continue;
|
|
1802
|
+
for (let i = 1; i < group.length; i++) group[i].method = `${group[i].method}${i + 1}`;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1447
1806
|
function generateResources(services, ctx) {
|
|
1448
1807
|
if (services.length === 0) return [];
|
|
1449
1808
|
const files = [];
|
|
1450
1809
|
for (const service of services) {
|
|
1451
|
-
if (isServiceCoveredByExisting(service, ctx))
|
|
1810
|
+
if (isServiceCoveredByExisting(service, ctx)) {
|
|
1811
|
+
const file = generateResourceClass(service, ctx);
|
|
1812
|
+
if (hasMethodsAbsentFromBaseline(service, ctx)) delete file.skipIfExists;
|
|
1813
|
+
files.push(file);
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1452
1816
|
const ops = uncoveredOperations(service, ctx);
|
|
1453
1817
|
if (ops.length === 0) continue;
|
|
1454
1818
|
if (ops.length < service.operations.length) {
|
|
1455
|
-
const file = generateResourceClass(
|
|
1456
|
-
...service,
|
|
1457
|
-
operations: ops
|
|
1458
|
-
}, ctx);
|
|
1819
|
+
const file = generateResourceClass(service, ctx);
|
|
1459
1820
|
delete file.skipIfExists;
|
|
1460
1821
|
files.push(file);
|
|
1461
1822
|
} else files.push(generateResourceClass(service, ctx));
|
|
@@ -1464,7 +1825,7 @@ function generateResources(services, ctx) {
|
|
|
1464
1825
|
}
|
|
1465
1826
|
function generateResourceClass(service, ctx) {
|
|
1466
1827
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
1467
|
-
const serviceDir =
|
|
1828
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
1468
1829
|
const serviceClass = resolvedName;
|
|
1469
1830
|
const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
|
|
1470
1831
|
const plans = service.operations.map((op) => ({
|
|
@@ -1472,6 +1833,8 @@ function generateResourceClass(service, ctx) {
|
|
|
1472
1833
|
plan: planOperation(op),
|
|
1473
1834
|
method: resolveMethodName(op, service, ctx)
|
|
1474
1835
|
}));
|
|
1836
|
+
reconcileMethodNames(plans, service, ctx);
|
|
1837
|
+
deduplicateMethodNames(plans, ctx);
|
|
1475
1838
|
if (ctx.overlayLookup?.methodByOperation) {
|
|
1476
1839
|
const methodOrder = /* @__PURE__ */ new Map();
|
|
1477
1840
|
let pos = 0;
|
|
@@ -1499,7 +1862,7 @@ function generateResourceClass(service, ctx) {
|
|
|
1499
1862
|
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
1500
1863
|
if (bodyInfo?.kind === "model") requestModels.add(bodyInfo.name);
|
|
1501
1864
|
else if (bodyInfo?.kind === "union") if (bodyInfo.discriminator) for (const name of bodyInfo.modelNames) requestModels.add(name);
|
|
1502
|
-
else for (const name of bodyInfo.modelNames)
|
|
1865
|
+
else for (const name of bodyInfo.modelNames) requestModels.add(name);
|
|
1503
1866
|
const queryParams = plan.isPaginated ? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name)) : op.queryParams;
|
|
1504
1867
|
for (const param of [...queryParams, ...op.pathParams]) collectParamTypeRefs(param.type, paramEnums, paramModels);
|
|
1505
1868
|
}
|
|
@@ -1744,7 +2107,7 @@ function renderDeleteWithBodyMethod(lines, op, plan, method, pathStr, ctx, specE
|
|
|
1744
2107
|
} else if (bodyInfo?.kind === "union") {
|
|
1745
2108
|
requestType = bodyInfo.typeStr;
|
|
1746
2109
|
if (bodyInfo.discriminator) bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
1747
|
-
else bodyExpr =
|
|
2110
|
+
else bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
1748
2111
|
} else {
|
|
1749
2112
|
requestType = "Record<string, unknown>";
|
|
1750
2113
|
bodyExpr = "payload";
|
|
@@ -1766,7 +2129,7 @@ function renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx,
|
|
|
1766
2129
|
} else if (bodyInfo?.kind === "union") {
|
|
1767
2130
|
requestType = bodyInfo.typeStr;
|
|
1768
2131
|
if (bodyInfo.discriminator) bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
1769
|
-
else bodyExpr =
|
|
2132
|
+
else bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
1770
2133
|
} else {
|
|
1771
2134
|
requestType = "Record<string, unknown>";
|
|
1772
2135
|
bodyExpr = "payload";
|
|
@@ -1839,7 +2202,7 @@ function renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames)
|
|
|
1839
2202
|
} else if (bodyInfo?.kind === "union") {
|
|
1840
2203
|
bodyParam = `payload: ${bodyInfo.typeStr}`;
|
|
1841
2204
|
if (bodyInfo.discriminator) bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
1842
|
-
else bodyExpr =
|
|
2205
|
+
else bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
1843
2206
|
} else {
|
|
1844
2207
|
bodyParam = "payload: Record<string, unknown>";
|
|
1845
2208
|
bodyExpr = "payload";
|
|
@@ -1930,6 +2293,90 @@ function renderUnionBodySerializer(disc, ctx) {
|
|
|
1930
2293
|
}
|
|
1931
2294
|
return `(() => { switch ((payload as any).${prop}) { ${cases.join("; ")}; default: return payload } })()`;
|
|
1932
2295
|
}
|
|
2296
|
+
/**
|
|
2297
|
+
* Generate an IIFE expression that dispatches to the correct serializer for a
|
|
2298
|
+
* non-discriminated union request body. Inspects model fields to find a
|
|
2299
|
+
* required field unique to each variant and uses `'field' in payload` guards.
|
|
2300
|
+
* Falls back to `payload` only when no variant can be distinguished.
|
|
2301
|
+
*/
|
|
2302
|
+
function renderNonDiscriminatedUnionBodySerializer(modelNames, ctx) {
|
|
2303
|
+
const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
2304
|
+
const implicitDisc = detectImplicitDiscriminator(modelNames, modelMap);
|
|
2305
|
+
if (implicitDisc) return renderUnionBodySerializer(implicitDisc, ctx);
|
|
2306
|
+
const requiredFieldsByModel = /* @__PURE__ */ new Map();
|
|
2307
|
+
for (const name of modelNames) {
|
|
2308
|
+
const model = modelMap.get(name);
|
|
2309
|
+
if (!model) return "payload";
|
|
2310
|
+
requiredFieldsByModel.set(name, new Set(model.fields.filter((f) => f.required).map((f) => fieldName(f.name))));
|
|
2311
|
+
}
|
|
2312
|
+
const guards = [];
|
|
2313
|
+
let fallbackModel;
|
|
2314
|
+
for (const name of modelNames) {
|
|
2315
|
+
const myFields = requiredFieldsByModel.get(name);
|
|
2316
|
+
let uniqueField;
|
|
2317
|
+
for (const field of myFields) if (modelNames.every((other) => other === name || !requiredFieldsByModel.get(other)?.has(field))) {
|
|
2318
|
+
uniqueField = field;
|
|
2319
|
+
break;
|
|
2320
|
+
}
|
|
2321
|
+
if (uniqueField) guards.push({
|
|
2322
|
+
modelName: name,
|
|
2323
|
+
field: uniqueField
|
|
2324
|
+
});
|
|
2325
|
+
else if (!fallbackModel) fallbackModel = name;
|
|
2326
|
+
else return "payload";
|
|
2327
|
+
}
|
|
2328
|
+
if (guards.length === 0) return "payload";
|
|
2329
|
+
const parts = [];
|
|
2330
|
+
for (const { modelName, field } of guards) {
|
|
2331
|
+
const resolved = resolveInterfaceName(modelName, ctx);
|
|
2332
|
+
parts.push(`if ('${field}' in payload) return serialize${resolved}(payload as any)`);
|
|
2333
|
+
}
|
|
2334
|
+
if (fallbackModel) {
|
|
2335
|
+
const resolved = resolveInterfaceName(fallbackModel, ctx);
|
|
2336
|
+
parts.push(`return serialize${resolved}(payload as any)`);
|
|
2337
|
+
} else parts.push("return payload");
|
|
2338
|
+
return `(() => { ${parts.join("; ")} })()`;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Detect an implicit discriminator from literal-typed fields.
|
|
2342
|
+
* Returns a discriminator descriptor if all variants share a required field
|
|
2343
|
+
* whose type is `kind: 'literal'` with a distinct value per variant.
|
|
2344
|
+
*/
|
|
2345
|
+
function detectImplicitDiscriminator(modelNames, modelMap) {
|
|
2346
|
+
if (modelNames.length < 2) return null;
|
|
2347
|
+
const firstModel = modelMap.get(modelNames[0]);
|
|
2348
|
+
if (!firstModel) return null;
|
|
2349
|
+
const candidates = firstModel.fields.filter((f) => f.required && f.type.kind === "literal");
|
|
2350
|
+
for (const candidate of candidates) {
|
|
2351
|
+
const mapping = {};
|
|
2352
|
+
const values = /* @__PURE__ */ new Set();
|
|
2353
|
+
let valid = true;
|
|
2354
|
+
for (const name of modelNames) {
|
|
2355
|
+
const model = modelMap.get(name);
|
|
2356
|
+
if (!model) {
|
|
2357
|
+
valid = false;
|
|
2358
|
+
break;
|
|
2359
|
+
}
|
|
2360
|
+
const field = model.fields.find((f) => f.name === candidate.name);
|
|
2361
|
+
if (!field || !field.required || field.type.kind !== "literal") {
|
|
2362
|
+
valid = false;
|
|
2363
|
+
break;
|
|
2364
|
+
}
|
|
2365
|
+
const val = field.type.value;
|
|
2366
|
+
if (values.has(val)) {
|
|
2367
|
+
valid = false;
|
|
2368
|
+
break;
|
|
2369
|
+
}
|
|
2370
|
+
values.add(val);
|
|
2371
|
+
mapping[String(val)] = name;
|
|
2372
|
+
}
|
|
2373
|
+
if (valid && Object.keys(mapping).length === modelNames.length) return {
|
|
2374
|
+
property: candidate.name,
|
|
2375
|
+
mapping
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
return null;
|
|
2379
|
+
}
|
|
1933
2380
|
function extractRequestBodyType(op, ctx) {
|
|
1934
2381
|
if (!op.requestBody) return null;
|
|
1935
2382
|
if (op.requestBody.kind === "model") return {
|
|
@@ -1989,7 +2436,7 @@ function generateWorkOSClient(spec, ctx) {
|
|
|
1989
2436
|
for (const service of spec.services) {
|
|
1990
2437
|
if (coveredServices.has(service.name)) continue;
|
|
1991
2438
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
1992
|
-
const serviceDir =
|
|
2439
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
1993
2440
|
lines.push(`import { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
|
|
1994
2441
|
}
|
|
1995
2442
|
lines.push("");
|
|
@@ -2139,9 +2586,52 @@ function generateBarrel(spec, ctx) {
|
|
|
2139
2586
|
const coveredServicesBarrel = /* @__PURE__ */ new Set();
|
|
2140
2587
|
for (const service of spec.services) if (isServiceCoveredByExisting(service, ctx)) coveredServicesBarrel.add(service.name);
|
|
2141
2588
|
const exportedDirs = /* @__PURE__ */ new Set();
|
|
2589
|
+
const dirAllNames = /* @__PURE__ */ new Map();
|
|
2590
|
+
for (const service of spec.services) {
|
|
2591
|
+
const iDir = resolveDir(service.name);
|
|
2592
|
+
if (!dirAllNames.has(iDir)) dirAllNames.set(iDir, /* @__PURE__ */ new Set());
|
|
2593
|
+
const names = dirAllNames.get(iDir);
|
|
2594
|
+
for (const model of spec.models) {
|
|
2595
|
+
if (modelToService.get(model.name) !== service.name) continue;
|
|
2596
|
+
if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
|
|
2597
|
+
names.add(resolveInterfaceName(model.name, ctx));
|
|
2598
|
+
names.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
if (ctx.apiSurface?.interfaces) for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
|
|
2602
|
+
const sourceFile = iface.sourceFile;
|
|
2603
|
+
if (!sourceFile) continue;
|
|
2604
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
2605
|
+
if (match) {
|
|
2606
|
+
const dirName = match[1];
|
|
2607
|
+
if (!dirAllNames.has(dirName)) dirAllNames.set(dirName, /* @__PURE__ */ new Set());
|
|
2608
|
+
dirAllNames.get(dirName).add(name);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
if (ctx.apiSurface?.typeAliases) for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
|
|
2612
|
+
const sourceFile = alias.sourceFile;
|
|
2613
|
+
if (!sourceFile) continue;
|
|
2614
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
2615
|
+
if (match) {
|
|
2616
|
+
const dirName = match[1];
|
|
2617
|
+
if (!dirAllNames.has(dirName)) dirAllNames.set(dirName, /* @__PURE__ */ new Set());
|
|
2618
|
+
dirAllNames.get(dirName).add(name);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const unsafeStarDirs = /* @__PURE__ */ new Set();
|
|
2622
|
+
const allDirEntries = [...dirAllNames.entries()];
|
|
2623
|
+
for (let i = 0; i < allDirEntries.length; i++) for (let j = i + 1; j < allDirEntries.length; j++) {
|
|
2624
|
+
const [dirA, namesA] = allDirEntries[i];
|
|
2625
|
+
const [dirB, namesB] = allDirEntries[j];
|
|
2626
|
+
for (const name of namesA) if (namesB.has(name)) {
|
|
2627
|
+
unsafeStarDirs.add(dirA);
|
|
2628
|
+
unsafeStarDirs.add(dirB);
|
|
2629
|
+
break;
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2142
2632
|
for (const service of spec.services) {
|
|
2143
2633
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
2144
|
-
const serviceDir =
|
|
2634
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
2145
2635
|
const interfacesDir = resolveDir(service.name);
|
|
2146
2636
|
const serviceModels = spec.models.filter((m) => {
|
|
2147
2637
|
if (modelToService.get(m.name) !== service.name) return false;
|
|
@@ -2151,8 +2641,12 @@ function generateBarrel(spec, ctx) {
|
|
|
2151
2641
|
const serviceEnums = spec.enums.filter((e) => {
|
|
2152
2642
|
return findEnumService(e.name, spec.services) === service.name;
|
|
2153
2643
|
});
|
|
2154
|
-
const hasConflict = serviceModels.some((m) =>
|
|
2155
|
-
|
|
2644
|
+
const hasConflict = serviceModels.some((m) => {
|
|
2645
|
+
const name = resolveInterfaceName(m.name, ctx);
|
|
2646
|
+
return existingSdkExports.has(name) || exportedNames.has(name) || exportedNames.has(wireInterfaceName(name));
|
|
2647
|
+
}) || serviceEnums.some((e) => existingSdkExports.has(e.name) || exportedNames.has(e.name));
|
|
2648
|
+
const isCovered = coveredServicesBarrel.has(service.name);
|
|
2649
|
+
if ((serviceModels.length > 0 || serviceEnums.length > 0) && !exportedDirs.has(interfacesDir) && !hasConflict && !unsafeStarDirs.has(interfacesDir) && !isCovered) {
|
|
2156
2650
|
exportedDirs.add(interfacesDir);
|
|
2157
2651
|
lines.push(`export * from './${interfacesDir}/interfaces';`);
|
|
2158
2652
|
for (const model of serviceModels) {
|
|
@@ -2887,7 +3381,7 @@ export function fetchBody({ raw = false } = {}): any {
|
|
|
2887
3381
|
export function testUnauthorized(fn: () => Promise<any>) {
|
|
2888
3382
|
it('throws on unauthorized', async () => {
|
|
2889
3383
|
fetchOnce({ message: 'Unauthorized' }, { status: 401 });
|
|
2890
|
-
await expect(fn()).rejects.toThrow('
|
|
3384
|
+
await expect(fn()).rejects.toThrow('Could not authorize the request');
|
|
2891
3385
|
});
|
|
2892
3386
|
}
|
|
2893
3387
|
|
|
@@ -2941,7 +3435,8 @@ function generateTests(spec, ctx) {
|
|
|
2941
3435
|
for (const f of fixtures) files.push({
|
|
2942
3436
|
path: f.path,
|
|
2943
3437
|
content: f.content,
|
|
2944
|
-
headerPlacement: "skip"
|
|
3438
|
+
headerPlacement: "skip",
|
|
3439
|
+
integrateTarget: false
|
|
2945
3440
|
});
|
|
2946
3441
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
2947
3442
|
for (const service of spec.services) {
|
|
@@ -2960,7 +3455,7 @@ function generateTests(spec, ctx) {
|
|
|
2960
3455
|
}
|
|
2961
3456
|
function generateServiceTest(service, spec, ctx, modelMap) {
|
|
2962
3457
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
2963
|
-
const serviceDir =
|
|
3458
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
2964
3459
|
const serviceClass = resolvedName;
|
|
2965
3460
|
const serviceProp = servicePropertyName(resolvedName);
|
|
2966
3461
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
@@ -3219,8 +3714,9 @@ function buildCallArgs(op, plan, modelMap) {
|
|
|
3219
3714
|
const hasBody = plan.hasBody;
|
|
3220
3715
|
if (isPaginated) return pathArgs || "";
|
|
3221
3716
|
if (hasBody) {
|
|
3222
|
-
const
|
|
3223
|
-
|
|
3717
|
+
const payload = buildTestPayload(op, modelMap);
|
|
3718
|
+
const bodyArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
|
|
3719
|
+
return pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg;
|
|
3224
3720
|
}
|
|
3225
3721
|
return pathArgs || "";
|
|
3226
3722
|
}
|
|
@@ -3307,10 +3803,12 @@ function buildFieldAssertions(model, accessor, modelMap) {
|
|
|
3307
3803
|
return assertions;
|
|
3308
3804
|
}
|
|
3309
3805
|
/**
|
|
3310
|
-
* Return a JS literal string for the expected fixture value of a
|
|
3311
|
-
* Returns null for
|
|
3806
|
+
* Return a JS literal string for the expected fixture value of a field.
|
|
3807
|
+
* Returns null for types that cannot be deterministically generated.
|
|
3808
|
+
* When a modelMap is provided, recursively builds object literals for nested model types.
|
|
3809
|
+
* When wire is true, uses snake_case keys for nested model objects (wire format).
|
|
3312
3810
|
*/
|
|
3313
|
-
function fixtureValueForType(ref, name, modelName) {
|
|
3811
|
+
function fixtureValueForType(ref, name, modelName, modelMap, wire) {
|
|
3314
3812
|
switch (ref.kind) {
|
|
3315
3813
|
case "primitive": return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
|
|
3316
3814
|
case "literal": return typeof ref.value === "string" ? `'${ref.value}'` : String(ref.value);
|
|
@@ -3321,10 +3819,24 @@ function fixtureValueForType(ref, name, modelName) {
|
|
|
3321
3819
|
}
|
|
3322
3820
|
return null;
|
|
3323
3821
|
case "array": {
|
|
3324
|
-
const itemValue = fixtureValueForType(ref.items, name, modelName);
|
|
3822
|
+
const itemValue = fixtureValueForType(ref.items, name, modelName, modelMap, wire);
|
|
3325
3823
|
if (itemValue !== null) return `[${itemValue}]`;
|
|
3326
3824
|
return null;
|
|
3327
3825
|
}
|
|
3826
|
+
case "model": {
|
|
3827
|
+
if (!modelMap) return null;
|
|
3828
|
+
const nested = modelMap.get(ref.name);
|
|
3829
|
+
if (!nested) return null;
|
|
3830
|
+
const requiredFields = nested.fields.filter((f) => f.required);
|
|
3831
|
+
const entries = [];
|
|
3832
|
+
for (const field of requiredFields) {
|
|
3833
|
+
const value = fixtureValueForType(field.type, field.name, nested.name, modelMap, wire);
|
|
3834
|
+
if (value === null) return null;
|
|
3835
|
+
const key = wire ? wireFieldName(field.name) : fieldName(field.name);
|
|
3836
|
+
entries.push(`${key}: ${value}`);
|
|
3837
|
+
}
|
|
3838
|
+
return `{ ${entries.join(", ")} }`;
|
|
3839
|
+
}
|
|
3328
3840
|
default: return null;
|
|
3329
3841
|
}
|
|
3330
3842
|
}
|
|
@@ -3368,16 +3880,17 @@ function buildTestPayload(op, modelMap) {
|
|
|
3368
3880
|
const model = modelMap.get(op.requestBody.name);
|
|
3369
3881
|
if (!model) return null;
|
|
3370
3882
|
const fields = model.fields.filter((f) => f.required);
|
|
3371
|
-
const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name) !== null);
|
|
3883
|
+
const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null);
|
|
3372
3884
|
if (usableFields.length === 0 || usableFields.length < fields.length) return null;
|
|
3373
3885
|
const camelEntries = [];
|
|
3374
3886
|
const snakeEntries = [];
|
|
3375
3887
|
for (const field of usableFields) {
|
|
3376
|
-
const
|
|
3888
|
+
const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap);
|
|
3889
|
+
const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true);
|
|
3377
3890
|
const camelKey = fieldName(field.name);
|
|
3378
3891
|
const snakeKey = wireFieldName(field.name);
|
|
3379
|
-
camelEntries.push(`${camelKey}: ${
|
|
3380
|
-
snakeEntries.push(`${snakeKey}: ${
|
|
3892
|
+
camelEntries.push(`${camelKey}: ${camelValue}`);
|
|
3893
|
+
snakeEntries.push(`${snakeKey}: ${wireValue}`);
|
|
3381
3894
|
}
|
|
3382
3895
|
return {
|
|
3383
3896
|
camelCaseObj: `{ ${camelEntries.join(", ")} }`,
|
|
@@ -3413,7 +3926,7 @@ function generateSerializerTests(spec, ctx) {
|
|
|
3413
3926
|
const modelToService = assignModelsToServices$1(spec.models, spec.services);
|
|
3414
3927
|
const serviceNameMap = /* @__PURE__ */ new Map();
|
|
3415
3928
|
for (const service of spec.services) serviceNameMap.set(service.name, resolveResourceClassName(service, ctx));
|
|
3416
|
-
const resolveDir = (irService) => irService ?
|
|
3929
|
+
const resolveDir = (irService) => irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : "common";
|
|
3417
3930
|
const eligibleModels = spec.models.filter((m) => modelNeedsRoundTripTest(m) && !isListMetadataModel(m) && !isListWrapperModel(m));
|
|
3418
3931
|
if (eligibleModels.length === 0) return files;
|
|
3419
3932
|
const modelsByDir = /* @__PURE__ */ new Map();
|
|
@@ -3483,37 +3996,72 @@ function generateManifest(spec, ctx) {
|
|
|
3483
3996
|
}
|
|
3484
3997
|
//#endregion
|
|
3485
3998
|
//#region src/node/index.ts
|
|
3999
|
+
/** Ensure every generated file's content ends with a trailing newline. */
|
|
4000
|
+
function ensureTrailingNewlines(files) {
|
|
4001
|
+
for (const f of files) if (f.content && !f.content.endsWith("\n")) f.content += "\n";
|
|
4002
|
+
return files;
|
|
4003
|
+
}
|
|
3486
4004
|
const nodeEmitter = {
|
|
3487
4005
|
language: "node",
|
|
3488
4006
|
generateModels(models, ctx) {
|
|
3489
|
-
return [...generateModels(models, ctx), ...generateSerializers(models, ctx)];
|
|
4007
|
+
return ensureTrailingNewlines([...generateModels(models, ctx), ...generateSerializers(models, ctx)]);
|
|
3490
4008
|
},
|
|
3491
4009
|
generateEnums(enums, ctx) {
|
|
3492
|
-
return generateEnums(enums, ctx);
|
|
4010
|
+
return ensureTrailingNewlines(generateEnums(enums, ctx));
|
|
3493
4011
|
},
|
|
3494
4012
|
generateResources(services, ctx) {
|
|
3495
|
-
return generateResources(services, ctx);
|
|
4013
|
+
return ensureTrailingNewlines(generateResources(services, ctx));
|
|
3496
4014
|
},
|
|
3497
4015
|
generateClient(spec, ctx) {
|
|
3498
|
-
return generateClient(spec, ctx);
|
|
4016
|
+
return ensureTrailingNewlines(generateClient(spec, ctx));
|
|
3499
4017
|
},
|
|
3500
4018
|
generateErrors(ctx) {
|
|
3501
|
-
return generateErrors(ctx);
|
|
4019
|
+
return ensureTrailingNewlines(generateErrors(ctx));
|
|
3502
4020
|
},
|
|
3503
4021
|
generateConfig(_ctx) {
|
|
3504
|
-
return [...generateConfig(), ...generateCommon()];
|
|
4022
|
+
return ensureTrailingNewlines([...generateConfig(), ...generateCommon()]);
|
|
3505
4023
|
},
|
|
3506
4024
|
generateTypeSignatures(_spec, _ctx) {
|
|
3507
4025
|
return [];
|
|
3508
4026
|
},
|
|
3509
4027
|
generateTests(spec, ctx) {
|
|
3510
|
-
return generateTests(spec, ctx);
|
|
4028
|
+
return ensureTrailingNewlines(generateTests(spec, ctx));
|
|
3511
4029
|
},
|
|
3512
4030
|
generateManifest(spec, ctx) {
|
|
3513
|
-
return generateManifest(spec, ctx);
|
|
4031
|
+
return ensureTrailingNewlines(generateManifest(spec, ctx));
|
|
3514
4032
|
},
|
|
3515
4033
|
fileHeader() {
|
|
3516
4034
|
return "// This file is auto-generated by oagen. Do not edit.";
|
|
4035
|
+
},
|
|
4036
|
+
formatCommand(targetDir) {
|
|
4037
|
+
const hasPrettier = fs.existsSync(path.join(targetDir, ".prettierrc"));
|
|
4038
|
+
const hasEslint = fs.existsSync(path.join(targetDir, "eslint.config.mjs")) || fs.existsSync(path.join(targetDir, "eslint.config.js")) || fs.existsSync(path.join(targetDir, ".eslintrc.json")) || fs.existsSync(path.join(targetDir, ".eslintrc.js"));
|
|
4039
|
+
if (hasPrettier && hasEslint) return {
|
|
4040
|
+
cmd: "bash",
|
|
4041
|
+
args: [
|
|
4042
|
+
"-c",
|
|
4043
|
+
"npx eslint --fix --no-error-on-unmatched-pattern \"$@\" 2>/dev/null; npx prettier --write --log-level silent \"$@\"",
|
|
4044
|
+
"--"
|
|
4045
|
+
]
|
|
4046
|
+
};
|
|
4047
|
+
if (hasPrettier) return {
|
|
4048
|
+
cmd: "npx",
|
|
4049
|
+
args: [
|
|
4050
|
+
"prettier",
|
|
4051
|
+
"--write",
|
|
4052
|
+
"--log-level",
|
|
4053
|
+
"silent"
|
|
4054
|
+
]
|
|
4055
|
+
};
|
|
4056
|
+
if (hasEslint) return {
|
|
4057
|
+
cmd: "npx",
|
|
4058
|
+
args: [
|
|
4059
|
+
"eslint",
|
|
4060
|
+
"--fix",
|
|
4061
|
+
"--no-error-on-unmatched-pattern"
|
|
4062
|
+
]
|
|
4063
|
+
};
|
|
4064
|
+
return null;
|
|
3517
4065
|
}
|
|
3518
4066
|
};
|
|
3519
4067
|
//#endregion
|