@workos/oagen-emitters 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-B9F2jmwy.mjs → plugin-BxVeu2v9.mjs} +610 -22
- package/dist/plugin-BxVeu2v9.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +2 -2
- package/src/node/discriminated-models.ts +735 -0
- package/src/node/index.ts +56 -5
- package/src/node/models.ts +15 -1
- package/src/node/node-overrides.ts +49 -6
- package/src/php/index.ts +25 -2
- package/src/ruby/index.ts +27 -2
- package/src/rust/index.ts +26 -2
- package/src/shared/model-utils.ts +15 -5
- package/dist/plugin-B9F2jmwy.mjs.map +0 -1
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import type { EmitterContext, GeneratedFile, Model } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase, toCamelCase } from '@workos/oagen';
|
|
3
|
+
import { loadRawSpec } from '../shared/model-utils.js';
|
|
4
|
+
import { fileName, wireInterfaceName } from './naming.js';
|
|
5
|
+
import { createServiceDirResolver } from './utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Discriminated `allOf [base, oneOf [variant, …]]` model support for Node.
|
|
9
|
+
*
|
|
10
|
+
* When a component schema matches that shape and every oneOf branch pins the
|
|
11
|
+
* same property to a distinct string-const value, we emit:
|
|
12
|
+
*
|
|
13
|
+
* - One interface per variant, holding the base fields plus that variant's
|
|
14
|
+
* specific fields (with the discriminator typed as the const literal).
|
|
15
|
+
* - A type alias union that ties the variants together.
|
|
16
|
+
*
|
|
17
|
+
* Doing this from raw spec — rather than from the IR — sidesteps the parser's
|
|
18
|
+
* `detectAllOfVariantDiscriminator` failing on variants whose properties live
|
|
19
|
+
* behind another `allOf` (the OAuth branch of `ConnectApplication`). That
|
|
20
|
+
* limitation also breaks Python's flat dataclass output; fixing the parser is
|
|
21
|
+
* the proper long-term move but riskier because it would change every
|
|
22
|
+
* emitter's view of `ConnectApplication` at once. This module is contained to
|
|
23
|
+
* the Node emitter.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
interface RawSchema {
|
|
27
|
+
type?: string | string[];
|
|
28
|
+
const?: unknown;
|
|
29
|
+
enum?: unknown[];
|
|
30
|
+
format?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
required?: string[];
|
|
33
|
+
properties?: Record<string, RawSchema>;
|
|
34
|
+
items?: RawSchema;
|
|
35
|
+
allOf?: RawSchema[];
|
|
36
|
+
oneOf?: RawSchema[];
|
|
37
|
+
anyOf?: RawSchema[];
|
|
38
|
+
$ref?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface FieldSpec {
|
|
42
|
+
name: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
required: boolean;
|
|
45
|
+
/** Domain (camelCase) TS type. */
|
|
46
|
+
domainType: string;
|
|
47
|
+
/** Wire (snake_case) TS type. */
|
|
48
|
+
wireType: string;
|
|
49
|
+
/** Model deps for imports — IR names (PascalCase). */
|
|
50
|
+
modelDeps: Set<string>;
|
|
51
|
+
/** Whether the field requires date parsing/formatting (format: date-time). */
|
|
52
|
+
hasDateTime: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface VariantSpec {
|
|
56
|
+
/** Domain interface name suffix, e.g. `OAuth`, `M2M`. */
|
|
57
|
+
nameSuffix: string;
|
|
58
|
+
/** Discriminator string value, e.g. `oauth`, `m2m`. */
|
|
59
|
+
discriminatorValue: string;
|
|
60
|
+
/** Fields specific to this variant (excluding the discriminator). */
|
|
61
|
+
fields: FieldSpec[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface DiscriminatedShape {
|
|
65
|
+
modelName: string;
|
|
66
|
+
/** Base fields common to every variant. */
|
|
67
|
+
baseFields: FieldSpec[];
|
|
68
|
+
/** Field name on the wire (snake_case). */
|
|
69
|
+
discriminatorProperty: string;
|
|
70
|
+
/** Field name in domain (camelCase). */
|
|
71
|
+
discriminatorPropertyDomain: string;
|
|
72
|
+
variants: VariantSpec[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Detection
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export function detectDiscriminatedShape(
|
|
80
|
+
modelName: string,
|
|
81
|
+
rawSchemas: Record<string, RawSchema>,
|
|
82
|
+
): DiscriminatedShape | null {
|
|
83
|
+
const schema = rawSchemas[modelName];
|
|
84
|
+
if (!schema?.allOf) return null;
|
|
85
|
+
|
|
86
|
+
// The expected shape: allOf contains exactly one base object and one oneOf
|
|
87
|
+
// wrapper. The base contributes shared fields; the oneOf contributes
|
|
88
|
+
// variant-specific fields.
|
|
89
|
+
let baseObject: RawSchema | null = null;
|
|
90
|
+
let oneOfVariants: RawSchema[] | null = null;
|
|
91
|
+
for (const member of schema.allOf) {
|
|
92
|
+
const resolved = resolveRef(member, rawSchemas);
|
|
93
|
+
if (resolved.oneOf) {
|
|
94
|
+
if (oneOfVariants) return null; // unexpected: multiple oneOf branches at top
|
|
95
|
+
oneOfVariants = resolved.oneOf;
|
|
96
|
+
} else if (resolved.properties) {
|
|
97
|
+
baseObject = mergeBase(baseObject, resolved);
|
|
98
|
+
} else if (resolved.allOf) {
|
|
99
|
+
// Nested allOf at top: walk it
|
|
100
|
+
const nestedBase = flattenObjectAllOf(resolved, rawSchemas);
|
|
101
|
+
baseObject = mergeBase(baseObject, nestedBase);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!oneOfVariants || oneOfVariants.length < 2) return null;
|
|
105
|
+
|
|
106
|
+
// Flatten each variant through its own allOf to get the effective property
|
|
107
|
+
// set. Variants that themselves embed a nested `oneOf` (e.g. the OAuth
|
|
108
|
+
// first-party / dynamically-registered sub-variants) merge into a single
|
|
109
|
+
// shape: any field appearing in any sub-branch is included, required only
|
|
110
|
+
// when present in every sub-branch.
|
|
111
|
+
const flattenedVariants = oneOfVariants.map((v) => flattenVariant(v, rawSchemas));
|
|
112
|
+
|
|
113
|
+
// Find a shared discriminator: a property whose value is a distinct
|
|
114
|
+
// string-const on every variant. Has to be in `alwaysProperties` of every
|
|
115
|
+
// variant.
|
|
116
|
+
const discProp = findSharedDiscriminator(flattenedVariants);
|
|
117
|
+
if (!discProp) return null;
|
|
118
|
+
|
|
119
|
+
// Build variant specs.
|
|
120
|
+
const variants: VariantSpec[] = flattenedVariants
|
|
121
|
+
.map((fv) => {
|
|
122
|
+
const discValue = readConstString(fv.alwaysProperties.get(discProp));
|
|
123
|
+
if (!discValue) return null;
|
|
124
|
+
return {
|
|
125
|
+
nameSuffix: variantNameSuffix(discValue),
|
|
126
|
+
discriminatorValue: discValue,
|
|
127
|
+
fields: variantFields(fv, discProp, modelName, rawSchemas),
|
|
128
|
+
};
|
|
129
|
+
})
|
|
130
|
+
.filter((v): v is VariantSpec => v !== null);
|
|
131
|
+
|
|
132
|
+
if (variants.length !== flattenedVariants.length) return null;
|
|
133
|
+
|
|
134
|
+
const baseFields = baseObject ? collectObjectFields(baseObject, modelName, rawSchemas) : [];
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
modelName,
|
|
138
|
+
baseFields,
|
|
139
|
+
discriminatorProperty: discProp,
|
|
140
|
+
discriminatorPropertyDomain: toCamelCase(discProp),
|
|
141
|
+
variants,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mergeBase(prev: RawSchema | null, next: RawSchema): RawSchema {
|
|
146
|
+
if (!prev) return next;
|
|
147
|
+
return {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: { ...(prev.properties ?? {}), ...(next.properties ?? {}) },
|
|
150
|
+
required: [...new Set([...(prev.required ?? []), ...(next.required ?? [])])],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function flattenObjectAllOf(schema: RawSchema, rawSchemas: Record<string, RawSchema>): RawSchema {
|
|
155
|
+
let merged: RawSchema = { type: 'object', properties: {}, required: [] };
|
|
156
|
+
for (const sub of schema.allOf ?? []) {
|
|
157
|
+
const resolved = resolveRef(sub, rawSchemas);
|
|
158
|
+
if (resolved.properties) {
|
|
159
|
+
merged = mergeBase(merged, resolved);
|
|
160
|
+
} else if (resolved.allOf) {
|
|
161
|
+
merged = mergeBase(merged, flattenObjectAllOf(resolved, rawSchemas));
|
|
162
|
+
}
|
|
163
|
+
// Ignore oneOf inside base allOf — that's a different code path.
|
|
164
|
+
}
|
|
165
|
+
return merged;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface FlattenedVariant {
|
|
169
|
+
/** Properties present unconditionally (in every sub-branch of nested oneOf). */
|
|
170
|
+
alwaysProperties: Map<string, RawSchema>;
|
|
171
|
+
/** Properties present in at least one sub-branch but not all. */
|
|
172
|
+
optionalProperties: Map<string, RawSchema>;
|
|
173
|
+
/** Required field names — must be required in every sub-branch to remain required. */
|
|
174
|
+
required: Set<string>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function flattenVariant(variant: RawSchema, rawSchemas: Record<string, RawSchema>): FlattenedVariant {
|
|
178
|
+
const resolved = resolveRef(variant, rawSchemas);
|
|
179
|
+
|
|
180
|
+
// Leaf: plain object with properties.
|
|
181
|
+
if (resolved.properties && !resolved.allOf && !resolved.oneOf) {
|
|
182
|
+
const props = new Map<string, RawSchema>();
|
|
183
|
+
for (const [k, v] of Object.entries(resolved.properties)) props.set(k, v);
|
|
184
|
+
return {
|
|
185
|
+
alwaysProperties: props,
|
|
186
|
+
optionalProperties: new Map(),
|
|
187
|
+
required: new Set(resolved.required ?? []),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (resolved.allOf) {
|
|
192
|
+
// Merge each allOf member's flattened view.
|
|
193
|
+
let agg: FlattenedVariant = {
|
|
194
|
+
alwaysProperties: new Map(),
|
|
195
|
+
optionalProperties: new Map(),
|
|
196
|
+
required: new Set(),
|
|
197
|
+
};
|
|
198
|
+
let initialized = false;
|
|
199
|
+
for (const member of resolved.allOf) {
|
|
200
|
+
const memberView = flattenVariant(member, rawSchemas);
|
|
201
|
+
if (!initialized) {
|
|
202
|
+
agg = {
|
|
203
|
+
alwaysProperties: new Map(memberView.alwaysProperties),
|
|
204
|
+
optionalProperties: new Map(memberView.optionalProperties),
|
|
205
|
+
required: new Set(memberView.required),
|
|
206
|
+
};
|
|
207
|
+
initialized = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// allOf is intersection — union of properties, union of required.
|
|
211
|
+
for (const [k, v] of memberView.alwaysProperties) {
|
|
212
|
+
if (!agg.alwaysProperties.has(k) && !agg.optionalProperties.has(k)) {
|
|
213
|
+
agg.alwaysProperties.set(k, v);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const [k, v] of memberView.optionalProperties) {
|
|
217
|
+
if (!agg.alwaysProperties.has(k) && !agg.optionalProperties.has(k)) {
|
|
218
|
+
agg.optionalProperties.set(k, v);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const r of memberView.required) agg.required.add(r);
|
|
222
|
+
}
|
|
223
|
+
return agg;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (resolved.oneOf) {
|
|
227
|
+
// Merge each oneOf member's flattened view — union the properties, but
|
|
228
|
+
// demote anything not present in EVERY member to optional.
|
|
229
|
+
const memberViews = resolved.oneOf.map((m) => flattenVariant(m, rawSchemas));
|
|
230
|
+
const allKeys = new Set<string>();
|
|
231
|
+
for (const view of memberViews) {
|
|
232
|
+
for (const k of view.alwaysProperties.keys()) allKeys.add(k);
|
|
233
|
+
for (const k of view.optionalProperties.keys()) allKeys.add(k);
|
|
234
|
+
}
|
|
235
|
+
const always = new Map<string, RawSchema>();
|
|
236
|
+
const optional = new Map<string, RawSchema>();
|
|
237
|
+
const requiredEverywhere = new Set<string>();
|
|
238
|
+
for (const key of allKeys) {
|
|
239
|
+
const inAll = memberViews.every((v) => v.alwaysProperties.has(key) || v.optionalProperties.has(key));
|
|
240
|
+
const schemas = memberViews
|
|
241
|
+
.map((v) => v.alwaysProperties.get(key) ?? v.optionalProperties.get(key))
|
|
242
|
+
.filter((s): s is RawSchema => Boolean(s));
|
|
243
|
+
const merged = mergeFieldSchemas(schemas);
|
|
244
|
+
if (inAll) {
|
|
245
|
+
always.set(key, merged);
|
|
246
|
+
} else {
|
|
247
|
+
optional.set(key, merged);
|
|
248
|
+
}
|
|
249
|
+
if (inAll && memberViews.every((v) => v.required.has(key))) {
|
|
250
|
+
requiredEverywhere.add(key);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
alwaysProperties: always,
|
|
255
|
+
optionalProperties: optional,
|
|
256
|
+
required: requiredEverywhere,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
alwaysProperties: new Map(),
|
|
262
|
+
optionalProperties: new Map(),
|
|
263
|
+
required: new Set(),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function mergeFieldSchemas(schemas: RawSchema[]): RawSchema {
|
|
268
|
+
if (schemas.length === 0) return {};
|
|
269
|
+
if (schemas.length === 1) return schemas[0];
|
|
270
|
+
|
|
271
|
+
// If every schema is a boolean const, widen to boolean.
|
|
272
|
+
const boolConsts = schemas.every((s) => s.type === 'boolean' && typeof s.const === 'boolean');
|
|
273
|
+
if (boolConsts) {
|
|
274
|
+
return { type: 'boolean', description: schemas[0].description };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// If every schema is a string const with differing values, widen to a plain
|
|
278
|
+
// string (the variant interface narrows it back to the specific literal via
|
|
279
|
+
// its discriminator value, so we lose nothing here).
|
|
280
|
+
const stringConsts = schemas.every((s) => s.type === 'string' && typeof s.const === 'string');
|
|
281
|
+
if (stringConsts) {
|
|
282
|
+
const values = schemas.map((s) => s.const as string);
|
|
283
|
+
if (new Set(values).size === 1) {
|
|
284
|
+
return schemas[0];
|
|
285
|
+
}
|
|
286
|
+
return { type: 'string', description: schemas[0].description };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return schemas[0];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
|
|
293
|
+
if (variants.length < 2) return null;
|
|
294
|
+
const firstAlways = variants[0].alwaysProperties;
|
|
295
|
+
for (const propName of firstAlways.keys()) {
|
|
296
|
+
let allConst = true;
|
|
297
|
+
const values: string[] = [];
|
|
298
|
+
for (const v of variants) {
|
|
299
|
+
const schema = v.alwaysProperties.get(propName);
|
|
300
|
+
const val = readConstString(schema);
|
|
301
|
+
if (val === null) {
|
|
302
|
+
allConst = false;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
values.push(val);
|
|
306
|
+
}
|
|
307
|
+
if (allConst && new Set(values).size === values.length) {
|
|
308
|
+
return propName;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function readConstString(schema: RawSchema | undefined | null): string | null {
|
|
315
|
+
if (!schema) return null;
|
|
316
|
+
if (typeof schema.const === 'string') return schema.const;
|
|
317
|
+
if (Array.isArray(schema.enum) && schema.enum.length === 1 && typeof schema.enum[0] === 'string') {
|
|
318
|
+
return schema.enum[0];
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function variantNameSuffix(constValue: string): string {
|
|
324
|
+
return toPascalCase(constValue);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Field extraction
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function collectObjectFields(
|
|
332
|
+
schema: RawSchema,
|
|
333
|
+
parentName: string,
|
|
334
|
+
rawSchemas: Record<string, RawSchema>,
|
|
335
|
+
): FieldSpec[] {
|
|
336
|
+
const props = schema.properties ?? {};
|
|
337
|
+
const required = new Set(schema.required ?? []);
|
|
338
|
+
const fields: FieldSpec[] = [];
|
|
339
|
+
for (const [name, propSchema] of Object.entries(props)) {
|
|
340
|
+
fields.push(buildField(name, propSchema, required.has(name), parentName, rawSchemas));
|
|
341
|
+
}
|
|
342
|
+
return fields;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function variantFields(
|
|
346
|
+
fv: FlattenedVariant,
|
|
347
|
+
discriminatorProperty: string,
|
|
348
|
+
parentName: string,
|
|
349
|
+
rawSchemas: Record<string, RawSchema>,
|
|
350
|
+
): FieldSpec[] {
|
|
351
|
+
const fields: FieldSpec[] = [];
|
|
352
|
+
for (const [name, propSchema] of fv.alwaysProperties) {
|
|
353
|
+
if (name === discriminatorProperty) continue;
|
|
354
|
+
fields.push(buildField(name, propSchema, fv.required.has(name), parentName, rawSchemas));
|
|
355
|
+
}
|
|
356
|
+
for (const [name, propSchema] of fv.optionalProperties) {
|
|
357
|
+
if (name === discriminatorProperty) continue;
|
|
358
|
+
fields.push(buildField(name, propSchema, false, parentName, rawSchemas));
|
|
359
|
+
}
|
|
360
|
+
return fields;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function buildField(
|
|
364
|
+
rawName: string,
|
|
365
|
+
schema: RawSchema,
|
|
366
|
+
required: boolean,
|
|
367
|
+
parentName: string,
|
|
368
|
+
rawSchemas: Record<string, RawSchema>,
|
|
369
|
+
): FieldSpec {
|
|
370
|
+
const modelDeps = new Set<string>();
|
|
371
|
+
const domainType = rawSchemaToTS(schema, parentName, rawName, false, modelDeps, rawSchemas);
|
|
372
|
+
const wireType = rawSchemaToTS(schema, parentName, rawName, true, modelDeps, rawSchemas);
|
|
373
|
+
return {
|
|
374
|
+
name: rawName,
|
|
375
|
+
description: schema.description,
|
|
376
|
+
required,
|
|
377
|
+
domainType,
|
|
378
|
+
wireType,
|
|
379
|
+
modelDeps,
|
|
380
|
+
hasDateTime: schemaHasDateTime(schema),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function schemaHasDateTime(schema: RawSchema): boolean {
|
|
385
|
+
if (schema.format === 'date-time' && typeOf(schema) === 'string') return true;
|
|
386
|
+
if (schema.items && schemaHasDateTime(schema.items)) return true;
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function typeOf(schema: RawSchema): string | undefined {
|
|
391
|
+
if (Array.isArray(schema.type)) {
|
|
392
|
+
return schema.type.find((t) => t !== 'null');
|
|
393
|
+
}
|
|
394
|
+
return schema.type;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isNullable(schema: RawSchema): boolean {
|
|
398
|
+
return Array.isArray(schema.type) && schema.type.includes('null');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function rawSchemaToTS(
|
|
402
|
+
schema: RawSchema,
|
|
403
|
+
parentName: string,
|
|
404
|
+
fieldName: string,
|
|
405
|
+
isWire: boolean,
|
|
406
|
+
modelDeps: Set<string>,
|
|
407
|
+
rawSchemas: Record<string, RawSchema>,
|
|
408
|
+
): string {
|
|
409
|
+
if (schema.$ref) {
|
|
410
|
+
const refName = schema.$ref.split('/').pop()!;
|
|
411
|
+
modelDeps.add(refName);
|
|
412
|
+
const domain = toPascalCase(refName);
|
|
413
|
+
return isWire ? wireInterfaceName(domain) : domain;
|
|
414
|
+
}
|
|
415
|
+
if (typeof schema.const === 'string') {
|
|
416
|
+
return `'${schema.const}'`;
|
|
417
|
+
}
|
|
418
|
+
if (typeof schema.const === 'boolean') {
|
|
419
|
+
return String(schema.const);
|
|
420
|
+
}
|
|
421
|
+
const baseType = typeOf(schema);
|
|
422
|
+
const nullable = isNullable(schema);
|
|
423
|
+
let core: string;
|
|
424
|
+
if (baseType === 'string') {
|
|
425
|
+
core = !isWire && schema.format === 'date-time' ? 'Date' : 'string';
|
|
426
|
+
} else if (baseType === 'integer' || baseType === 'number') {
|
|
427
|
+
core = 'number';
|
|
428
|
+
} else if (baseType === 'boolean') {
|
|
429
|
+
core = 'boolean';
|
|
430
|
+
} else if (baseType === 'array' && schema.items) {
|
|
431
|
+
const items = rawSchemaToTS(schema.items, parentName, singularize(fieldName), isWire, modelDeps, rawSchemas);
|
|
432
|
+
core = `${parenthesizeUnion(items)}[]`;
|
|
433
|
+
} else if (baseType === 'object' && schema.properties) {
|
|
434
|
+
// Inline object — refer to the synthetic model name that
|
|
435
|
+
// `enrichModelsFromSpec` produces. Pattern: `<Parent>_<fieldSingular>`.
|
|
436
|
+
const synthName = `${parentName}_${singularize(fieldName)}`;
|
|
437
|
+
modelDeps.add(synthName);
|
|
438
|
+
const domain = toPascalCase(synthName);
|
|
439
|
+
return isWire ? wireInterfaceName(domain) : domain;
|
|
440
|
+
} else {
|
|
441
|
+
core = 'unknown';
|
|
442
|
+
}
|
|
443
|
+
return nullable ? `${core} | null` : core;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function parenthesizeUnion(t: string): string {
|
|
447
|
+
return /\s\|\s/.test(t) ? `(${t})` : t;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function singularize(name: string): string {
|
|
451
|
+
if (name.endsWith('ies') && name.length > 3) return `${name.slice(0, -3)}y`;
|
|
452
|
+
if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1);
|
|
453
|
+
return name;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function resolveRef(schema: RawSchema, rawSchemas: Record<string, RawSchema>): RawSchema {
|
|
457
|
+
if (!schema.$ref) return schema;
|
|
458
|
+
const segments = schema.$ref.split('/');
|
|
459
|
+
const name = segments[segments.length - 1];
|
|
460
|
+
return rawSchemas[name] ?? schema;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Public emission entry points
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
export interface DiscriminatedPlan {
|
|
468
|
+
shape: DiscriminatedShape;
|
|
469
|
+
modelDir: string;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): Map<string, DiscriminatedPlan> {
|
|
473
|
+
const plans = new Map<string, DiscriminatedPlan>();
|
|
474
|
+
const spec = loadRawSpec();
|
|
475
|
+
if (!spec?.components?.schemas) return plans;
|
|
476
|
+
const rawSchemas = spec.components.schemas as Record<string, RawSchema>;
|
|
477
|
+
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
478
|
+
for (const model of models) {
|
|
479
|
+
const shape = detectDiscriminatedShape(model.name, rawSchemas);
|
|
480
|
+
if (!shape) continue;
|
|
481
|
+
const modelDir = resolveDir(modelToService.get(model.name));
|
|
482
|
+
plans.set(model.name, { shape, modelDir });
|
|
483
|
+
}
|
|
484
|
+
return plans;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function generateDiscriminatedFiles(
|
|
488
|
+
plans: Map<string, DiscriminatedPlan>,
|
|
489
|
+
ctx: EmitterContext,
|
|
490
|
+
): GeneratedFile[] {
|
|
491
|
+
const files: GeneratedFile[] = [];
|
|
492
|
+
for (const plan of plans.values()) {
|
|
493
|
+
files.push(buildInterfaceFile(plan, ctx));
|
|
494
|
+
files.push(buildSerializerFile(plan, ctx));
|
|
495
|
+
}
|
|
496
|
+
return files;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): GeneratedFile {
|
|
500
|
+
const { shape, modelDir } = plan;
|
|
501
|
+
const domain = toPascalCase(shape.modelName);
|
|
502
|
+
const wire = wireInterfaceName(domain);
|
|
503
|
+
const lines: string[] = [];
|
|
504
|
+
|
|
505
|
+
const imports = collectImports(plan);
|
|
506
|
+
if (imports.length > 0) {
|
|
507
|
+
for (const imp of imports) {
|
|
508
|
+
lines.push(`import type { ${imp.symbols.join(', ')} } from '${imp.path}';`);
|
|
509
|
+
}
|
|
510
|
+
lines.push('');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Variant interfaces (domain + wire) plus a union type alias.
|
|
514
|
+
for (const variant of shape.variants) {
|
|
515
|
+
const variantDomain = `${domain}${variant.nameSuffix}`;
|
|
516
|
+
const variantWire = `${variantDomain}Response`;
|
|
517
|
+
lines.push(...buildInterfaceBody(variantDomain, shape, variant, /*isWire*/ false));
|
|
518
|
+
lines.push('');
|
|
519
|
+
lines.push(...buildInterfaceBody(variantWire, shape, variant, /*isWire*/ true));
|
|
520
|
+
lines.push('');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Union aliases
|
|
524
|
+
const variantNames = shape.variants.map((v) => `${domain}${v.nameSuffix}`);
|
|
525
|
+
lines.push(`export type ${domain} = ${variantNames.join(' | ')};`);
|
|
526
|
+
lines.push('');
|
|
527
|
+
const wireVariantNames = shape.variants.map((v) => `${domain}${v.nameSuffix}Response`);
|
|
528
|
+
lines.push(`export type ${wire} = ${wireVariantNames.join(' | ')};`);
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
path: `src/${modelDir}/interfaces/${fileName(shape.modelName)}.interface.ts`,
|
|
532
|
+
content: lines.join('\n') + '\n',
|
|
533
|
+
overwriteExisting: true,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: VariantSpec, isWire: boolean): string[] {
|
|
538
|
+
const lines: string[] = [];
|
|
539
|
+
lines.push(`export interface ${name} {`);
|
|
540
|
+
// Base fields
|
|
541
|
+
for (const field of shape.baseFields) {
|
|
542
|
+
pushFieldLine(lines, field, isWire);
|
|
543
|
+
}
|
|
544
|
+
// Discriminator (typed as the variant's const value)
|
|
545
|
+
const discKey = isWire ? shape.discriminatorProperty : shape.discriminatorPropertyDomain;
|
|
546
|
+
lines.push(` ${discKey}: '${variant.discriminatorValue}';`);
|
|
547
|
+
// Variant-specific fields
|
|
548
|
+
for (const field of variant.fields) {
|
|
549
|
+
pushFieldLine(lines, field, isWire);
|
|
550
|
+
}
|
|
551
|
+
lines.push('}');
|
|
552
|
+
return lines;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function pushFieldLine(lines: string[], field: FieldSpec, isWire: boolean): void {
|
|
556
|
+
const key = isWire ? field.name : toCamelCase(field.name);
|
|
557
|
+
const opt = field.required ? '' : '?';
|
|
558
|
+
const type = isWire ? field.wireType : field.domainType;
|
|
559
|
+
if (field.description) {
|
|
560
|
+
lines.push(` /** ${field.description} */`);
|
|
561
|
+
}
|
|
562
|
+
lines.push(` ${key}${opt}: ${type};`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
interface ImportSpec {
|
|
566
|
+
path: string;
|
|
567
|
+
symbols: string[];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function collectImports(plan: DiscriminatedPlan): ImportSpec[] {
|
|
571
|
+
const deps = new Set<string>();
|
|
572
|
+
for (const field of plan.shape.baseFields) {
|
|
573
|
+
for (const d of field.modelDeps) deps.add(d);
|
|
574
|
+
}
|
|
575
|
+
for (const variant of plan.shape.variants) {
|
|
576
|
+
for (const field of variant.fields) {
|
|
577
|
+
for (const d of field.modelDeps) deps.add(d);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Group by directory — all deps under the same modelDir get one import.
|
|
581
|
+
// We assume all deps live in the same service for now (same dir as this
|
|
582
|
+
// model). Cross-service imports would need ctx.spec.services lookups; the
|
|
583
|
+
// current discriminated-shape cases (ConnectApplication) are all
|
|
584
|
+
// intra-service.
|
|
585
|
+
const symbols: string[] = [];
|
|
586
|
+
for (const dep of [...deps].sort()) {
|
|
587
|
+
const domain = toPascalCase(dep);
|
|
588
|
+
symbols.push(domain);
|
|
589
|
+
const wire = wireInterfaceName(domain);
|
|
590
|
+
if (wire !== domain) symbols.push(wire);
|
|
591
|
+
}
|
|
592
|
+
if (symbols.length === 0) return [];
|
|
593
|
+
// Single import block from sibling files in the same interfaces directory.
|
|
594
|
+
return symbols
|
|
595
|
+
.map((sym) => {
|
|
596
|
+
const fname = fileName(toSnakeFromPascal(sym.replace(/Response$/, '')));
|
|
597
|
+
return { path: `./${fname}.interface`, symbols: [sym] };
|
|
598
|
+
})
|
|
599
|
+
.reduce((acc, cur) => {
|
|
600
|
+
const existing = acc.find((a) => a.path === cur.path);
|
|
601
|
+
if (existing) existing.symbols.push(...cur.symbols);
|
|
602
|
+
else acc.push(cur);
|
|
603
|
+
return acc;
|
|
604
|
+
}, [] as ImportSpec[]);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function toSnakeFromPascal(s: string): string {
|
|
608
|
+
// PascalCase → snake_case, preserving acronyms via word splits.
|
|
609
|
+
return s
|
|
610
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
611
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
612
|
+
.toLowerCase();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// Serializer
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): GeneratedFile {
|
|
620
|
+
const { shape, modelDir } = plan;
|
|
621
|
+
const domain = toPascalCase(shape.modelName);
|
|
622
|
+
const wire = wireInterfaceName(domain);
|
|
623
|
+
const lines: string[] = [];
|
|
624
|
+
|
|
625
|
+
// Imports: domain + wire union types from the interfaces file.
|
|
626
|
+
const interfaceImportPath = `../interfaces/${fileName(shape.modelName)}.interface`;
|
|
627
|
+
lines.push(`import type { ${domain}, ${wire} } from '${interfaceImportPath}';`);
|
|
628
|
+
|
|
629
|
+
// Serializer imports for model deps
|
|
630
|
+
const allDeps = new Set<string>();
|
|
631
|
+
for (const field of shape.baseFields) for (const d of field.modelDeps) allDeps.add(d);
|
|
632
|
+
for (const variant of shape.variants)
|
|
633
|
+
for (const field of variant.fields) for (const d of field.modelDeps) allDeps.add(d);
|
|
634
|
+
|
|
635
|
+
for (const dep of [...allDeps].sort()) {
|
|
636
|
+
const depDomain = toPascalCase(dep);
|
|
637
|
+
const depFile = fileName(toSnakeFromPascal(depDomain));
|
|
638
|
+
lines.push(`import { deserialize${depDomain}, serialize${depDomain} } from './${depFile}.serializer';`);
|
|
639
|
+
}
|
|
640
|
+
lines.push('');
|
|
641
|
+
|
|
642
|
+
// Deserializer
|
|
643
|
+
lines.push(`export const deserialize${domain} = (response: ${wire}): ${domain} => {`);
|
|
644
|
+
lines.push(` switch (response.${shape.discriminatorProperty}) {`);
|
|
645
|
+
for (const variant of shape.variants) {
|
|
646
|
+
lines.push(` case '${variant.discriminatorValue}':`);
|
|
647
|
+
lines.push(` return {`);
|
|
648
|
+
for (const field of shape.baseFields) {
|
|
649
|
+
lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
|
|
650
|
+
}
|
|
651
|
+
lines.push(` ${shape.discriminatorPropertyDomain}: '${variant.discriminatorValue}',`);
|
|
652
|
+
for (const field of variant.fields) {
|
|
653
|
+
lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
|
|
654
|
+
}
|
|
655
|
+
lines.push(` };`);
|
|
656
|
+
}
|
|
657
|
+
lines.push(` default:`);
|
|
658
|
+
lines.push(
|
|
659
|
+
` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${(response as { ${shape.discriminatorProperty}: string }).${shape.discriminatorProperty}}\`);`,
|
|
660
|
+
);
|
|
661
|
+
lines.push(` }`);
|
|
662
|
+
lines.push(`};`);
|
|
663
|
+
lines.push('');
|
|
664
|
+
|
|
665
|
+
// Serializer
|
|
666
|
+
lines.push(`export const serialize${domain} = (model: ${domain}): ${wire} => {`);
|
|
667
|
+
lines.push(` switch (model.${shape.discriminatorPropertyDomain}) {`);
|
|
668
|
+
for (const variant of shape.variants) {
|
|
669
|
+
lines.push(` case '${variant.discriminatorValue}':`);
|
|
670
|
+
lines.push(` return {`);
|
|
671
|
+
for (const field of shape.baseFields) {
|
|
672
|
+
lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
|
|
673
|
+
}
|
|
674
|
+
lines.push(` ${shape.discriminatorProperty}: '${variant.discriminatorValue}',`);
|
|
675
|
+
for (const field of variant.fields) {
|
|
676
|
+
lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
|
|
677
|
+
}
|
|
678
|
+
lines.push(` };`);
|
|
679
|
+
}
|
|
680
|
+
lines.push(` }`);
|
|
681
|
+
lines.push(`};`);
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
path: `src/${modelDir}/serializers/${fileName(shape.modelName)}.serializer.ts`,
|
|
685
|
+
content: lines.join('\n') + '\n',
|
|
686
|
+
overwriteExisting: true,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function assignmentLine(field: FieldSpec, serialize: boolean, _allDeps: Set<string>): string {
|
|
691
|
+
const camel = toCamelCase(field.name);
|
|
692
|
+
const snake = field.name;
|
|
693
|
+
const lhs = serialize ? snake : camel;
|
|
694
|
+
const rhsKey = serialize ? camel : snake;
|
|
695
|
+
const source = serialize ? `model.${rhsKey}` : `response.${rhsKey}`;
|
|
696
|
+
|
|
697
|
+
if (field.hasDateTime) {
|
|
698
|
+
if (serialize) {
|
|
699
|
+
if (field.required) return `${lhs}: ${source}.toISOString()`;
|
|
700
|
+
return `${lhs}: ${source} != null ? ${source}.toISOString() : undefined`;
|
|
701
|
+
}
|
|
702
|
+
if (field.required) return `${lhs}: new Date(${source})`;
|
|
703
|
+
return `${lhs}: ${source} != null ? new Date(${source}) : undefined`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const arrayDep = arrayItemModelDep(field);
|
|
707
|
+
if (arrayDep) {
|
|
708
|
+
const fn = serialize ? `serialize${arrayDep}` : `deserialize${arrayDep}`;
|
|
709
|
+
if (field.required) return `${lhs}: ${source}.map(${fn})`;
|
|
710
|
+
return `${lhs}: ${source} != null ? ${source}.map(${fn}) : undefined`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const scalarDep = scalarModelDepName(field);
|
|
714
|
+
if (scalarDep) {
|
|
715
|
+
const fn = serialize ? `serialize${scalarDep}` : `deserialize${scalarDep}`;
|
|
716
|
+
if (field.required) return `${lhs}: ${fn}(${source})`;
|
|
717
|
+
return `${lhs}: ${source} != null ? ${fn}(${source}) : undefined`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return `${lhs}: ${source}`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function arrayItemModelDep(field: FieldSpec): string | null {
|
|
724
|
+
const m = field.domainType.match(/^([A-Z]\w*)\[\]$/);
|
|
725
|
+
if (m && field.modelDeps.size > 0) return m[1];
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function scalarModelDepName(field: FieldSpec): string | null {
|
|
730
|
+
const stripped = field.domainType.replace(/\s*\|\s*null$/, '');
|
|
731
|
+
if (/^[A-Z]\w*$/.test(stripped) && field.modelDeps.size === 1) {
|
|
732
|
+
return toPascalCase([...field.modelDeps][0]);
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|