@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/models.ts
CHANGED
|
@@ -1,48 +1,121 @@
|
|
|
1
|
-
import type { Model, Field, EmitterContext, GeneratedFile
|
|
2
|
-
import { walkTypeRef } from '@workos/oagen';
|
|
1
|
+
import type { Model, Field, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
3
2
|
import { mapTypeRef, mapWireTypeRef } from './type-map.js';
|
|
3
|
+
import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
5
|
+
collectFieldDependencies,
|
|
6
|
+
docComment,
|
|
7
|
+
buildGenericModelDefaults,
|
|
8
|
+
pruneUnusedImports,
|
|
9
|
+
TS_BUILTINS,
|
|
10
|
+
detectStringDateConvention,
|
|
11
|
+
buildKnownTypeNames,
|
|
12
|
+
isBaselineGeneric,
|
|
13
|
+
createServiceDirResolver,
|
|
14
|
+
isListMetadataModel,
|
|
15
|
+
isListWrapperModel,
|
|
16
|
+
buildDeduplicationMap,
|
|
17
|
+
} from './utils.js';
|
|
18
|
+
import { assignEnumsToServices } from './enums.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect baseline interfaces that are generic (have type parameters) even though
|
|
22
|
+
* the IR model has no typeParams (OpenAPI doesn't support generics).
|
|
23
|
+
*
|
|
24
|
+
* Heuristic: if any field type in the baseline interface contains a PascalCase
|
|
25
|
+
* name that isn't a known model, enum, or builtin, it's likely a type parameter
|
|
26
|
+
* (e.g., `CustomAttributesType`), indicating the interface is generic.
|
|
27
|
+
*
|
|
28
|
+
* When detected, adds a default generic type arg so references like `Profile`
|
|
29
|
+
* become `Profile<Record<string, unknown>>`.
|
|
30
|
+
*/
|
|
31
|
+
function enrichGenericDefaultsFromBaseline(
|
|
32
|
+
genericDefaults: Map<string, string>,
|
|
33
|
+
models: Model[],
|
|
34
|
+
ctx: EmitterContext,
|
|
35
|
+
resolveDir: (irService: string | undefined) => string,
|
|
36
|
+
modelToService: Map<string, string>,
|
|
37
|
+
): void {
|
|
38
|
+
if (!ctx.apiSurface?.interfaces) return;
|
|
39
|
+
const knownNames = buildKnownTypeNames(models, ctx);
|
|
40
|
+
|
|
41
|
+
for (const model of models) {
|
|
42
|
+
if (genericDefaults.has(model.name)) continue; // IR already handles it
|
|
43
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
44
|
+
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
45
|
+
if (!baseline?.fields) continue;
|
|
46
|
+
|
|
47
|
+
// Only enrich generic defaults for models whose baseline file will be
|
|
48
|
+
// preserved via skipIfExists (paths match). If the file is generated
|
|
49
|
+
// fresh in a new directory, it won't have generics, so references
|
|
50
|
+
// to it don't need type args.
|
|
51
|
+
const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
52
|
+
const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
|
|
53
|
+
if (baselineSourceFile && baselineSourceFile !== generatedPath) continue;
|
|
54
|
+
|
|
55
|
+
if (isBaselineGeneric(baseline.fields, knownNames)) {
|
|
56
|
+
genericDefaults.set(model.name, '<Record<string, unknown>>');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
35
60
|
|
|
36
61
|
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
37
62
|
if (models.length === 0) return [];
|
|
38
63
|
|
|
39
|
-
const modelToService =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
65
|
+
// Detect whether the existing SDK uses string dates (ISO 8601) rather than Date objects.
|
|
66
|
+
// When detected, newly generated models also use string to maintain consistency.
|
|
67
|
+
const useStringDates = detectStringDateConvention(models, ctx);
|
|
68
|
+
const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
|
|
69
|
+
// Enrich genericDefaults from baseline interfaces that appear to be generic.
|
|
70
|
+
// The IR doesn't carry typeParams for models parsed from OpenAPI (which has no
|
|
71
|
+
// generics), but the existing SDK may have hand-written generic interfaces
|
|
72
|
+
// (e.g., Profile<CustomAttributesType>). Detect these by checking if any
|
|
73
|
+
// field type contains a PascalCase name that isn't a known model, enum, or builtin.
|
|
74
|
+
enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
|
|
75
|
+
const typeRefOpts = useStringDates ? { stringDates: true, genericDefaults } : { genericDefaults };
|
|
76
|
+
const wireTypeRefOpts = { genericDefaults };
|
|
43
77
|
const files: GeneratedFile[] = [];
|
|
44
78
|
|
|
79
|
+
// Detect structurally identical or same-name models — emit type aliases for duplicates
|
|
80
|
+
const dedup = buildDeduplicationMap(models, ctx);
|
|
81
|
+
|
|
45
82
|
for (const model of models) {
|
|
83
|
+
// Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
|
|
84
|
+
if (isListMetadataModel(model)) continue;
|
|
85
|
+
|
|
86
|
+
// Fix #6: Skip per-domain list wrapper interfaces — the shared List<T>/ListResponse<T> covers these
|
|
87
|
+
if (isListWrapperModel(model)) continue;
|
|
88
|
+
|
|
89
|
+
// Deduplication: if this model is structurally identical to a canonical model,
|
|
90
|
+
// emit a type alias instead of a full interface.
|
|
91
|
+
const canonicalName = dedup.get(model.name);
|
|
92
|
+
if (canonicalName) {
|
|
93
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
94
|
+
const responseName = wireInterfaceName(domainName);
|
|
95
|
+
const canonDomainName = resolveInterfaceName(canonicalName, ctx);
|
|
96
|
+
const canonResponseName = wireInterfaceName(canonDomainName);
|
|
97
|
+
const service = modelToService.get(model.name);
|
|
98
|
+
const dirName = resolveDir(service);
|
|
99
|
+
const canonService = modelToService.get(canonicalName);
|
|
100
|
+
const canonDir = resolveDir(canonService);
|
|
101
|
+
const canonRelPath =
|
|
102
|
+
canonDir === dirName
|
|
103
|
+
? `./${fileName(canonicalName)}.interface`
|
|
104
|
+
: `../../${canonDir}/interfaces/${fileName(canonicalName)}.interface`;
|
|
105
|
+
const aliasLines = [
|
|
106
|
+
`import type { ${canonDomainName}, ${canonResponseName} } from '${canonRelPath}';`,
|
|
107
|
+
'',
|
|
108
|
+
`export type ${domainName} = ${canonDomainName};`,
|
|
109
|
+
`export type ${responseName} = ${canonResponseName};`,
|
|
110
|
+
];
|
|
111
|
+
files.push({
|
|
112
|
+
path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
|
|
113
|
+
content: aliasLines.join('\n'),
|
|
114
|
+
skipIfExists: true,
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
46
119
|
const service = modelToService.get(model.name);
|
|
47
120
|
const dirName = resolveDir(service);
|
|
48
121
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
@@ -50,6 +123,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
50
123
|
const deps = collectFieldDependencies(model);
|
|
51
124
|
const lines: string[] = [];
|
|
52
125
|
|
|
126
|
+
// Exclude the current model from generic defaults to avoid self-referencing
|
|
127
|
+
// (e.g., Profile's own fields should use TCustom, not Profile<Record<...>>)
|
|
128
|
+
let modelTypeRefOpts = typeRefOpts;
|
|
129
|
+
let modelWireTypeRefOpts = wireTypeRefOpts;
|
|
130
|
+
if (genericDefaults.has(model.name)) {
|
|
131
|
+
const filteredDefaults = new Map(genericDefaults);
|
|
132
|
+
filteredDefaults.delete(model.name);
|
|
133
|
+
modelTypeRefOpts = { ...typeRefOpts, genericDefaults: filteredDefaults };
|
|
134
|
+
modelWireTypeRefOpts = { genericDefaults: filteredDefaults };
|
|
135
|
+
}
|
|
136
|
+
|
|
53
137
|
// Baseline interface data (for compat field type matching)
|
|
54
138
|
const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
|
|
55
139
|
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
@@ -71,9 +155,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
71
155
|
// Pre-pass: discover baseline type names that aren't directly importable.
|
|
72
156
|
// For each unresolvable name we either:
|
|
73
157
|
// 1. Import the real type from another service (if it exists as an enum/model there)
|
|
74
|
-
// 2. Create a local type
|
|
158
|
+
// 2. Create a local type alias from a suffix match
|
|
159
|
+
// 3. Mark as unresolvable — the field will fall back to the IR-generated type
|
|
75
160
|
const typeDecls = new Map<string, string>(); // aliasName → type expression
|
|
76
161
|
const crossServiceImports = new Map<string, { name: string; relPath: string }>(); // extra imports
|
|
162
|
+
const unresolvableNames = new Set<string>(); // names that can't be resolved — forces IR fallback
|
|
77
163
|
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
|
|
78
164
|
// Build a lookup: resolved enum name → IR enum name
|
|
79
165
|
const resolvedEnumNames = new Map<string, string>();
|
|
@@ -92,10 +178,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
92
178
|
if (!names) continue;
|
|
93
179
|
|
|
94
180
|
for (const name of names) {
|
|
95
|
-
if (
|
|
181
|
+
if (TS_BUILTINS.has(name)) continue;
|
|
96
182
|
if (importableNames.has(name)) continue;
|
|
97
183
|
if (typeDecls.has(name)) continue;
|
|
98
184
|
if (crossServiceImports.has(name)) continue;
|
|
185
|
+
if (unresolvableNames.has(name)) continue;
|
|
99
186
|
|
|
100
187
|
// Check if this name exists as an enum in another service —
|
|
101
188
|
// import the actual type so the extractor sees the real name
|
|
@@ -119,11 +206,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
119
206
|
typeDecls.set(name, candidates[0]);
|
|
120
207
|
importableNames.add(name);
|
|
121
208
|
} else {
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
importableNames.add(name);
|
|
209
|
+
// Cannot resolve this baseline type name — mark it so the field
|
|
210
|
+
// falls back to the IR-generated type instead of the baseline.
|
|
211
|
+
// This avoids creating type aliases that reference undefined types.
|
|
212
|
+
unresolvableNames.add(name);
|
|
127
213
|
}
|
|
128
214
|
}
|
|
129
215
|
}
|
|
@@ -157,75 +243,111 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
157
243
|
}
|
|
158
244
|
if (typeDecls.size > 0) lines.push('');
|
|
159
245
|
|
|
160
|
-
// Type params (generics)
|
|
161
|
-
|
|
246
|
+
// Type params (generics) — pass genericDefaults so baseline-detected generics
|
|
247
|
+
// also get type parameter declarations on the interface itself.
|
|
248
|
+
const typeParams = renderTypeParams(model, genericDefaults);
|
|
162
249
|
|
|
163
250
|
// Domain interface (camelCase fields) — deduplicate by camelCase name
|
|
164
251
|
const seenDomainFields = new Set<string>();
|
|
165
252
|
if (model.description) {
|
|
166
253
|
lines.push(...docComment(model.description));
|
|
167
254
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (field.deprecated
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
255
|
+
if (model.fields.length === 0) {
|
|
256
|
+
lines.push(`export type ${domainName}${typeParams} = object;`);
|
|
257
|
+
} else {
|
|
258
|
+
lines.push(`export interface ${domainName}${typeParams} {`);
|
|
259
|
+
for (const field of model.fields) {
|
|
260
|
+
const domainFieldName = fieldName(field.name);
|
|
261
|
+
if (seenDomainFields.has(domainFieldName)) continue;
|
|
262
|
+
seenDomainFields.add(domainFieldName);
|
|
263
|
+
if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
|
|
264
|
+
const parts: string[] = [];
|
|
265
|
+
if (field.description) parts.push(field.description);
|
|
266
|
+
if (field.readOnly) parts.push('@readonly');
|
|
267
|
+
if (field.writeOnly) parts.push('@writeonly');
|
|
268
|
+
if (field.default !== undefined) parts.push(`@default ${JSON.stringify(field.default)}`);
|
|
269
|
+
if (field.deprecated) parts.push('@deprecated');
|
|
270
|
+
lines.push(...docComment(parts.join('\n'), 2));
|
|
271
|
+
}
|
|
272
|
+
const baselineField = baselineDomain?.fields?.[domainFieldName];
|
|
273
|
+
// For the domain interface, also check that the response baseline's optionality
|
|
274
|
+
// is compatible — the serializer reads from the response type and assigns to the domain type.
|
|
275
|
+
// If the domain baseline says required but the response baseline says optional,
|
|
276
|
+
// the serializer would produce T | undefined for a field expecting T.
|
|
277
|
+
const domainWireField = wireFieldName(field.name);
|
|
278
|
+
const responseBaselineField = baselineResponse?.fields?.[domainWireField];
|
|
279
|
+
const domainResponseOptionalMismatch =
|
|
280
|
+
baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
|
|
281
|
+
const readonlyPrefix = field.readOnly ? 'readonly ' : '';
|
|
282
|
+
if (
|
|
283
|
+
baselineField &&
|
|
284
|
+
!domainResponseOptionalMismatch &&
|
|
285
|
+
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
286
|
+
baselineFieldCompatible(baselineField, field)
|
|
287
|
+
) {
|
|
288
|
+
const opt = baselineField.optional ? '?' : '';
|
|
289
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
|
|
290
|
+
} else {
|
|
291
|
+
// When a baseline exists for this model, new fields (not present in the
|
|
292
|
+
// baseline) are generated as optional. The merger can deep-merge new
|
|
293
|
+
// fields into existing interfaces, but it cannot update existing
|
|
294
|
+
// deserializer function bodies. Making the field optional prevents a
|
|
295
|
+
// type error where the interface requires a field that the preserved
|
|
296
|
+
// deserializer never populates.
|
|
297
|
+
const isNewFieldOnExistingModel = baselineDomain && !baselineField;
|
|
298
|
+
// Also make the field optional when the response baseline has it as optional
|
|
299
|
+
// but the domain baseline has it as required — the deserializer reads from
|
|
300
|
+
// the response type, so if the response field is optional, the domain value
|
|
301
|
+
// may be undefined.
|
|
302
|
+
// Additionally, when a baseline exists for the RESPONSE interface but NOT the
|
|
303
|
+
// domain interface, fields that are new on the response baseline become optional
|
|
304
|
+
// in the wire type. The domain type must also be optional to match, otherwise
|
|
305
|
+
// the deserializer produces T | undefined for a field typed as T.
|
|
306
|
+
const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
|
|
307
|
+
const opt =
|
|
308
|
+
!field.required ||
|
|
309
|
+
isNewFieldOnExistingModel ||
|
|
310
|
+
domainResponseOptionalMismatch ||
|
|
311
|
+
isNewFieldOnExistingResponse
|
|
312
|
+
? '?'
|
|
313
|
+
: '';
|
|
314
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef(field.type, modelTypeRefOpts)};`);
|
|
315
|
+
}
|
|
199
316
|
}
|
|
200
|
-
|
|
201
|
-
|
|
317
|
+
lines.push('}');
|
|
318
|
+
} // close else for non-empty domain interface
|
|
202
319
|
lines.push('');
|
|
203
320
|
|
|
204
321
|
// Wire/response interface (snake_case fields) — deduplicate by snake_case name
|
|
205
322
|
const seenWireFields = new Set<string>();
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
323
|
+
if (model.fields.length === 0) {
|
|
324
|
+
lines.push(`export type ${responseName}${typeParams} = object;`);
|
|
325
|
+
} else {
|
|
326
|
+
lines.push(`export interface ${responseName}${typeParams} {`);
|
|
327
|
+
for (const field of model.fields) {
|
|
328
|
+
const wireField = wireFieldName(field.name);
|
|
329
|
+
if (seenWireFields.has(wireField)) continue;
|
|
330
|
+
seenWireFields.add(wireField);
|
|
331
|
+
const baselineField = baselineResponse?.fields?.[wireField];
|
|
332
|
+
if (
|
|
333
|
+
baselineField &&
|
|
334
|
+
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
335
|
+
baselineFieldCompatible(baselineField, field)
|
|
336
|
+
) {
|
|
337
|
+
const opt = baselineField.optional ? '?' : '';
|
|
338
|
+
lines.push(` ${wireField}${opt}: ${baselineField.type};`);
|
|
339
|
+
} else {
|
|
340
|
+
const isNewFieldOnExistingModel = baselineResponse && !baselineField;
|
|
341
|
+
const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
|
|
342
|
+
lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
|
|
343
|
+
}
|
|
222
344
|
}
|
|
223
|
-
|
|
224
|
-
|
|
345
|
+
lines.push('}');
|
|
346
|
+
} // close else for non-empty wire interface
|
|
225
347
|
|
|
226
348
|
files.push({
|
|
227
349
|
path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
|
|
228
|
-
content: lines.join('\n'),
|
|
350
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
229
351
|
skipIfExists: true,
|
|
230
352
|
});
|
|
231
353
|
}
|
|
@@ -246,7 +368,7 @@ function baselineTypeResolvable(typeStr: string, importableNames: Set<string>):
|
|
|
246
368
|
if (!matches) return true;
|
|
247
369
|
|
|
248
370
|
for (const name of matches) {
|
|
249
|
-
if (
|
|
371
|
+
if (TS_BUILTINS.has(name)) continue;
|
|
250
372
|
if (importableNames.has(name)) continue;
|
|
251
373
|
return false;
|
|
252
374
|
}
|
|
@@ -286,38 +408,43 @@ function baselineFieldCompatible(baselineField: { type: string; optional: boolea
|
|
|
286
408
|
// the serializer produces a definite value but the interface is looser — that's OK
|
|
287
409
|
// (the domain type is wider than the serializer output)
|
|
288
410
|
|
|
411
|
+
// If the baseline type is Record<string, unknown> but the IR field has a more specific
|
|
412
|
+
// type (model, enum, or union with named variants), prefer the IR type for better type safety
|
|
413
|
+
if (baselineField.type === 'Record<string, unknown>' && hasSpecificIRType(irField.type)) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
289
417
|
return true;
|
|
290
418
|
}
|
|
291
419
|
|
|
292
|
-
|
|
293
|
-
|
|
420
|
+
/** Check if an IR type is more specific than Record<string, unknown>. */
|
|
421
|
+
function hasSpecificIRType(ref: TypeRef): boolean {
|
|
422
|
+
switch (ref.kind) {
|
|
423
|
+
case 'model':
|
|
424
|
+
case 'enum':
|
|
425
|
+
return true;
|
|
426
|
+
case 'union':
|
|
427
|
+
// A union with named model/enum variants is more specific
|
|
428
|
+
return ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum');
|
|
429
|
+
case 'nullable':
|
|
430
|
+
return hasSpecificIRType(ref.inner);
|
|
431
|
+
default:
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
|
|
437
|
+
if (!model.typeParams?.length) {
|
|
438
|
+
// Fallback: if genericDefaults indicates this model is generic (detected
|
|
439
|
+
// from the baseline), generate a default generic type parameter declaration.
|
|
440
|
+
if (genericDefaults?.has(model.name)) {
|
|
441
|
+
return '<GenericType extends Record<string, unknown> = Record<string, unknown>>';
|
|
442
|
+
}
|
|
443
|
+
return '';
|
|
444
|
+
}
|
|
294
445
|
const params = model.typeParams.map((tp) => {
|
|
295
446
|
const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
|
|
296
447
|
return `${tp.name}${def}`;
|
|
297
448
|
});
|
|
298
449
|
return `<${params.join(', ')}>`;
|
|
299
450
|
}
|
|
300
|
-
|
|
301
|
-
function assignEnumsToServices(enums: { name: string }[], services: Service[]): Map<string, string> {
|
|
302
|
-
const enumToService = new Map<string, string>();
|
|
303
|
-
const enumNames = new Set(enums.map((e) => e.name));
|
|
304
|
-
for (const service of services) {
|
|
305
|
-
for (const op of service.operations) {
|
|
306
|
-
const refs = new Set<string>();
|
|
307
|
-
const collect = (ref: any) => {
|
|
308
|
-
walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
|
|
309
|
-
};
|
|
310
|
-
if (op.requestBody) collect(op.requestBody);
|
|
311
|
-
collect(op.response);
|
|
312
|
-
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
|
|
313
|
-
collect(p.type);
|
|
314
|
-
}
|
|
315
|
-
for (const name of refs) {
|
|
316
|
-
if (enumNames.has(name) && !enumToService.has(name)) {
|
|
317
|
-
enumToService.set(name, service.name);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
return enumToService;
|
|
323
|
-
}
|
package/src/node/naming.ts
CHANGED
|
@@ -66,26 +66,130 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
|
|
|
66
66
|
return map;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Explicit method name overrides for operations where the spec's operationId
|
|
71
|
+
* does not match the desired SDK method name and the spec cannot be changed.
|
|
72
|
+
* Key: "HTTP_METHOD /path", Value: camelCase method name.
|
|
73
|
+
*/
|
|
74
|
+
const METHOD_NAME_OVERRIDES: Record<string, string> = {
|
|
75
|
+
'POST /portal/generate_link': 'generatePortalLink',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Explicit service directory overrides. Maps a resolved PascalCase service name
|
|
80
|
+
* to a target directory (kebab-case). Use this when the spec's tag grouping
|
|
81
|
+
* does not match the desired SDK directory layout and the spec cannot be changed.
|
|
82
|
+
*/
|
|
83
|
+
const SERVICE_DIR_OVERRIDES: Record<string, string> = {
|
|
84
|
+
ApplicationClientSecrets: 'workos-connect',
|
|
85
|
+
Applications: 'workos-connect',
|
|
86
|
+
Connections: 'sso',
|
|
87
|
+
Directories: 'directory-sync',
|
|
88
|
+
DirectoryGroups: 'directory-sync',
|
|
89
|
+
DirectoryUsers: 'directory-sync',
|
|
90
|
+
FeatureFlagsTargets: 'feature-flags',
|
|
91
|
+
MultiFactorAuth: 'mfa',
|
|
92
|
+
MultiFactorAuthChallenges: 'mfa',
|
|
93
|
+
OrganizationsApiKeys: 'organizations',
|
|
94
|
+
WebhooksEndpoints: 'webhooks',
|
|
95
|
+
UserManagementAuthentication: 'user-management',
|
|
96
|
+
UserManagementCorsOrigins: 'user-management',
|
|
97
|
+
UserManagementDataProviders: 'user-management',
|
|
98
|
+
UserManagementInvitations: 'user-management',
|
|
99
|
+
UserManagementJWTTemplate: 'user-management',
|
|
100
|
+
UserManagementMagicAuth: 'user-management',
|
|
101
|
+
UserManagementMultiFactorAuthentication: 'user-management',
|
|
102
|
+
UserManagementOrganizationMembership: 'user-management',
|
|
103
|
+
UserManagementRedirectUris: 'user-management',
|
|
104
|
+
UserManagementSessionTokens: 'user-management',
|
|
105
|
+
UserManagementUsers: 'user-management',
|
|
106
|
+
UserManagementUsersAuthorizedApplications: 'user-management',
|
|
107
|
+
WorkOSConnect: 'workos-connect',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Maps a service (by PascalCase name) to the existing hand-written class that
|
|
112
|
+
* already covers its endpoints. When a service appears here:
|
|
113
|
+
* - `resolveClassName` returns the target class (so generated code merges in)
|
|
114
|
+
* - `isServiceCoveredByExisting` returns true
|
|
115
|
+
* - `hasMethodsAbsentFromBaseline` checks the target class for missing methods,
|
|
116
|
+
* so new endpoints are added to the existing class rather than silently dropped
|
|
117
|
+
*/
|
|
118
|
+
export const SERVICE_COVERED_BY: Record<string, string> = {
|
|
119
|
+
Connections: 'SSO',
|
|
120
|
+
Directories: 'DirectorySync',
|
|
121
|
+
DirectoryGroups: 'DirectorySync',
|
|
122
|
+
DirectoryUsers: 'DirectorySync',
|
|
123
|
+
FeatureFlagsTargets: 'FeatureFlags',
|
|
124
|
+
MultiFactorAuth: 'Mfa',
|
|
125
|
+
MultiFactorAuthChallenges: 'Mfa',
|
|
126
|
+
OrganizationsApiKeys: 'Organizations',
|
|
127
|
+
UserManagementAuthentication: 'UserManagement',
|
|
128
|
+
UserManagementInvitations: 'UserManagement',
|
|
129
|
+
UserManagementMagicAuth: 'UserManagement',
|
|
130
|
+
UserManagementMultiFactorAuthentication: 'UserManagement',
|
|
131
|
+
UserManagementOrganizationMembership: 'UserManagement',
|
|
132
|
+
UserManagementUsers: 'UserManagement',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Explicit class name overrides. Maps the default PascalCase service name
|
|
137
|
+
* to the desired SDK class name when toPascalCase produces the wrong casing.
|
|
138
|
+
*/
|
|
139
|
+
const CLASS_NAME_OVERRIDES: Record<string, string> = {
|
|
140
|
+
WorkosConnect: 'WorkOSConnect',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve the output directory for a service, checking overrides first.
|
|
145
|
+
* Falls back to the standard kebab-case conversion.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveServiceDir(resolvedServiceName: string): string {
|
|
148
|
+
return SERVICE_DIR_OVERRIDES[resolvedServiceName] ?? serviceDirName(resolvedServiceName);
|
|
149
|
+
}
|
|
150
|
+
|
|
69
151
|
/** Resolve the SDK method name for an operation, checking overlay first. */
|
|
70
152
|
export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
|
|
71
153
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
154
|
+
const override = METHOD_NAME_OVERRIDES[httpKey];
|
|
155
|
+
if (override) return override;
|
|
72
156
|
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
73
|
-
if (existing)
|
|
157
|
+
if (existing) {
|
|
158
|
+
// Fix: when the path ends with a path parameter (single-resource operation)
|
|
159
|
+
// and the overlay method name is plural, prefer the singular form.
|
|
160
|
+
// E.g., getUsers → getUser when path is /user_management/users/{id}
|
|
161
|
+
const isSingleResource = /\/\{[^}]+\}$/.test(op.path);
|
|
162
|
+
if (isSingleResource && existing.methodName.endsWith('s') && !existing.methodName.endsWith('ss')) {
|
|
163
|
+
const singular = existing.methodName.slice(0, -1);
|
|
164
|
+
// Only singularize if it looks like a typical pluralization (ends in 's')
|
|
165
|
+
// and the spec-derived name agrees it should be singular
|
|
166
|
+
const specDerived = toCamelCase(op.name);
|
|
167
|
+
if (specDerived === singular || specDerived.endsWith(singular.slice(singular.length - 4))) {
|
|
168
|
+
return singular;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return existing.methodName;
|
|
172
|
+
}
|
|
74
173
|
return toCamelCase(op.name);
|
|
75
174
|
}
|
|
76
175
|
|
|
77
176
|
/** Resolve the SDK class name for a service, checking overlay for existing names. */
|
|
78
177
|
export function resolveClassName(service: Service, ctx: EmitterContext): string {
|
|
178
|
+
// Explicit coverage: this service's endpoints belong to an existing class
|
|
179
|
+
const coveredBy = SERVICE_COVERED_BY[toPascalCase(service.name)];
|
|
180
|
+
if (coveredBy) return coveredBy;
|
|
181
|
+
|
|
79
182
|
// Check overlay's methodByOperation for any operation in this service
|
|
80
183
|
// to find the existing class name
|
|
81
184
|
if (ctx.overlayLookup?.methodByOperation) {
|
|
82
185
|
for (const op of service.operations) {
|
|
83
186
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
84
187
|
const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
|
|
85
|
-
if (existing) return existing.className;
|
|
188
|
+
if (existing) return CLASS_NAME_OVERRIDES[existing.className] ?? existing.className;
|
|
86
189
|
}
|
|
87
190
|
}
|
|
88
|
-
|
|
191
|
+
const defaultName = toPascalCase(service.name);
|
|
192
|
+
return CLASS_NAME_OVERRIDES[defaultName] ?? defaultName;
|
|
89
193
|
}
|
|
90
194
|
|
|
91
195
|
/** Resolve the interface name for a model, checking overlay first. */
|