@workos/oagen-emitters 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release-please.yml +9 -1
- package/.husky/commit-msg +0 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +54 -0
- package/README.md +2 -2
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3522 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/src/node/client.ts +437 -204
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +50 -6
- package/src/node/errors.ts +78 -3
- package/src/node/fixtures.ts +84 -15
- package/src/node/index.ts +2 -2
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +195 -79
- package/src/node/naming.ts +16 -1
- package/src/node/resources.ts +721 -106
- package/src/node/serializers.ts +510 -52
- package/src/node/tests.ts +621 -105
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +377 -114
- package/test/node/client.test.ts +979 -15
- package/test/node/enums.test.ts +0 -1
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +409 -2
- package/test/node/naming.test.ts +0 -3
- package/test/node/resources.test.ts +964 -7
- package/test/node/serializers.test.ts +212 -3
- package/tsconfig.json +2 -3
- package/{tsup.config.ts → tsdown.config.ts} +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -2158
package/src/node/type-map.ts
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
|
-
import type { TypeRef, PrimitiveType } from '@workos/oagen';
|
|
1
|
+
import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
|
|
2
2
|
import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
3
3
|
import { wireInterfaceName } from './naming.js';
|
|
4
4
|
|
|
5
|
+
export interface MapTypeRefOpts {
|
|
6
|
+
stringDates?: boolean;
|
|
7
|
+
/** Map from model name → default type args (e.g., `'<Record<string, unknown>>'`).
|
|
8
|
+
* When present, model refs for generic models get their defaults appended. */
|
|
9
|
+
genericDefaults?: Map<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
/**
|
|
6
13
|
* Map an IR TypeRef to a TypeScript domain type string.
|
|
7
14
|
* Domain types use PascalCase model names (e.g., `Organization`).
|
|
15
|
+
*
|
|
16
|
+
* @param opts.stringDates - When true, map `date-time` to `string` instead of `Date`.
|
|
17
|
+
* Use this when integrating into an existing SDK that represents timestamps as
|
|
18
|
+
* ISO 8601 strings rather than Date objects.
|
|
19
|
+
* @param opts.genericDefaults - When present, appends default type args to generic model refs.
|
|
8
20
|
*/
|
|
9
|
-
export function mapTypeRef(ref: TypeRef): string {
|
|
21
|
+
export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
|
|
22
|
+
const primMapper = opts?.stringDates ? mapPrimitiveStringDates : mapPrimitive;
|
|
23
|
+
const genericDefaults = opts?.genericDefaults;
|
|
10
24
|
return irMapTypeRef<string>(ref, {
|
|
11
|
-
primitive:
|
|
25
|
+
primitive: primMapper,
|
|
12
26
|
array: (_r, items) => `${parenthesizeUnion(items)}[]`,
|
|
13
|
-
model: (r) => r.name,
|
|
27
|
+
model: (r) => r.name + (genericDefaults?.get(r.name) ?? ''),
|
|
14
28
|
enum: (r) => r.name,
|
|
15
|
-
union: (
|
|
29
|
+
union: (r, variants) => joinUnionVariants(r, variants),
|
|
16
30
|
nullable: (_r, inner) => `${inner} | null`,
|
|
17
31
|
literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
|
|
18
32
|
map: (_r, value) => `Record<string, ${value}>`,
|
|
@@ -22,14 +36,16 @@ export function mapTypeRef(ref: TypeRef): string {
|
|
|
22
36
|
/**
|
|
23
37
|
* Map an IR TypeRef to a TypeScript wire/response type string.
|
|
24
38
|
* Model references get the `Response` suffix (e.g., `OrganizationResponse`).
|
|
39
|
+
* Wire types use JSON-native types (string for date-time, number/string for int64).
|
|
25
40
|
*/
|
|
26
|
-
export function mapWireTypeRef(ref: TypeRef): string {
|
|
41
|
+
export function mapWireTypeRef(ref: TypeRef, opts?: { genericDefaults?: Map<string, string> }): string {
|
|
42
|
+
const genericDefaults = opts?.genericDefaults;
|
|
27
43
|
return irMapTypeRef<string>(ref, {
|
|
28
|
-
primitive:
|
|
44
|
+
primitive: mapWirePrimitive,
|
|
29
45
|
array: (_r, items) => `${parenthesizeUnion(items)}[]`,
|
|
30
|
-
model: (r) => wireInterfaceName(r.name),
|
|
46
|
+
model: (r) => wireInterfaceName(r.name) + (genericDefaults?.get(r.name) ?? ''),
|
|
31
47
|
enum: (r) => r.name,
|
|
32
|
-
union: (
|
|
48
|
+
union: (r, variants) => joinUnionVariants(r, variants),
|
|
33
49
|
nullable: (_r, inner) => `${inner} | null`,
|
|
34
50
|
literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
|
|
35
51
|
map: (_r, value) => `Record<string, ${value}>`,
|
|
@@ -37,6 +53,39 @@ export function mapWireTypeRef(ref: TypeRef): string {
|
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
function mapPrimitive(ref: PrimitiveType): string {
|
|
56
|
+
if (ref.format) {
|
|
57
|
+
switch (ref.format) {
|
|
58
|
+
case 'date-time':
|
|
59
|
+
return 'Date';
|
|
60
|
+
case 'int64':
|
|
61
|
+
return 'bigint';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
switch (ref.type) {
|
|
65
|
+
case 'string':
|
|
66
|
+
return 'string';
|
|
67
|
+
case 'integer':
|
|
68
|
+
case 'number':
|
|
69
|
+
return 'number';
|
|
70
|
+
case 'boolean':
|
|
71
|
+
return 'boolean';
|
|
72
|
+
case 'unknown':
|
|
73
|
+
return 'any';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Map a primitive type using string representation for dates.
|
|
79
|
+
* Used when the existing SDK represents timestamps as ISO 8601 strings.
|
|
80
|
+
*/
|
|
81
|
+
function mapPrimitiveStringDates(ref: PrimitiveType): string {
|
|
82
|
+
if (ref.format) {
|
|
83
|
+
switch (ref.format) {
|
|
84
|
+
case 'int64':
|
|
85
|
+
return 'bigint';
|
|
86
|
+
// date-time intentionally falls through to the string case
|
|
87
|
+
}
|
|
88
|
+
}
|
|
40
89
|
switch (ref.type) {
|
|
41
90
|
case 'string':
|
|
42
91
|
return 'string';
|
|
@@ -50,7 +99,36 @@ function mapPrimitive(ref: PrimitiveType): string {
|
|
|
50
99
|
}
|
|
51
100
|
}
|
|
52
101
|
|
|
53
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Map an IR PrimitiveType to a TypeScript wire/JSON type string.
|
|
104
|
+
* Wire types match JSON encoding: date-time stays string, int64 stays string/number.
|
|
105
|
+
*/
|
|
106
|
+
function mapWirePrimitive(ref: PrimitiveType): string {
|
|
107
|
+
switch (ref.type) {
|
|
108
|
+
case 'string':
|
|
109
|
+
return 'string';
|
|
110
|
+
case 'integer':
|
|
111
|
+
case 'number':
|
|
112
|
+
return 'number';
|
|
113
|
+
case 'boolean':
|
|
114
|
+
return 'boolean';
|
|
115
|
+
case 'unknown':
|
|
116
|
+
return 'any';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Join union variant type strings using the appropriate operator.
|
|
122
|
+
* allOf unions use `&` (intersection), oneOf/anyOf/unspecified use `|` (union).
|
|
123
|
+
*/
|
|
124
|
+
function joinUnionVariants(ref: UnionType, variants: string[]): string {
|
|
125
|
+
if (ref.compositionKind === 'allOf') {
|
|
126
|
+
return variants.join(' & ');
|
|
127
|
+
}
|
|
128
|
+
return variants.join(' | ');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Wrap union/intersection types in parentheses when used as array item type. */
|
|
54
132
|
function parenthesizeUnion(type: string): string {
|
|
55
|
-
return type.includes(' | ') ? `(${type})` : type;
|
|
133
|
+
return type.includes(' | ') || type.includes(' & ') ? `(${type})` : type;
|
|
56
134
|
}
|
package/src/node/utils.ts
CHANGED
|
@@ -1,107 +1,14 @@
|
|
|
1
|
-
import type { Model, Service,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Collect all enum names referenced by a TypeRef.
|
|
15
|
-
*/
|
|
16
|
-
export function collectEnumRefs(ref: TypeRef): string[] {
|
|
17
|
-
const names: string[] = [];
|
|
18
|
-
walkTypeRef(ref, { enum: (r) => names.push(r.name) });
|
|
19
|
-
return names;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Assign each model to the service that first references it.
|
|
24
|
-
* Models referenced by multiple services are assigned to the first.
|
|
25
|
-
* Models not referenced by any service are unassigned (returned as undefined).
|
|
26
|
-
*/
|
|
27
|
-
export function assignModelsToServices(models: Model[], services: Service[]): Map<string, string> {
|
|
28
|
-
const modelToService = new Map<string, string>();
|
|
29
|
-
const modelNames = new Set(models.map((m) => m.name));
|
|
30
|
-
|
|
31
|
-
for (const service of services) {
|
|
32
|
-
const referencedModels = new Set<string>();
|
|
33
|
-
|
|
34
|
-
// Collect directly referenced models from all operations
|
|
35
|
-
for (const op of service.operations) {
|
|
36
|
-
if (op.requestBody) {
|
|
37
|
-
for (const name of collectModelRefs(op.requestBody)) {
|
|
38
|
-
referencedModels.add(name);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
for (const name of collectModelRefs(op.response)) {
|
|
42
|
-
referencedModels.add(name);
|
|
43
|
-
}
|
|
44
|
-
for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
|
|
45
|
-
for (const name of collectModelRefs(param.type)) {
|
|
46
|
-
referencedModels.add(name);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
if (op.pagination) {
|
|
50
|
-
for (const name of collectModelRefs(op.pagination.itemType)) {
|
|
51
|
-
referencedModels.add(name);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Transitively collect models referenced by the directly-referenced models
|
|
57
|
-
const toVisit = [...referencedModels];
|
|
58
|
-
while (toVisit.length > 0) {
|
|
59
|
-
const name = toVisit.pop()!;
|
|
60
|
-
const model = models.find((m) => m.name === name);
|
|
61
|
-
if (!model) continue;
|
|
62
|
-
for (const field of model.fields) {
|
|
63
|
-
for (const ref of collectModelRefs(field.type)) {
|
|
64
|
-
if (!referencedModels.has(ref) && modelNames.has(ref)) {
|
|
65
|
-
referencedModels.add(ref);
|
|
66
|
-
toVisit.push(ref);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Assign models to this service (first-come)
|
|
73
|
-
for (const name of referencedModels) {
|
|
74
|
-
if (!modelToService.has(name)) {
|
|
75
|
-
modelToService.set(name, service.name);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return modelToService;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Collect all TypeRef-referenced model and enum names from a model's fields.
|
|
85
|
-
* Returns { models, enums } sets for generating import statements.
|
|
86
|
-
*/
|
|
87
|
-
export function collectFieldDependencies(model: Model): {
|
|
88
|
-
models: Set<string>;
|
|
89
|
-
enums: Set<string>;
|
|
90
|
-
} {
|
|
91
|
-
const models = new Set<string>();
|
|
92
|
-
const enums = new Set<string>();
|
|
93
|
-
|
|
94
|
-
for (const field of model.fields) {
|
|
95
|
-
for (const name of collectModelRefs(field.type)) {
|
|
96
|
-
if (name !== model.name) models.add(name);
|
|
97
|
-
}
|
|
98
|
-
for (const name of collectEnumRefs(field.type)) {
|
|
99
|
-
enums.add(name);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return { models, enums };
|
|
104
|
-
}
|
|
1
|
+
import type { Model, EmitterContext, Service, Operation, Field } from '@workos/oagen';
|
|
2
|
+
export {
|
|
3
|
+
collectModelRefs,
|
|
4
|
+
collectEnumRefs,
|
|
5
|
+
assignModelsToServices,
|
|
6
|
+
collectFieldDependencies,
|
|
7
|
+
collectRequestBodyModels,
|
|
8
|
+
} from '@workos/oagen';
|
|
9
|
+
import { mapTypeRef } from './type-map.js';
|
|
10
|
+
import { resolveInterfaceName, fieldName, serviceDirName, buildServiceNameMap } from './naming.js';
|
|
11
|
+
import { assignModelsToServices } from '@workos/oagen';
|
|
105
12
|
|
|
106
13
|
/**
|
|
107
14
|
* Compute a relative import path between two files within the generated SDK.
|
|
@@ -147,18 +54,374 @@ export function docComment(description: string, indent = 0): string[] {
|
|
|
147
54
|
}
|
|
148
55
|
|
|
149
56
|
/**
|
|
150
|
-
*
|
|
57
|
+
* Build a map from model name → default type args string for generic models.
|
|
58
|
+
* E.g., Profile<CustomAttributesType = Record<string, unknown>>
|
|
59
|
+
* → Map { 'Profile' → '<Record<string, unknown>>' }
|
|
60
|
+
*
|
|
61
|
+
* Non-generic models are not included in the map.
|
|
62
|
+
*/
|
|
63
|
+
export function buildGenericModelDefaults(models: Model[]): Map<string, string> {
|
|
64
|
+
const result = new Map<string, string>();
|
|
65
|
+
for (const model of models) {
|
|
66
|
+
if (!model.typeParams?.length) continue;
|
|
67
|
+
const defaults = model.typeParams.map((tp) => (tp.default ? mapTypeRef(tp.default) : 'unknown'));
|
|
68
|
+
result.set(model.name, `<${defaults.join(', ')}>`);
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove unused imports from generated source code.
|
|
75
|
+
* Scans the non-import body for each imported identifier and drops
|
|
76
|
+
* individual names that are never referenced. Removes entire import
|
|
77
|
+
* statements when no names are used.
|
|
78
|
+
*/
|
|
79
|
+
export function pruneUnusedImports(lines: string[]): string[] {
|
|
80
|
+
// Split lines into imports and body
|
|
81
|
+
const importLines: string[] = [];
|
|
82
|
+
const bodyLines: string[] = [];
|
|
83
|
+
let inBody = false;
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (!inBody && (line.startsWith('import ') || line === '')) {
|
|
86
|
+
importLines.push(line);
|
|
87
|
+
} else {
|
|
88
|
+
inBody = true;
|
|
89
|
+
bodyLines.push(line);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const body = bodyLines.join('\n');
|
|
94
|
+
const kept: string[] = [];
|
|
95
|
+
|
|
96
|
+
for (const line of importLines) {
|
|
97
|
+
if (line === '') {
|
|
98
|
+
kept.push(line);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Extract imported names from the import statement
|
|
102
|
+
const match = line.match(/\{([^}]+)\}/);
|
|
103
|
+
if (!match) {
|
|
104
|
+
// Non-destructured import (e.g., import X from '...') — keep
|
|
105
|
+
kept.push(line);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const names = match[1]
|
|
109
|
+
.split(',')
|
|
110
|
+
.map((n) => n.trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
// Filter to only names that appear in the body
|
|
113
|
+
const usedNames = names.filter((name) => {
|
|
114
|
+
const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
115
|
+
return re.test(body);
|
|
116
|
+
});
|
|
117
|
+
if (usedNames.length === 0) {
|
|
118
|
+
// No names used — drop entire import
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (usedNames.length === names.length) {
|
|
122
|
+
// All names used — keep original line
|
|
123
|
+
kept.push(line);
|
|
124
|
+
} else {
|
|
125
|
+
// Some names unused — reconstruct import with only used names
|
|
126
|
+
const isTypeImport = line.startsWith('import type');
|
|
127
|
+
const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
|
|
128
|
+
if (fromMatch) {
|
|
129
|
+
const prefix = isTypeImport ? 'import type' : 'import';
|
|
130
|
+
kept.push(`${prefix} { ${usedNames.join(', ')} } from '${fromMatch[1]}';`);
|
|
131
|
+
} else {
|
|
132
|
+
kept.push(line);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...kept, ...bodyLines];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Built-in TypeScript types that are always available (no import needed). */
|
|
141
|
+
export const TS_BUILTINS = new Set([
|
|
142
|
+
'Record',
|
|
143
|
+
'Promise',
|
|
144
|
+
'Array',
|
|
145
|
+
'Map',
|
|
146
|
+
'Set',
|
|
147
|
+
'Date',
|
|
148
|
+
'string',
|
|
149
|
+
'number',
|
|
150
|
+
'boolean',
|
|
151
|
+
'void',
|
|
152
|
+
'null',
|
|
153
|
+
'undefined',
|
|
154
|
+
'any',
|
|
155
|
+
'never',
|
|
156
|
+
'unknown',
|
|
157
|
+
'true',
|
|
158
|
+
'false',
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Detect whether the existing SDK uses string (ISO 8601) representation for
|
|
163
|
+
* date-time fields. Checks if any baseline interface has a date-time IR field
|
|
164
|
+
* typed as plain `string` (not `Date`).
|
|
151
165
|
*/
|
|
152
|
-
export function
|
|
153
|
-
|
|
154
|
-
for (const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
export function detectStringDateConvention(models: Model[], ctx: EmitterContext): boolean {
|
|
167
|
+
if (!ctx.apiSurface?.interfaces) return false;
|
|
168
|
+
for (const model of models) {
|
|
169
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
170
|
+
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
171
|
+
if (!baseline?.fields) continue;
|
|
172
|
+
for (const field of model.fields) {
|
|
173
|
+
if (field.type.kind !== 'primitive' || field.type.format !== 'date-time') continue;
|
|
174
|
+
const baselineField = baseline.fields[fieldName(field.name)];
|
|
175
|
+
if (baselineField && !baselineField.type.includes('Date')) {
|
|
176
|
+
return true;
|
|
160
177
|
}
|
|
161
178
|
}
|
|
162
179
|
}
|
|
163
|
-
return
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build a comprehensive set of all known type names from the IR and baseline.
|
|
185
|
+
* Used to identify type parameters by elimination — any PascalCase name not in
|
|
186
|
+
* this set is likely a generic type parameter.
|
|
187
|
+
*/
|
|
188
|
+
export function buildKnownTypeNames(models: Model[], ctx: EmitterContext): Set<string> {
|
|
189
|
+
const knownNames = new Set<string>();
|
|
190
|
+
for (const m of models) knownNames.add(resolveInterfaceName(m.name, ctx));
|
|
191
|
+
for (const e of ctx.spec.enums) knownNames.add(e.name);
|
|
192
|
+
if (ctx.apiSurface?.interfaces) {
|
|
193
|
+
for (const name of Object.keys(ctx.apiSurface.interfaces)) knownNames.add(name);
|
|
194
|
+
}
|
|
195
|
+
if (ctx.apiSurface?.typeAliases) {
|
|
196
|
+
for (const name of Object.keys(ctx.apiSurface.typeAliases)) knownNames.add(name);
|
|
197
|
+
}
|
|
198
|
+
if (ctx.apiSurface?.enums) {
|
|
199
|
+
for (const name of Object.keys(ctx.apiSurface.enums)) knownNames.add(name);
|
|
200
|
+
}
|
|
201
|
+
return knownNames;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create a service directory resolver bundle.
|
|
206
|
+
* Encapsulates the common pattern of mapping models to services and resolving
|
|
207
|
+
* the output directory for a given IR service name.
|
|
208
|
+
*/
|
|
209
|
+
export function createServiceDirResolver(
|
|
210
|
+
models: Model[],
|
|
211
|
+
services: Service[],
|
|
212
|
+
ctx: EmitterContext,
|
|
213
|
+
): {
|
|
214
|
+
modelToService: Map<string, string>;
|
|
215
|
+
serviceNameMap: Map<string, string>;
|
|
216
|
+
resolveDir: (irService: string | undefined) => string;
|
|
217
|
+
} {
|
|
218
|
+
const modelToService = assignModelsToServices(models, services);
|
|
219
|
+
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
220
|
+
const resolveDir = (irService: string | undefined) =>
|
|
221
|
+
irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
222
|
+
return { modelToService, serviceNameMap, resolveDir };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if a set of baseline interface fields appears to contain generic type
|
|
227
|
+
* parameters — PascalCase names that aren't known models, enums, or builtins.
|
|
228
|
+
*/
|
|
229
|
+
export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: Set<string>): boolean {
|
|
230
|
+
for (const [, bf] of Object.entries(fields)) {
|
|
231
|
+
const fieldType = (bf as { type: string }).type;
|
|
232
|
+
const typeNames = fieldType.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
|
|
233
|
+
if (!typeNames) continue;
|
|
234
|
+
for (const tn of typeNames) {
|
|
235
|
+
if (TS_BUILTINS.has(tn)) continue;
|
|
236
|
+
if (knownNames.has(tn)) continue;
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Detect whether a model matches the standard list-metadata shape:
|
|
245
|
+
* exactly 2 fields named `before` and `after`, both nullable string.
|
|
246
|
+
*
|
|
247
|
+
* These models are redundant because the SDK already has a shared
|
|
248
|
+
* `ListMetadata` type in `src/common/utils/pagination.ts`.
|
|
249
|
+
*/
|
|
250
|
+
export function isListMetadataModel(model: Model): boolean {
|
|
251
|
+
if (model.fields.length !== 2) return false;
|
|
252
|
+
|
|
253
|
+
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
254
|
+
const before = fieldsByName.get('before');
|
|
255
|
+
const after = fieldsByName.get('after');
|
|
256
|
+
|
|
257
|
+
if (!before || !after) return false;
|
|
258
|
+
|
|
259
|
+
return isNullableString(before) && isNullableString(after);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Detect whether a model is a list wrapper — the standard paginated
|
|
264
|
+
* list envelope with `data` (array), `list_metadata`, and `object: 'list'`.
|
|
265
|
+
*
|
|
266
|
+
* These models are redundant because the SDK already has `List<T>` and
|
|
267
|
+
* `ListResponse<T>` in `src/common/utils/pagination.ts`, and the shared
|
|
268
|
+
* `deserializeList` handles deserialization.
|
|
269
|
+
*/
|
|
270
|
+
export function isListWrapperModel(model: Model): boolean {
|
|
271
|
+
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
272
|
+
|
|
273
|
+
// Must have a `data` field that is an array type
|
|
274
|
+
const dataField = fieldsByName.get('data');
|
|
275
|
+
if (!dataField) return false;
|
|
276
|
+
if (dataField.type.kind !== 'array') return false;
|
|
277
|
+
|
|
278
|
+
// Must have a `list_metadata` field (the IR uses snake_case names)
|
|
279
|
+
const listMetadataField = fieldsByName.get('list_metadata');
|
|
280
|
+
if (!listMetadataField) return false;
|
|
281
|
+
|
|
282
|
+
// Optionally has an `object` field with literal value 'list'
|
|
283
|
+
const objectField = fieldsByName.get('object');
|
|
284
|
+
if (objectField) {
|
|
285
|
+
if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Check if a field type is nullable string (nullable<string> or just string). */
|
|
294
|
+
function isNullableString(field: Field): boolean {
|
|
295
|
+
const { type } = field;
|
|
296
|
+
if (type.kind === 'nullable') {
|
|
297
|
+
return type.inner.kind === 'primitive' && type.inner.type === 'string';
|
|
298
|
+
}
|
|
299
|
+
if (type.kind === 'primitive') {
|
|
300
|
+
return type.type === 'string';
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Compute a structural fingerprint for a model based on its fields.
|
|
307
|
+
* Two models with identical fingerprints are structurally equivalent.
|
|
308
|
+
*/
|
|
309
|
+
function modelFingerprint(model: Model): string {
|
|
310
|
+
const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
|
|
311
|
+
return fields.join('|');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Find structurally identical models and build a deduplication map.
|
|
316
|
+
* Also deduplicates models that resolve to the same interface name across
|
|
317
|
+
* services — when a `$ref` schema is used by multiple tags, the IR may
|
|
318
|
+
* produce per-tag copies that diverge slightly. The version with the most
|
|
319
|
+
* fields is chosen as canonical.
|
|
320
|
+
*
|
|
321
|
+
* Returns a Map from duplicate model name → canonical model name.
|
|
322
|
+
*/
|
|
323
|
+
export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Map<string, string> {
|
|
324
|
+
const dedup = new Map<string, string>();
|
|
325
|
+
|
|
326
|
+
// Pass 1: structural fingerprint dedup (exact match)
|
|
327
|
+
const fingerprints = new Map<string, string>();
|
|
328
|
+
for (const model of models) {
|
|
329
|
+
if (model.fields.length === 0) continue;
|
|
330
|
+
const fp = modelFingerprint(model);
|
|
331
|
+
const existing = fingerprints.get(fp);
|
|
332
|
+
if (existing) {
|
|
333
|
+
dedup.set(model.name, existing);
|
|
334
|
+
} else {
|
|
335
|
+
fingerprints.set(fp, model.name);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Pass 2: name-based dedup for models that resolve to the same interface
|
|
340
|
+
// name across services. Only applies when context with name resolution is
|
|
341
|
+
// available. Picks the model with the most fields as canonical.
|
|
342
|
+
if (ctx) {
|
|
343
|
+
const byDomainName = new Map<string, Model[]>();
|
|
344
|
+
for (const model of models) {
|
|
345
|
+
if (model.fields.length === 0) continue;
|
|
346
|
+
if (dedup.has(model.name)) continue; // already deduped in pass 1
|
|
347
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
348
|
+
const group = byDomainName.get(domainName);
|
|
349
|
+
if (group) {
|
|
350
|
+
group.push(model);
|
|
351
|
+
} else {
|
|
352
|
+
byDomainName.set(domainName, [model]);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
for (const [, group] of byDomainName) {
|
|
356
|
+
if (group.length < 2) continue;
|
|
357
|
+
// Choose canonical: most fields, then alphabetically by name
|
|
358
|
+
group.sort((a, b) => b.fields.length - a.fields.length || a.name.localeCompare(b.name));
|
|
359
|
+
const canonical = group[0];
|
|
360
|
+
for (let i = 1; i < group.length; i++) {
|
|
361
|
+
dedup.set(group[i].name, canonical.name);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return dedup;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Check whether a service's endpoints are already fully covered by existing
|
|
371
|
+
* hand-written service classes.
|
|
372
|
+
*
|
|
373
|
+
* A service is considered "covered" when:
|
|
374
|
+
* 1. **Every** operation in it appears in `overlayLookup.methodByOperation`
|
|
375
|
+
* 2. The overlay maps those operations to a class that exists in the baseline
|
|
376
|
+
* `apiSurface` (confirming the hand-written class is actually present)
|
|
377
|
+
*
|
|
378
|
+
* Services with zero operations are never considered covered (nothing to
|
|
379
|
+
* deduplicate). When no `apiSurface` is available, the overlay alone is
|
|
380
|
+
* used as the coverage signal (the overlay is only built from existing code).
|
|
381
|
+
*
|
|
382
|
+
* This prevents the emitter from generating resource classes like `Connections`
|
|
383
|
+
* that would duplicate hand-written modules like `SSO` for the same API
|
|
384
|
+
* endpoints (e.g., `GET /connections`).
|
|
385
|
+
*/
|
|
386
|
+
export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
|
|
387
|
+
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
388
|
+
if (!overlay || overlay.size === 0) return false;
|
|
389
|
+
if (service.operations.length === 0) return false;
|
|
390
|
+
|
|
391
|
+
// Collect the set of existing class names from the baseline surface.
|
|
392
|
+
// When no apiSurface is available, the overlay alone cannot confirm that
|
|
393
|
+
// a hand-written class exists — it may only carry naming hints.
|
|
394
|
+
const baselineClasses = ctx.apiSurface?.classes;
|
|
395
|
+
if (!baselineClasses) return false;
|
|
396
|
+
const existingClassNames = new Set(Object.keys(baselineClasses));
|
|
397
|
+
|
|
398
|
+
// Check that every operation is in the overlay AND the overlay's target class
|
|
399
|
+
// exists in the baseline.
|
|
400
|
+
return service.operations.every((op: Operation) => {
|
|
401
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
402
|
+
const match = overlay.get(httpKey);
|
|
403
|
+
if (!match) return false;
|
|
404
|
+
return existingClassNames.has(match.className);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Return operations in a service that are NOT covered by existing hand-written
|
|
410
|
+
* service classes. For fully uncovered services, returns all operations.
|
|
411
|
+
* For partially covered services, returns only the uncovered operations.
|
|
412
|
+
*/
|
|
413
|
+
export function uncoveredOperations(service: Service, ctx: EmitterContext): Operation[] {
|
|
414
|
+
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
415
|
+
if (!overlay || overlay.size === 0) return service.operations;
|
|
416
|
+
|
|
417
|
+
const baselineClasses = ctx.apiSurface?.classes;
|
|
418
|
+
if (!baselineClasses) return service.operations;
|
|
419
|
+
const existingClassNames = new Set(Object.keys(baselineClasses));
|
|
420
|
+
|
|
421
|
+
return service.operations.filter((op: Operation) => {
|
|
422
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
423
|
+
const match = overlay.get(httpKey);
|
|
424
|
+
if (!match) return true; // Not in overlay → uncovered
|
|
425
|
+
return !existingClassNames.has(match.className); // Class doesn't exist → uncovered
|
|
426
|
+
});
|
|
164
427
|
}
|