@workos/oagen-emitters 0.0.1 → 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/.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/.oxfmtrc.json +8 -1
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +61 -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 +4070 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- 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 +521 -206
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +53 -9
- package/src/node/errors.ts +82 -3
- package/src/node/fixtures.ts +87 -16
- package/src/node/index.ts +66 -10
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +251 -124
- package/src/node/naming.ts +107 -3
- package/src/node/resources.ts +1162 -108
- package/src/node/serializers.ts +512 -52
- package/src/node/tests.ts +650 -110
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +426 -113
- package/test/node/client.test.ts +1083 -20
- package/test/node/enums.test.ts +73 -4
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +499 -5
- package/test/node/naming.test.ts +14 -7
- package/test/node/resources.test.ts +1568 -9
- package/test/node/serializers.test.ts +241 -5
- 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/utils.ts
CHANGED
|
@@ -1,164 +1,477 @@
|
|
|
1
|
-
import type { Model, Service,
|
|
2
|
-
import {
|
|
1
|
+
import type { Model, EmitterContext, Service, Operation, Field } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase } from '@workos/oagen';
|
|
3
|
+
export {
|
|
4
|
+
collectModelRefs,
|
|
5
|
+
collectEnumRefs,
|
|
6
|
+
assignModelsToServices,
|
|
7
|
+
collectFieldDependencies,
|
|
8
|
+
collectRequestBodyModels,
|
|
9
|
+
} from '@workos/oagen';
|
|
10
|
+
import { mapTypeRef } from './type-map.js';
|
|
11
|
+
import {
|
|
12
|
+
resolveInterfaceName,
|
|
13
|
+
fieldName,
|
|
14
|
+
resolveServiceDir,
|
|
15
|
+
resolveMethodName,
|
|
16
|
+
buildServiceNameMap,
|
|
17
|
+
SERVICE_COVERED_BY,
|
|
18
|
+
} from './naming.js';
|
|
19
|
+
import { assignModelsToServices } from '@workos/oagen';
|
|
3
20
|
|
|
4
21
|
/**
|
|
5
|
-
*
|
|
22
|
+
* Compute a relative import path between two files within the generated SDK.
|
|
23
|
+
* Strips .ts extension from the result.
|
|
6
24
|
*/
|
|
7
|
-
export function
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
25
|
+
export function relativeImport(fromFile: string, toFile: string): string {
|
|
26
|
+
const fromDir = fromFile.split('/').slice(0, -1);
|
|
27
|
+
const toFileParts = toFile.split('/');
|
|
28
|
+
const toDir = toFileParts.slice(0, -1);
|
|
29
|
+
const toFileName = toFileParts[toFileParts.length - 1];
|
|
30
|
+
|
|
31
|
+
let common = 0;
|
|
32
|
+
while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
|
|
33
|
+
common++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ups = fromDir.length - common;
|
|
37
|
+
const downs = toDir.slice(common);
|
|
38
|
+
const parts = [...Array(ups).fill('..'), ...downs, toFileName];
|
|
39
|
+
let result = parts.join('/');
|
|
40
|
+
result = result.replace(/\.ts$/, '');
|
|
41
|
+
if (!result.startsWith('.')) result = './' + result;
|
|
42
|
+
return result;
|
|
11
43
|
}
|
|
12
44
|
|
|
13
45
|
/**
|
|
14
|
-
*
|
|
46
|
+
* Render a JSDoc comment block from a description string.
|
|
47
|
+
* Handles multiline descriptions by prefixing each line with ` * `.
|
|
48
|
+
* Returns the lines with the given indent (default 0 spaces).
|
|
15
49
|
*/
|
|
16
|
-
export function
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
50
|
+
export function docComment(description: string, indent = 0): string[] {
|
|
51
|
+
const pad = ' '.repeat(indent);
|
|
52
|
+
const descLines = description.split('\n');
|
|
53
|
+
if (descLines.length === 1) {
|
|
54
|
+
return [`${pad}/** ${descLines[0]} */`];
|
|
55
|
+
}
|
|
56
|
+
const lines: string[] = [`${pad}/**`];
|
|
57
|
+
for (const line of descLines) {
|
|
58
|
+
lines.push(line === '' ? `${pad} *` : `${pad} * ${line}`);
|
|
59
|
+
}
|
|
60
|
+
lines.push(`${pad} */`);
|
|
61
|
+
return lines;
|
|
20
62
|
}
|
|
21
63
|
|
|
22
64
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
65
|
+
* Build a map from model name → default type args string for generic models.
|
|
66
|
+
* E.g., Profile<CustomAttributesType = Record<string, unknown>>
|
|
67
|
+
* → Map { 'Profile' → '<Record<string, unknown>>' }
|
|
68
|
+
*
|
|
69
|
+
* Non-generic models are not included in the map.
|
|
26
70
|
*/
|
|
27
|
-
export function
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
71
|
+
export function buildGenericModelDefaults(models: Model[]): Map<string, string> {
|
|
72
|
+
const result = new Map<string, string>();
|
|
73
|
+
for (const model of models) {
|
|
74
|
+
if (!model.typeParams?.length) continue;
|
|
75
|
+
const defaults = model.typeParams.map((tp) => (tp.default ? mapTypeRef(tp.default) : 'unknown'));
|
|
76
|
+
result.set(model.name, `<${defaults.join(', ')}>`);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
33
80
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
referencedModels.add(name);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
81
|
+
/**
|
|
82
|
+
* Remove unused imports from generated source code.
|
|
83
|
+
* Scans the non-import body for each imported identifier and drops
|
|
84
|
+
* individual names that are never referenced. Removes entire import
|
|
85
|
+
* statements when no names are used.
|
|
86
|
+
*/
|
|
87
|
+
export function pruneUnusedImports(lines: string[]): string[] {
|
|
88
|
+
// Split lines into imports and body
|
|
89
|
+
const importLines: string[] = [];
|
|
90
|
+
const bodyLines: string[] = [];
|
|
91
|
+
let inBody = false;
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
if (!inBody && (line.startsWith('import ') || line === '')) {
|
|
94
|
+
importLines.push(line);
|
|
95
|
+
} else {
|
|
96
|
+
inBody = true;
|
|
97
|
+
bodyLines.push(line);
|
|
54
98
|
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const body = bodyLines.join('\n');
|
|
102
|
+
const kept: string[] = [];
|
|
55
103
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
104
|
+
for (const line of importLines) {
|
|
105
|
+
if (line === '') {
|
|
106
|
+
kept.push(line);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// Extract imported names from the import statement
|
|
110
|
+
const match = line.match(/\{([^}]+)\}/);
|
|
111
|
+
if (!match) {
|
|
112
|
+
// Non-destructured import (e.g., import X from '...') — keep
|
|
113
|
+
kept.push(line);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const names = match[1]
|
|
117
|
+
.split(',')
|
|
118
|
+
.map((n) => n.trim())
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
// Filter to only names that appear in the body
|
|
121
|
+
const usedNames = names.filter((name) => {
|
|
122
|
+
const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
123
|
+
return re.test(body);
|
|
124
|
+
});
|
|
125
|
+
if (usedNames.length === 0) {
|
|
126
|
+
// No names used — drop entire import
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (usedNames.length === names.length) {
|
|
130
|
+
// All names used — keep original line
|
|
131
|
+
kept.push(line);
|
|
132
|
+
} else {
|
|
133
|
+
// Some names unused — reconstruct import with only used names
|
|
134
|
+
const isTypeImport = line.startsWith('import type');
|
|
135
|
+
const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
|
|
136
|
+
if (fromMatch) {
|
|
137
|
+
const prefix = isTypeImport ? 'import type' : 'import';
|
|
138
|
+
kept.push(`${prefix} { ${usedNames.join(', ')} } from '${fromMatch[1]}';`);
|
|
139
|
+
} else {
|
|
140
|
+
kept.push(line);
|
|
69
141
|
}
|
|
70
142
|
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return [...kept, ...bodyLines];
|
|
146
|
+
}
|
|
71
147
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
148
|
+
/** Built-in TypeScript types that are always available (no import needed). */
|
|
149
|
+
export const TS_BUILTINS = new Set([
|
|
150
|
+
'Record',
|
|
151
|
+
'Promise',
|
|
152
|
+
'Array',
|
|
153
|
+
'Map',
|
|
154
|
+
'Set',
|
|
155
|
+
'Date',
|
|
156
|
+
'string',
|
|
157
|
+
'number',
|
|
158
|
+
'boolean',
|
|
159
|
+
'void',
|
|
160
|
+
'null',
|
|
161
|
+
'undefined',
|
|
162
|
+
'any',
|
|
163
|
+
'never',
|
|
164
|
+
'unknown',
|
|
165
|
+
'true',
|
|
166
|
+
'false',
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Detect whether the existing SDK uses string (ISO 8601) representation for
|
|
171
|
+
* date-time fields. Checks if any baseline interface has a date-time IR field
|
|
172
|
+
* typed as plain `string` (not `Date`).
|
|
173
|
+
*/
|
|
174
|
+
export function detectStringDateConvention(models: Model[], ctx: EmitterContext): boolean {
|
|
175
|
+
if (!ctx.apiSurface?.interfaces) return false;
|
|
176
|
+
for (const model of models) {
|
|
177
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
178
|
+
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
179
|
+
if (!baseline?.fields) continue;
|
|
180
|
+
for (const field of model.fields) {
|
|
181
|
+
if (field.type.kind !== 'primitive' || field.type.format !== 'date-time') continue;
|
|
182
|
+
const baselineField = baseline.fields[fieldName(field.name)];
|
|
183
|
+
if (baselineField && !baselineField.type.includes('Date')) {
|
|
184
|
+
return true;
|
|
76
185
|
}
|
|
77
186
|
}
|
|
78
187
|
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
79
190
|
|
|
80
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Build a comprehensive set of all known type names from the IR and baseline.
|
|
193
|
+
* Used to identify type parameters by elimination — any PascalCase name not in
|
|
194
|
+
* this set is likely a generic type parameter.
|
|
195
|
+
*/
|
|
196
|
+
export function buildKnownTypeNames(models: Model[], ctx: EmitterContext): Set<string> {
|
|
197
|
+
const knownNames = new Set<string>();
|
|
198
|
+
for (const m of models) knownNames.add(resolveInterfaceName(m.name, ctx));
|
|
199
|
+
for (const e of ctx.spec.enums) knownNames.add(e.name);
|
|
200
|
+
if (ctx.apiSurface?.interfaces) {
|
|
201
|
+
for (const name of Object.keys(ctx.apiSurface.interfaces)) knownNames.add(name);
|
|
202
|
+
}
|
|
203
|
+
if (ctx.apiSurface?.typeAliases) {
|
|
204
|
+
for (const name of Object.keys(ctx.apiSurface.typeAliases)) knownNames.add(name);
|
|
205
|
+
}
|
|
206
|
+
if (ctx.apiSurface?.enums) {
|
|
207
|
+
for (const name of Object.keys(ctx.apiSurface.enums)) knownNames.add(name);
|
|
208
|
+
}
|
|
209
|
+
return knownNames;
|
|
81
210
|
}
|
|
82
211
|
|
|
83
212
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
213
|
+
* Create a service directory resolver bundle.
|
|
214
|
+
* Encapsulates the common pattern of mapping models to services and resolving
|
|
215
|
+
* the output directory for a given IR service name.
|
|
86
216
|
*/
|
|
87
|
-
export function
|
|
88
|
-
models:
|
|
89
|
-
|
|
217
|
+
export function createServiceDirResolver(
|
|
218
|
+
models: Model[],
|
|
219
|
+
services: Service[],
|
|
220
|
+
ctx: EmitterContext,
|
|
221
|
+
): {
|
|
222
|
+
modelToService: Map<string, string>;
|
|
223
|
+
serviceNameMap: Map<string, string>;
|
|
224
|
+
resolveDir: (irService: string | undefined) => string;
|
|
90
225
|
} {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
226
|
+
const modelToService = assignModelsToServices(models, services);
|
|
227
|
+
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
228
|
+
const resolveDir = (irService: string | undefined) =>
|
|
229
|
+
irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
230
|
+
return { modelToService, serviceNameMap, resolveDir };
|
|
231
|
+
}
|
|
93
232
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Check if a set of baseline interface fields appears to contain generic type
|
|
235
|
+
* parameters — PascalCase names that aren't known models, enums, or builtins.
|
|
236
|
+
*/
|
|
237
|
+
export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: Set<string>): boolean {
|
|
238
|
+
for (const [, bf] of Object.entries(fields)) {
|
|
239
|
+
const fieldType = (bf as { type: string }).type;
|
|
240
|
+
const typeNames = fieldType.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
|
|
241
|
+
if (!typeNames) continue;
|
|
242
|
+
for (const tn of typeNames) {
|
|
243
|
+
if (TS_BUILTINS.has(tn)) continue;
|
|
244
|
+
if (knownNames.has(tn)) continue;
|
|
245
|
+
return true;
|
|
100
246
|
}
|
|
101
247
|
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Detect whether a model matches the standard list-metadata shape:
|
|
253
|
+
* exactly 2 fields named `before` and `after`, both nullable string.
|
|
254
|
+
*
|
|
255
|
+
* These models are redundant because the SDK already has a shared
|
|
256
|
+
* `ListMetadata` type in `src/common/utils/pagination.ts`.
|
|
257
|
+
*/
|
|
258
|
+
export function isListMetadataModel(model: Model): boolean {
|
|
259
|
+
if (model.fields.length !== 2) return false;
|
|
102
260
|
|
|
103
|
-
|
|
261
|
+
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
262
|
+
const before = fieldsByName.get('before');
|
|
263
|
+
const after = fieldsByName.get('after');
|
|
264
|
+
|
|
265
|
+
if (!before || !after) return false;
|
|
266
|
+
|
|
267
|
+
return isNullableString(before) && isNullableString(after);
|
|
104
268
|
}
|
|
105
269
|
|
|
106
270
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
271
|
+
* Detect whether a model is a list wrapper — the standard paginated
|
|
272
|
+
* list envelope with `data` (array), `list_metadata`, and `object: 'list'`.
|
|
273
|
+
*
|
|
274
|
+
* These models are redundant because the SDK already has `List<T>` and
|
|
275
|
+
* `ListResponse<T>` in `src/common/utils/pagination.ts`, and the shared
|
|
276
|
+
* `deserializeList` handles deserialization.
|
|
109
277
|
*/
|
|
110
|
-
export function
|
|
111
|
-
const
|
|
112
|
-
const toFileParts = toFile.split('/');
|
|
113
|
-
const toDir = toFileParts.slice(0, -1);
|
|
114
|
-
const toFileName = toFileParts[toFileParts.length - 1];
|
|
278
|
+
export function isListWrapperModel(model: Model): boolean {
|
|
279
|
+
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
115
280
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
281
|
+
// Must have a `data` field that is an array type
|
|
282
|
+
const dataField = fieldsByName.get('data');
|
|
283
|
+
if (!dataField) return false;
|
|
284
|
+
if (dataField.type.kind !== 'array') return false;
|
|
285
|
+
|
|
286
|
+
// Must have a `list_metadata` field (the IR uses snake_case names)
|
|
287
|
+
const listMetadataField = fieldsByName.get('list_metadata');
|
|
288
|
+
if (!listMetadataField) return false;
|
|
289
|
+
|
|
290
|
+
// Optionally has an `object` field with literal value 'list'
|
|
291
|
+
const objectField = fieldsByName.get('object');
|
|
292
|
+
if (objectField) {
|
|
293
|
+
if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
119
296
|
}
|
|
120
297
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Check if a field type is nullable string (nullable<string> or just string). */
|
|
302
|
+
function isNullableString(field: Field): boolean {
|
|
303
|
+
const { type } = field;
|
|
304
|
+
if (type.kind === 'nullable') {
|
|
305
|
+
return type.inner.kind === 'primitive' && type.inner.type === 'string';
|
|
306
|
+
}
|
|
307
|
+
if (type.kind === 'primitive') {
|
|
308
|
+
return type.type === 'string';
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
128
311
|
}
|
|
129
312
|
|
|
130
313
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* Returns the lines with the given indent (default 0 spaces).
|
|
314
|
+
* Compute a structural fingerprint for a model based on its fields.
|
|
315
|
+
* Two models with identical fingerprints are structurally equivalent.
|
|
134
316
|
*/
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
317
|
+
function modelFingerprint(model: Model): string {
|
|
318
|
+
const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
|
|
319
|
+
return fields.join('|');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Find structurally identical models and build a deduplication map.
|
|
324
|
+
* Also deduplicates models that resolve to the same interface name across
|
|
325
|
+
* services — when a `$ref` schema is used by multiple tags, the IR may
|
|
326
|
+
* produce per-tag copies that diverge slightly. The version with the most
|
|
327
|
+
* fields is chosen as canonical.
|
|
328
|
+
*
|
|
329
|
+
* Returns a Map from duplicate model name → canonical model name.
|
|
330
|
+
*/
|
|
331
|
+
export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Map<string, string> {
|
|
332
|
+
const dedup = new Map<string, string>();
|
|
333
|
+
|
|
334
|
+
// Pass 1: structural fingerprint dedup (exact match)
|
|
335
|
+
const fingerprints = new Map<string, string>();
|
|
336
|
+
for (const model of models) {
|
|
337
|
+
if (model.fields.length === 0) continue;
|
|
338
|
+
const fp = modelFingerprint(model);
|
|
339
|
+
const existing = fingerprints.get(fp);
|
|
340
|
+
if (existing) {
|
|
341
|
+
dedup.set(model.name, existing);
|
|
342
|
+
} else {
|
|
343
|
+
fingerprints.set(fp, model.name);
|
|
344
|
+
}
|
|
140
345
|
}
|
|
141
|
-
|
|
142
|
-
for
|
|
143
|
-
|
|
346
|
+
|
|
347
|
+
// Pass 2: name-based dedup for models that resolve to the same interface
|
|
348
|
+
// name across services. Only applies when context with name resolution is
|
|
349
|
+
// available. Picks the model with the most fields as canonical.
|
|
350
|
+
if (ctx) {
|
|
351
|
+
const byDomainName = new Map<string, Model[]>();
|
|
352
|
+
for (const model of models) {
|
|
353
|
+
if (model.fields.length === 0) continue;
|
|
354
|
+
if (dedup.has(model.name)) continue; // already deduped in pass 1
|
|
355
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
356
|
+
const group = byDomainName.get(domainName);
|
|
357
|
+
if (group) {
|
|
358
|
+
group.push(model);
|
|
359
|
+
} else {
|
|
360
|
+
byDomainName.set(domainName, [model]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const [, group] of byDomainName) {
|
|
364
|
+
if (group.length < 2) continue;
|
|
365
|
+
// Choose canonical: most fields, then alphabetically by name
|
|
366
|
+
group.sort((a, b) => b.fields.length - a.fields.length || a.name.localeCompare(b.name));
|
|
367
|
+
const canonical = group[0];
|
|
368
|
+
for (let i = 1; i < group.length; i++) {
|
|
369
|
+
dedup.set(group[i].name, canonical.name);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
144
372
|
}
|
|
145
|
-
|
|
146
|
-
return
|
|
373
|
+
|
|
374
|
+
return dedup;
|
|
147
375
|
}
|
|
148
376
|
|
|
149
377
|
/**
|
|
150
|
-
*
|
|
378
|
+
* Check whether a service's endpoints are already fully covered by existing
|
|
379
|
+
* hand-written service classes.
|
|
380
|
+
*
|
|
381
|
+
* A service is considered "covered" when:
|
|
382
|
+
* 1. **Every** operation in it appears in `overlayLookup.methodByOperation`
|
|
383
|
+
* 2. The overlay maps those operations to a class that exists in the baseline
|
|
384
|
+
* `apiSurface` (confirming the hand-written class is actually present)
|
|
385
|
+
*
|
|
386
|
+
* Services with zero operations are never considered covered (nothing to
|
|
387
|
+
* deduplicate). When no `apiSurface` is available, the overlay alone is
|
|
388
|
+
* used as the coverage signal (the overlay is only built from existing code).
|
|
389
|
+
*
|
|
390
|
+
* This prevents the emitter from generating resource classes like `Connections`
|
|
391
|
+
* that would duplicate hand-written modules like `SSO` for the same API
|
|
392
|
+
* endpoints (e.g., `GET /connections`).
|
|
151
393
|
*/
|
|
152
|
-
export function
|
|
153
|
-
|
|
154
|
-
|
|
394
|
+
export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
|
|
395
|
+
// Explicit override: services known to be covered by existing hand-written classes
|
|
396
|
+
if (SERVICE_COVERED_BY[toPascalCase(service.name)]) return true;
|
|
397
|
+
|
|
398
|
+
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
399
|
+
if (!overlay || overlay.size === 0) return false;
|
|
400
|
+
if (service.operations.length === 0) return false;
|
|
401
|
+
|
|
402
|
+
// Collect the set of existing class names from the baseline surface.
|
|
403
|
+
// When no apiSurface is available, the overlay alone cannot confirm that
|
|
404
|
+
// a hand-written class exists — it may only carry naming hints.
|
|
405
|
+
const baselineClasses = ctx.apiSurface?.classes;
|
|
406
|
+
if (!baselineClasses) return false;
|
|
407
|
+
const existingClassNames = new Set(Object.keys(baselineClasses));
|
|
408
|
+
|
|
409
|
+
// Check that every operation is in the overlay AND the overlay's target class
|
|
410
|
+
// exists in the baseline.
|
|
411
|
+
return service.operations.every((op: Operation) => {
|
|
412
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
413
|
+
const match = overlay.get(httpKey);
|
|
414
|
+
if (!match) return false;
|
|
415
|
+
return existingClassNames.has(match.className);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Check whether a fully-covered service has operations whose overlay-mapped
|
|
421
|
+
* methods are missing from the baseline class. Returns true when at least
|
|
422
|
+
* one operation maps to a method name that the baseline class does not have,
|
|
423
|
+
* meaning the merger needs to add new methods (skipIfExists must be removed).
|
|
424
|
+
*/
|
|
425
|
+
export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterContext): boolean {
|
|
426
|
+
const baselineClasses = ctx.apiSurface?.classes;
|
|
427
|
+
if (!baselineClasses) return false;
|
|
428
|
+
|
|
429
|
+
// For services explicitly mapped to an existing class via SERVICE_COVERED_BY,
|
|
430
|
+
// check each operation's resolved method name against the target class directly.
|
|
431
|
+
// This avoids the overlay gap where new endpoints are silently skipped.
|
|
432
|
+
const targetClassName = SERVICE_COVERED_BY[toPascalCase(service.name)];
|
|
433
|
+
if (targetClassName) {
|
|
434
|
+
const cls = baselineClasses[targetClassName];
|
|
435
|
+
if (!cls) return true; // Target class missing from baseline — treat as absent
|
|
155
436
|
for (const op of service.operations) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
result.add(name);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
437
|
+
const method = resolveMethodName(op, service, ctx);
|
|
438
|
+
if (!cls.methods?.[method]) return true;
|
|
161
439
|
}
|
|
440
|
+
return false;
|
|
162
441
|
}
|
|
163
|
-
|
|
442
|
+
|
|
443
|
+
// Default overlay-based detection
|
|
444
|
+
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
445
|
+
if (!overlay) return false;
|
|
446
|
+
|
|
447
|
+
for (const op of service.operations) {
|
|
448
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
449
|
+
const match = overlay.get(httpKey);
|
|
450
|
+
if (!match) continue;
|
|
451
|
+
const cls = baselineClasses[match.className];
|
|
452
|
+
if (!cls) continue;
|
|
453
|
+
if (!cls.methods?.[match.methodName]) return true;
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Return operations in a service that are NOT covered by existing hand-written
|
|
460
|
+
* service classes. For fully uncovered services, returns all operations.
|
|
461
|
+
* For partially covered services, returns only the uncovered operations.
|
|
462
|
+
*/
|
|
463
|
+
export function uncoveredOperations(service: Service, ctx: EmitterContext): Operation[] {
|
|
464
|
+
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
465
|
+
if (!overlay || overlay.size === 0) return service.operations;
|
|
466
|
+
|
|
467
|
+
const baselineClasses = ctx.apiSurface?.classes;
|
|
468
|
+
if (!baselineClasses) return service.operations;
|
|
469
|
+
const existingClassNames = new Set(Object.keys(baselineClasses));
|
|
470
|
+
|
|
471
|
+
return service.operations.filter((op: Operation) => {
|
|
472
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
473
|
+
const match = overlay.get(httpKey);
|
|
474
|
+
if (!match) return true; // Not in overlay → uncovered
|
|
475
|
+
return !existingClassNames.has(match.className); // Class doesn't exist → uncovered
|
|
476
|
+
});
|
|
164
477
|
}
|