@workos/oagen-emitters 0.2.0 → 0.3.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/.husky/pre-commit +1 -0
- package/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11943 -2728
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +137 -46
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +167 -122
- package/src/node/enums.ts +13 -4
- package/src/node/errors.ts +42 -233
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +15 -5
- package/src/node/index.ts +65 -16
- package/src/node/models.ts +264 -96
- package/src/node/naming.ts +52 -25
- package/src/node/resources.ts +621 -172
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +71 -27
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +56 -64
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +199 -94
- package/test/node/enums.test.ts +75 -3
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +109 -20
- package/test/node/naming.test.ts +37 -4
- package/test/node/resources.test.ts +662 -30
- package/test/node/serializers.test.ts +36 -7
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -744
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import type { Model, Field, EmitterContext, TypeRef, UnionType, PrimitiveType } from '@workos/oagen';
|
|
2
|
+
import { mapTypeRef as tsMapTypeRef } from './type-map.js';
|
|
3
|
+
import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
4
|
+
import { relativeImport, buildKnownTypeNames, isBaselineGeneric, createServiceDirResolver } from './utils.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Guard strategy — determines how a field assignment is wrapped
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
type GuardStrategy =
|
|
11
|
+
| { kind: 'direct' }
|
|
12
|
+
| { kind: 'null-check'; fallback: string }
|
|
13
|
+
| { kind: 'coalesce'; fallback: string }
|
|
14
|
+
| { kind: 'non-null-assert' };
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Baseline types used by planning functions
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
interface BaselineFieldInfo {
|
|
21
|
+
type: string;
|
|
22
|
+
optional: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface BaselineInterface {
|
|
26
|
+
fields?: Record<string, BaselineFieldInfo>;
|
|
27
|
+
sourceFile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Expression builders
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a deserialization expression for a type reference.
|
|
36
|
+
* @param nullFallback - fallback value for nullable inner expressions (default 'null')
|
|
37
|
+
*/
|
|
38
|
+
export function deserializeExpression(
|
|
39
|
+
ref: TypeRef,
|
|
40
|
+
wireExpr: string,
|
|
41
|
+
ctx: EmitterContext,
|
|
42
|
+
nullFallback = 'null',
|
|
43
|
+
): string {
|
|
44
|
+
switch (ref.kind) {
|
|
45
|
+
case 'primitive':
|
|
46
|
+
return deserializePrimitive(ref, wireExpr);
|
|
47
|
+
case 'literal':
|
|
48
|
+
case 'enum':
|
|
49
|
+
return wireExpr;
|
|
50
|
+
case 'model': {
|
|
51
|
+
const name = resolveInterfaceName(ref.name, ctx);
|
|
52
|
+
return `deserialize${name}(${wireExpr})`;
|
|
53
|
+
}
|
|
54
|
+
case 'array':
|
|
55
|
+
if (ref.items.kind === 'model') {
|
|
56
|
+
const name = resolveInterfaceName(ref.items.name, ctx);
|
|
57
|
+
return `${wireExpr}.map(deserialize${name})`;
|
|
58
|
+
}
|
|
59
|
+
return wireExpr;
|
|
60
|
+
case 'nullable': {
|
|
61
|
+
const innerExpr = deserializeExpression(ref.inner, wireExpr, ctx, nullFallback);
|
|
62
|
+
if (innerExpr !== wireExpr) {
|
|
63
|
+
return `${wireExpr} != null ? ${innerExpr} : ${nullFallback}`;
|
|
64
|
+
}
|
|
65
|
+
return `${wireExpr} ?? ${nullFallback}`;
|
|
66
|
+
}
|
|
67
|
+
case 'union': {
|
|
68
|
+
if (ref.discriminator) {
|
|
69
|
+
return renderDiscriminatorSwitch(ref, wireExpr, 'deserialize', ctx);
|
|
70
|
+
}
|
|
71
|
+
if (ref.compositionKind === 'allOf') {
|
|
72
|
+
return renderAllOfMerge(ref, wireExpr, 'deserialize', ctx);
|
|
73
|
+
}
|
|
74
|
+
const models = uniqueModelVariants(ref);
|
|
75
|
+
if (models.length === 1) {
|
|
76
|
+
const name = resolveInterfaceName(models[0], ctx);
|
|
77
|
+
return `deserialize${name}(${wireExpr})`;
|
|
78
|
+
}
|
|
79
|
+
return wireExpr;
|
|
80
|
+
}
|
|
81
|
+
case 'map':
|
|
82
|
+
return wireExpr;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a serialization expression for a type reference.
|
|
88
|
+
* @param nullFallback - fallback value for nullable inner expressions (default 'null')
|
|
89
|
+
*/
|
|
90
|
+
export function serializeExpression(
|
|
91
|
+
ref: TypeRef,
|
|
92
|
+
domainExpr: string,
|
|
93
|
+
ctx: EmitterContext,
|
|
94
|
+
nullFallback = 'null',
|
|
95
|
+
): string {
|
|
96
|
+
switch (ref.kind) {
|
|
97
|
+
case 'primitive':
|
|
98
|
+
return serializePrimitive(ref, domainExpr);
|
|
99
|
+
case 'literal':
|
|
100
|
+
case 'enum':
|
|
101
|
+
return domainExpr;
|
|
102
|
+
case 'model': {
|
|
103
|
+
const name = resolveInterfaceName(ref.name, ctx);
|
|
104
|
+
return `serialize${name}(${domainExpr})`;
|
|
105
|
+
}
|
|
106
|
+
case 'array':
|
|
107
|
+
if (ref.items.kind === 'model') {
|
|
108
|
+
const name = resolveInterfaceName(ref.items.name, ctx);
|
|
109
|
+
return `${domainExpr}.map(serialize${name})`;
|
|
110
|
+
}
|
|
111
|
+
return domainExpr;
|
|
112
|
+
case 'nullable': {
|
|
113
|
+
const innerExpr = serializeExpression(ref.inner, domainExpr, ctx, nullFallback);
|
|
114
|
+
if (innerExpr !== domainExpr) {
|
|
115
|
+
return `${domainExpr} != null ? ${innerExpr} : ${nullFallback}`;
|
|
116
|
+
}
|
|
117
|
+
return domainExpr;
|
|
118
|
+
}
|
|
119
|
+
case 'union': {
|
|
120
|
+
if (ref.discriminator) {
|
|
121
|
+
return renderDiscriminatorSwitch(ref, domainExpr, 'serialize', ctx);
|
|
122
|
+
}
|
|
123
|
+
if (ref.compositionKind === 'allOf') {
|
|
124
|
+
return renderAllOfMerge(ref, domainExpr, 'serialize', ctx);
|
|
125
|
+
}
|
|
126
|
+
const models = uniqueModelVariants(ref);
|
|
127
|
+
if (models.length === 1) {
|
|
128
|
+
const name = resolveInterfaceName(models[0], ctx);
|
|
129
|
+
return `serialize${name}(${domainExpr})`;
|
|
130
|
+
}
|
|
131
|
+
return domainExpr;
|
|
132
|
+
}
|
|
133
|
+
case 'map':
|
|
134
|
+
return domainExpr;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Primitive format conversions
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function deserializePrimitive(ref: PrimitiveType, wireExpr: string): string {
|
|
143
|
+
if (ref.format === 'date-time') return `new Date(${wireExpr})`;
|
|
144
|
+
if (ref.format === 'int64') return `BigInt(${wireExpr})`;
|
|
145
|
+
return wireExpr;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function serializePrimitive(ref: PrimitiveType, domainExpr: string): string {
|
|
149
|
+
if (ref.format === 'date-time') return `${domainExpr}.toISOString()`;
|
|
150
|
+
if (ref.format === 'int64') return `String(${domainExpr})`;
|
|
151
|
+
return domainExpr;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Union helpers
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/** Extract unique model names from a union's variants. */
|
|
159
|
+
export function uniqueModelVariants(ref: UnionType): string[] {
|
|
160
|
+
const modelNames = new Set<string>();
|
|
161
|
+
for (const v of ref.variants) {
|
|
162
|
+
if (v.kind === 'model') modelNames.add(v.name);
|
|
163
|
+
}
|
|
164
|
+
return [...modelNames];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderDiscriminatorSwitch(
|
|
168
|
+
ref: UnionType,
|
|
169
|
+
expr: string,
|
|
170
|
+
direction: 'deserialize' | 'serialize',
|
|
171
|
+
ctx: EmitterContext,
|
|
172
|
+
): string {
|
|
173
|
+
const disc = ref.discriminator!;
|
|
174
|
+
const cases: string[] = [];
|
|
175
|
+
for (const [value, modelName] of Object.entries(disc.mapping)) {
|
|
176
|
+
const resolved = resolveInterfaceName(modelName, ctx);
|
|
177
|
+
const fn = `${direction}${resolved}`;
|
|
178
|
+
cases.push(`case '${value}': return ${fn}(${expr} as any)`);
|
|
179
|
+
}
|
|
180
|
+
return `(() => { switch ((${expr} as any).${disc.property}) { ${cases.join('; ')}; default: return ${expr} } })()`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderAllOfMerge(
|
|
184
|
+
ref: UnionType,
|
|
185
|
+
expr: string,
|
|
186
|
+
direction: 'deserialize' | 'serialize',
|
|
187
|
+
ctx: EmitterContext,
|
|
188
|
+
): string {
|
|
189
|
+
const models = uniqueModelVariants(ref);
|
|
190
|
+
if (models.length === 0) return expr;
|
|
191
|
+
const spreads = models.map((name) => {
|
|
192
|
+
const resolved = resolveInterfaceName(name, ctx);
|
|
193
|
+
return `...${direction}${resolved}(${expr} as any)`;
|
|
194
|
+
});
|
|
195
|
+
return `({ ${spreads.join(', ')} })`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Type inspection helpers
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/** Check whether a TypeRef involves a function call in serialization. */
|
|
203
|
+
export function needsNullGuard(ref: TypeRef): boolean {
|
|
204
|
+
switch (ref.kind) {
|
|
205
|
+
case 'model':
|
|
206
|
+
return true;
|
|
207
|
+
case 'primitive':
|
|
208
|
+
return hasFormatConversion(ref);
|
|
209
|
+
case 'array':
|
|
210
|
+
return ref.items.kind === 'model';
|
|
211
|
+
case 'nullable':
|
|
212
|
+
return needsNullGuard(ref.inner);
|
|
213
|
+
case 'union':
|
|
214
|
+
if (ref.discriminator) return true;
|
|
215
|
+
if (ref.compositionKind === 'allOf' && uniqueModelVariants(ref).length > 0) return true;
|
|
216
|
+
return uniqueModelVariants(ref).length === 1;
|
|
217
|
+
default:
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function hasFormatConversion(ref: TypeRef): boolean {
|
|
223
|
+
switch (ref.kind) {
|
|
224
|
+
case 'primitive':
|
|
225
|
+
return ref.format === 'date-time' || ref.format === 'int64';
|
|
226
|
+
case 'nullable':
|
|
227
|
+
return hasFormatConversion(ref.inner);
|
|
228
|
+
default:
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function hasDateTimeConversion(ref: TypeRef): boolean {
|
|
234
|
+
switch (ref.kind) {
|
|
235
|
+
case 'primitive':
|
|
236
|
+
return ref.format === 'date-time';
|
|
237
|
+
case 'nullable':
|
|
238
|
+
return hasDateTimeConversion(ref.inner);
|
|
239
|
+
default:
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Collect model names that will actually be called in serialize/deserialize expressions.
|
|
246
|
+
* Unlike collectModelRefs (which walks all union variants), this only includes models
|
|
247
|
+
* that the expression functions will actually invoke a serializer/deserializer for.
|
|
248
|
+
*/
|
|
249
|
+
export function collectSerializedModelRefs(ref: TypeRef): string[] {
|
|
250
|
+
switch (ref.kind) {
|
|
251
|
+
case 'model':
|
|
252
|
+
return [ref.name];
|
|
253
|
+
case 'array':
|
|
254
|
+
if (ref.items.kind === 'model') return [ref.items.name];
|
|
255
|
+
return collectSerializedModelRefs(ref.items);
|
|
256
|
+
case 'nullable':
|
|
257
|
+
return collectSerializedModelRefs(ref.inner);
|
|
258
|
+
case 'union': {
|
|
259
|
+
const models = uniqueModelVariants(ref);
|
|
260
|
+
if (ref.discriminator && models.length > 0) return models;
|
|
261
|
+
if (ref.compositionKind === 'allOf' && models.length > 0) return models;
|
|
262
|
+
if (models.length === 1) return models;
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
case 'map':
|
|
266
|
+
case 'primitive':
|
|
267
|
+
case 'literal':
|
|
268
|
+
case 'enum':
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Return a TypeScript default value expression for a type. */
|
|
274
|
+
export function defaultForType(ref: TypeRef): string | null {
|
|
275
|
+
switch (ref.kind) {
|
|
276
|
+
case 'literal':
|
|
277
|
+
return typeof ref.value === 'string' ? `'${ref.value}'` : String(ref.value);
|
|
278
|
+
case 'enum':
|
|
279
|
+
return null;
|
|
280
|
+
case 'map':
|
|
281
|
+
return '{}';
|
|
282
|
+
case 'nullable':
|
|
283
|
+
return 'null';
|
|
284
|
+
case 'primitive':
|
|
285
|
+
switch (ref.type) {
|
|
286
|
+
case 'boolean':
|
|
287
|
+
return 'false';
|
|
288
|
+
case 'string':
|
|
289
|
+
return "''";
|
|
290
|
+
case 'integer':
|
|
291
|
+
case 'number':
|
|
292
|
+
return '0';
|
|
293
|
+
default:
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
case 'array':
|
|
297
|
+
return '[]';
|
|
298
|
+
default:
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Serializer type params
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
export function renderSerializerTypeParams(model: Model, ctx?: EmitterContext): { decl: string; usage: string } {
|
|
308
|
+
if (model.typeParams?.length) {
|
|
309
|
+
const params = model.typeParams.map((tp) => {
|
|
310
|
+
const def = tp.default ? ` = ${tsMapTypeRef(tp.default)}` : '';
|
|
311
|
+
return `${tp.name}${def}`;
|
|
312
|
+
});
|
|
313
|
+
const names = model.typeParams.map((tp) => tp.name);
|
|
314
|
+
return { decl: `<${params.join(', ')}>`, usage: `<${names.join(', ')}>` };
|
|
315
|
+
}
|
|
316
|
+
if (ctx?.apiSurface?.interfaces) {
|
|
317
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
318
|
+
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
319
|
+
if (baseline?.fields) {
|
|
320
|
+
const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
|
|
321
|
+
const { modelToService, resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
|
|
322
|
+
const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
323
|
+
const pathMatches = !baselineSourceFile || baselineSourceFile === generatedPath;
|
|
324
|
+
const knownNames = buildKnownTypeNames(ctx.spec.models, ctx);
|
|
325
|
+
if (pathMatches && isBaselineGeneric(baseline.fields, knownNames)) {
|
|
326
|
+
return {
|
|
327
|
+
decl: '<GenericType extends Record<string, unknown> = Record<string, unknown>>',
|
|
328
|
+
usage: '<GenericType>',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { decl: '', usage: '' };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Baseline incompatibility detection
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
export function serializerHasBaselineIncompatibility(
|
|
341
|
+
model: Model,
|
|
342
|
+
baselineResponse: BaselineInterface | undefined,
|
|
343
|
+
baselineDomain?: BaselineInterface,
|
|
344
|
+
ctx?: EmitterContext,
|
|
345
|
+
): boolean {
|
|
346
|
+
if (!baselineResponse?.fields) return false;
|
|
347
|
+
|
|
348
|
+
const irWireFields = new Set<string>();
|
|
349
|
+
const irDomainFields = new Set<string>();
|
|
350
|
+
for (const field of model.fields) {
|
|
351
|
+
irWireFields.add(wireFieldName(field.name));
|
|
352
|
+
irDomainFields.add(fieldName(field.name));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const [wireField2, fieldDef] of Object.entries(baselineResponse.fields)) {
|
|
356
|
+
if (fieldDef.optional) continue;
|
|
357
|
+
if (!irWireFields.has(wireField2)) {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (baselineDomain?.fields) {
|
|
363
|
+
const baselineRequiredFields = Object.entries(baselineDomain.fields)
|
|
364
|
+
.filter(([, f]) => !f.optional)
|
|
365
|
+
.map(([name]) => name);
|
|
366
|
+
const unmatchedCount = baselineRequiredFields.filter((n) => !irDomainFields.has(n)).length;
|
|
367
|
+
if (unmatchedCount > 0 && baselineRequiredFields.length > 0) {
|
|
368
|
+
const unmatchedRatio = unmatchedCount / baselineRequiredFields.length;
|
|
369
|
+
if (unmatchedRatio > 0.3) {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (ctx?.apiSurface?.interfaces) {
|
|
376
|
+
const modelSourceFile = (baselineResponse as any)?.sourceFile as string | undefined;
|
|
377
|
+
const responseDir = modelSourceFile ? modelSourceFile.split('/').slice(0, 2).join('/') : null;
|
|
378
|
+
|
|
379
|
+
for (const field of model.fields) {
|
|
380
|
+
let fieldType = field.type;
|
|
381
|
+
if (fieldType.kind === 'nullable') fieldType = fieldType.inner;
|
|
382
|
+
if (fieldType.kind !== 'array' && fieldType.kind !== 'model') continue;
|
|
383
|
+
const innerType = fieldType.kind === 'array' ? fieldType.items : fieldType;
|
|
384
|
+
if (innerType.kind !== 'model') continue;
|
|
385
|
+
|
|
386
|
+
const nestedWireName = wireInterfaceName(resolveInterfaceName(innerType.name, ctx));
|
|
387
|
+
const wireField3 = wireFieldName(field.name);
|
|
388
|
+
const baselineWireField2 = baselineResponse.fields![wireField3];
|
|
389
|
+
if (!baselineWireField2) continue;
|
|
390
|
+
|
|
391
|
+
const baselineTypeNames: string[] = baselineWireField2.type.match(/\b[A-Z][a-zA-Z0-9]*Response\b/g) || [];
|
|
392
|
+
if (baselineTypeNames.length > 0 && !baselineTypeNames.includes(nestedWireName)) {
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (baselineWireField2.type.includes(nestedWireName) || baselineWireField2.type.match(/\b[A-Z]\w*Response\b/)) {
|
|
397
|
+
const typeNames: string[] = baselineWireField2.type.match(/\b[A-Z][a-zA-Z0-9]*\b/g) || [];
|
|
398
|
+
for (const typeName of typeNames) {
|
|
399
|
+
if (typeName === 'Record' || typeName === 'Array') continue;
|
|
400
|
+
const nestedIface = ctx.apiSurface!.interfaces![typeName];
|
|
401
|
+
if (!nestedIface) continue;
|
|
402
|
+
const nestedSrc = (nestedIface as any).sourceFile as string | undefined;
|
|
403
|
+
if (!nestedSrc || !responseDir) continue;
|
|
404
|
+
const nestedDir = nestedSrc.split('/').slice(0, 2).join('/');
|
|
405
|
+
if (nestedDir !== responseDir) {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Field assignment planning — replaces inline if/else chains
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
/** Plan a single deserializer field assignment. */
|
|
421
|
+
export function planDeserializeField(
|
|
422
|
+
field: Field,
|
|
423
|
+
baselineDomain: BaselineInterface | undefined,
|
|
424
|
+
baselineResponse: BaselineInterface | undefined,
|
|
425
|
+
skipFormatFields: Set<string>,
|
|
426
|
+
ctx: EmitterContext,
|
|
427
|
+
): { line: string; skip: boolean } {
|
|
428
|
+
const domain = fieldName(field.name);
|
|
429
|
+
const wire = wireFieldName(field.name);
|
|
430
|
+
const wireAccess = `response.${wire}`;
|
|
431
|
+
const skip = skipFormatFields.has(field.name);
|
|
432
|
+
const fallbackForNullable = field.type.kind === 'nullable' ? 'null' : 'undefined';
|
|
433
|
+
const expr = skip ? wireAccess : deserializeExpression(field.type, wireAccess, ctx, fallbackForNullable);
|
|
434
|
+
const isNewField = baselineDomain && !baselineDomain.fields?.[domain];
|
|
435
|
+
const effectivelyOptional = !field.required || isNewField;
|
|
436
|
+
|
|
437
|
+
const guard = planDeserializeGuard(field, expr, wireAccess, effectivelyOptional, isNewField, baselineResponse);
|
|
438
|
+
return { line: emitAssignment(domain, expr, wireAccess, guard), skip: false };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function planDeserializeGuard(
|
|
442
|
+
field: Field,
|
|
443
|
+
expr: string,
|
|
444
|
+
wireAccess: string,
|
|
445
|
+
effectivelyOptional: boolean | null | undefined,
|
|
446
|
+
isNewField: boolean | null | undefined,
|
|
447
|
+
baselineResponse: BaselineInterface | undefined,
|
|
448
|
+
): GuardStrategy {
|
|
449
|
+
// Optional field with function-call expression → null check
|
|
450
|
+
if (effectivelyOptional && expr !== wireAccess && needsNullGuard(field.type)) {
|
|
451
|
+
const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
|
|
452
|
+
return { kind: 'null-check', fallback };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Required field with direct assignment — may need coalesce fallback
|
|
456
|
+
if (field.required && expr === wireAccess) {
|
|
457
|
+
const wire = wireFieldName(field.name);
|
|
458
|
+
const responseFieldInfo = baselineResponse?.fields?.[wire];
|
|
459
|
+
const responseFieldOptional = responseFieldInfo?.optional ?? false;
|
|
460
|
+
const needsFallback = responseFieldOptional || !!isNewField;
|
|
461
|
+
const fallback = needsFallback ? defaultForType(field.type) : null;
|
|
462
|
+
if (fallback) {
|
|
463
|
+
return { kind: 'coalesce', fallback };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return { kind: 'direct' };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Plan a single serializer field assignment. */
|
|
471
|
+
export function planSerializeField(
|
|
472
|
+
field: Field,
|
|
473
|
+
baselineDomain: BaselineInterface | undefined,
|
|
474
|
+
baselineResponse: BaselineInterface | undefined,
|
|
475
|
+
skipFormatFields: Set<string>,
|
|
476
|
+
ctx: EmitterContext,
|
|
477
|
+
): { line: string; skip: boolean } {
|
|
478
|
+
const wire = wireFieldName(field.name);
|
|
479
|
+
const domain = fieldName(field.name);
|
|
480
|
+
const domainAccess = `model.${domain}`;
|
|
481
|
+
const skip = skipFormatFields.has(field.name);
|
|
482
|
+
const fallbackForNullable = field.type.kind === 'nullable' ? 'null' : 'undefined';
|
|
483
|
+
const expr = skip ? domainAccess : serializeExpression(field.type, domainAccess, ctx, fallbackForNullable);
|
|
484
|
+
const isNewSerField = baselineDomain && !baselineDomain.fields?.[domain];
|
|
485
|
+
const effectivelyOptionalSer = !field.required || isNewSerField;
|
|
486
|
+
|
|
487
|
+
const guard = planSerializeGuard(
|
|
488
|
+
field,
|
|
489
|
+
expr,
|
|
490
|
+
domainAccess,
|
|
491
|
+
effectivelyOptionalSer,
|
|
492
|
+
isNewSerField,
|
|
493
|
+
baselineDomain,
|
|
494
|
+
baselineResponse,
|
|
495
|
+
);
|
|
496
|
+
return { line: emitAssignment(wire, expr, domainAccess, guard), skip: false };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function planSerializeGuard(
|
|
500
|
+
field: Field,
|
|
501
|
+
expr: string,
|
|
502
|
+
domainAccess: string,
|
|
503
|
+
effectivelyOptionalSer: boolean | null | undefined,
|
|
504
|
+
isNewSerField: boolean | null | undefined,
|
|
505
|
+
baselineDomain: BaselineInterface | undefined,
|
|
506
|
+
baselineResponse: BaselineInterface | undefined,
|
|
507
|
+
): GuardStrategy {
|
|
508
|
+
const wire = wireFieldName(field.name);
|
|
509
|
+
const domain = fieldName(field.name);
|
|
510
|
+
|
|
511
|
+
// Function-call expression for optional/nullable fields → null check
|
|
512
|
+
const shouldGuardSer = effectivelyOptionalSer || field.type.kind === 'nullable';
|
|
513
|
+
if (expr !== domainAccess && needsNullGuard(field.type) && shouldGuardSer) {
|
|
514
|
+
const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
|
|
515
|
+
return { kind: 'null-check', fallback };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Optional domain → required wire: needs coalesce or assert
|
|
519
|
+
const baselineWireField = baselineResponse?.fields?.[wire];
|
|
520
|
+
const baselineDomainField = baselineDomain?.fields?.[domain];
|
|
521
|
+
const isNewFieldOnExistingDomain = baselineDomain && !baselineDomainField;
|
|
522
|
+
const domainFieldIsOptional =
|
|
523
|
+
!field.required || (baselineDomainField?.optional ?? false) || !!isNewFieldOnExistingDomain;
|
|
524
|
+
const wireFieldIsRequired = baselineWireField ? !baselineWireField.optional : field.required;
|
|
525
|
+
const needsUndefinedCoalesce = domainFieldIsOptional && wireFieldIsRequired && expr === domainAccess;
|
|
526
|
+
|
|
527
|
+
if (needsUndefinedCoalesce) {
|
|
528
|
+
const wireHasNull = baselineWireField?.type?.includes('null') || field.type.kind === 'nullable';
|
|
529
|
+
if (wireHasNull) {
|
|
530
|
+
return { kind: 'coalesce', fallback: 'null' };
|
|
531
|
+
}
|
|
532
|
+
return { kind: 'non-null-assert' };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Nullable with direct assignment → may need coalesce for domain-response mismatch
|
|
536
|
+
if (field.type.kind === 'nullable' && expr === domainAccess) {
|
|
537
|
+
const domainWireField2 = wireFieldName(field.name);
|
|
538
|
+
const responseBaselineField2 = baselineResponse?.fields?.[domainWireField2];
|
|
539
|
+
const baselineDomainField2 = baselineDomain?.fields?.[domain];
|
|
540
|
+
const domainResponseMismatch =
|
|
541
|
+
baselineDomainField2 &&
|
|
542
|
+
!baselineDomainField2.optional &&
|
|
543
|
+
responseBaselineField2 &&
|
|
544
|
+
responseBaselineField2.optional;
|
|
545
|
+
const fieldEffectivelyOptional = !field.required || !!isNewSerField || !!domainResponseMismatch;
|
|
546
|
+
if (fieldEffectivelyOptional) {
|
|
547
|
+
return { kind: 'coalesce', fallback: 'null' };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { kind: 'direct' };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
// Field assignment emission — single function for all guard strategies
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
function emitAssignment(lhs: string, expr: string, accessExpr: string, guard: GuardStrategy): string {
|
|
559
|
+
switch (guard.kind) {
|
|
560
|
+
case 'direct':
|
|
561
|
+
return ` ${lhs}: ${expr},`;
|
|
562
|
+
case 'null-check':
|
|
563
|
+
// If the expression already contains a null guard from nullable type handling
|
|
564
|
+
// (e.g., `response.x != null ? deserializeFoo(response.x) : null`),
|
|
565
|
+
// emit it directly — the fallback was baked into the expression.
|
|
566
|
+
// Otherwise, wrap with an outer null check using the accessor.
|
|
567
|
+
if (expr.includes(`${accessExpr} != null ?`)) {
|
|
568
|
+
return ` ${lhs}: ${expr},`;
|
|
569
|
+
}
|
|
570
|
+
return ` ${lhs}: ${accessExpr} != null ? ${expr} : ${guard.fallback},`;
|
|
571
|
+
case 'coalesce':
|
|
572
|
+
return ` ${lhs}: ${expr} ?? ${guard.fallback},`;
|
|
573
|
+
case 'non-null-assert':
|
|
574
|
+
return ` ${lhs}: ${expr}!,`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
// Serializer file emission helpers
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
interface SerializerContext {
|
|
583
|
+
modelToService: Map<string, string>;
|
|
584
|
+
resolveDir: (irService: string | undefined) => string;
|
|
585
|
+
useStringDates: boolean;
|
|
586
|
+
dedup: Map<string, string>;
|
|
587
|
+
skippedSerializeModels: Set<string>;
|
|
588
|
+
ctx: EmitterContext;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Build the import block for a serializer file. */
|
|
592
|
+
export function buildSerializerImports(
|
|
593
|
+
model: Model,
|
|
594
|
+
serializerPath: string,
|
|
595
|
+
dirName: string,
|
|
596
|
+
domainName: string,
|
|
597
|
+
responseName: string,
|
|
598
|
+
sctx: SerializerContext,
|
|
599
|
+
): string[] {
|
|
600
|
+
const lines: string[] = [];
|
|
601
|
+
const interfacePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
602
|
+
lines.push(`import type { ${domainName}, ${responseName} } from '${relativeImport(serializerPath, interfacePath)}';`);
|
|
603
|
+
|
|
604
|
+
const nestedModelRefs = new Set<string>();
|
|
605
|
+
for (const field of model.fields) {
|
|
606
|
+
for (const ref of collectSerializedModelRefs(field.type)) {
|
|
607
|
+
if (ref !== model.name) nestedModelRefs.add(ref);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
for (const dep of nestedModelRefs) {
|
|
612
|
+
const depService = sctx.modelToService.get(dep);
|
|
613
|
+
const depDir = sctx.resolveDir(depService);
|
|
614
|
+
const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
|
|
615
|
+
const depName = resolveInterfaceName(dep, sctx.ctx);
|
|
616
|
+
const rel = relativeImport(serializerPath, depSerializerPath);
|
|
617
|
+
lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
|
|
618
|
+
}
|
|
619
|
+
lines.push('');
|
|
620
|
+
return lines;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Build the set of field names where format conversion should be skipped. */
|
|
624
|
+
export function buildSkipFormatFields(
|
|
625
|
+
model: Model,
|
|
626
|
+
useStringDates: boolean,
|
|
627
|
+
baselineDomain: BaselineInterface | undefined,
|
|
628
|
+
): Set<string> {
|
|
629
|
+
const skipFormatFields = new Set<string>();
|
|
630
|
+
if (useStringDates) {
|
|
631
|
+
for (const field of model.fields) {
|
|
632
|
+
if (hasDateTimeConversion(field.type)) {
|
|
633
|
+
skipFormatFields.add(field.name);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (baselineDomain) {
|
|
638
|
+
for (const field of model.fields) {
|
|
639
|
+
if (skipFormatFields.has(field.name)) continue;
|
|
640
|
+
const baselineField = baselineDomain.fields?.[fieldName(field.name)];
|
|
641
|
+
if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
|
|
642
|
+
skipFormatFields.add(field.name);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return skipFormatFields;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Check if serialize should be skipped (baseline incompat or cascading dependency). */
|
|
650
|
+
export function shouldSkipSerializeForModel(
|
|
651
|
+
model: Model,
|
|
652
|
+
baselineResponse: BaselineInterface | undefined,
|
|
653
|
+
baselineDomain: BaselineInterface | undefined,
|
|
654
|
+
dedup: Map<string, string>,
|
|
655
|
+
skippedSerializeModels: Set<string>,
|
|
656
|
+
ctx: EmitterContext,
|
|
657
|
+
): boolean {
|
|
658
|
+
let shouldSkip = serializerHasBaselineIncompatibility(model, baselineResponse, baselineDomain, ctx);
|
|
659
|
+
if (!shouldSkip) {
|
|
660
|
+
for (const field of model.fields) {
|
|
661
|
+
for (const ref of collectSerializedModelRefs(field.type)) {
|
|
662
|
+
if (skippedSerializeModels.has(ref)) {
|
|
663
|
+
shouldSkip = true;
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
const canon = dedup.get(ref);
|
|
667
|
+
if (canon && skippedSerializeModels.has(canon)) {
|
|
668
|
+
shouldSkip = true;
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (shouldSkip) break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return shouldSkip;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Emit deserializer + serializer body lines for a model. */
|
|
679
|
+
export function emitSerializerBody(
|
|
680
|
+
model: Model,
|
|
681
|
+
domainName: string,
|
|
682
|
+
responseName: string,
|
|
683
|
+
typeParams: { decl: string; usage: string },
|
|
684
|
+
baselineDomain: BaselineInterface | undefined,
|
|
685
|
+
baselineResponse: BaselineInterface | undefined,
|
|
686
|
+
skipFormatFields: Set<string>,
|
|
687
|
+
shouldSkipSerialize: boolean,
|
|
688
|
+
ctx: EmitterContext,
|
|
689
|
+
): string[] {
|
|
690
|
+
const lines: string[] = [];
|
|
691
|
+
|
|
692
|
+
// Deserialize function (wire → domain)
|
|
693
|
+
const seenDeserFields = new Set<string>();
|
|
694
|
+
const deserParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
695
|
+
lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
|
|
696
|
+
lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
|
|
697
|
+
lines.push(`): ${domainName}${typeParams.usage} => ({`);
|
|
698
|
+
for (const field of model.fields) {
|
|
699
|
+
const domain = fieldName(field.name);
|
|
700
|
+
if (seenDeserFields.has(domain)) continue;
|
|
701
|
+
seenDeserFields.add(domain);
|
|
702
|
+
const plan = planDeserializeField(field, baselineDomain, baselineResponse, skipFormatFields, ctx);
|
|
703
|
+
if (!plan.skip) lines.push(plan.line);
|
|
704
|
+
}
|
|
705
|
+
lines.push('});');
|
|
706
|
+
|
|
707
|
+
// Serialize function (domain → wire)
|
|
708
|
+
if (!shouldSkipSerialize) {
|
|
709
|
+
const serParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
710
|
+
lines.push('');
|
|
711
|
+
lines.push(`export const serialize${domainName} = ${typeParams.decl}(`);
|
|
712
|
+
lines.push(` ${serParamPrefix}model: ${domainName}${typeParams.usage},`);
|
|
713
|
+
lines.push(`): ${responseName}${typeParams.usage} => ({`);
|
|
714
|
+
const seenSerFields = new Set<string>();
|
|
715
|
+
for (const field of model.fields) {
|
|
716
|
+
const wire = wireFieldName(field.name);
|
|
717
|
+
if (seenSerFields.has(wire)) continue;
|
|
718
|
+
seenSerFields.add(wire);
|
|
719
|
+
const plan = planSerializeField(field, baselineDomain, baselineResponse, skipFormatFields, ctx);
|
|
720
|
+
if (!plan.skip) lines.push(plan.line);
|
|
721
|
+
}
|
|
722
|
+
lines.push('});');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return lines;
|
|
726
|
+
}
|