@workos/oagen-emitters 0.2.1 → 0.4.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.
Files changed (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,1019 @@
1
+ import type {
2
+ ApiSpec,
3
+ EmitterContext,
4
+ GeneratedFile,
5
+ Operation,
6
+ Service,
7
+ Model,
8
+ TypeRef,
9
+ ResolvedOperation,
10
+ ResolvedWrapper,
11
+ } from '@workos/oagen';
12
+ import { planOperation } from '@workos/oagen';
13
+ import { apiClassName, packageSegment, resolveMethodName, ktStringLiteral, className, propertyName } from './naming.js';
14
+ import { mapTypeRef } from './type-map.js';
15
+ import { groupByMount, lookupResolved, buildResolvedLookup, buildHiddenParams } from '../shared/resolved-ops.js';
16
+ import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
17
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
18
+ import { isHandwrittenOverride } from './overrides.js';
19
+
20
+ const TEST_PREFIX = 'src/test/kotlin/';
21
+
22
+ /**
23
+ * Generate one JUnit 5 + WireMock test class per API mount group, plus a
24
+ * cross-cutting model round-trip test.
25
+ *
26
+ * Per mount group the emitter produces:
27
+ * - A happy-path test for every operation whose required arguments can be
28
+ * synthesized (primitives, enums, arrays, maps). Deserializes a minimal
29
+ * JSON response and asserts a non-null result.
30
+ * - 401/404/429/500 error-mapping tests against one representative operation
31
+ * in the group.
32
+ *
33
+ * Operations with required arguments we can't synthesize (e.g. a required
34
+ * model object in the request body) fall back to error-only coverage using
35
+ * the representative operation.
36
+ */
37
+ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
38
+ const files: GeneratedFile[] = [];
39
+ const mountGroups = groupByMount(ctx);
40
+ const resolvedLookup = buildResolvedLookup(ctx);
41
+
42
+ for (const [mountName, group] of mountGroups) {
43
+ const content = generateServiceTestClass(mountName, group.operations, ctx, resolvedLookup);
44
+ if (!content) continue;
45
+ const pkg = packageSegment(mountName);
46
+ files.push({
47
+ path: `${TEST_PREFIX}com/workos/${pkg}/${apiClassName(mountName)}Test.kt`,
48
+ content,
49
+ overwriteExisting: true,
50
+ });
51
+ }
52
+
53
+ const roundTripFile = generateModelRoundTripTest(spec, ctx);
54
+ if (roundTripFile) files.push(roundTripFile);
55
+
56
+ const forwardCompatFile = generateForwardCompatTest(spec, ctx);
57
+ if (forwardCompatFile) files.push(forwardCompatFile);
58
+
59
+ return files;
60
+ }
61
+
62
+ interface OpTest {
63
+ method: string;
64
+ httpMethod: string; // lowercase for WireMock
65
+ pathForWireMock: string;
66
+ callArgs: string;
67
+ responseClass: string | null;
68
+ minimalResponseBody: string;
69
+ canEmitHappyPath: boolean;
70
+ imports: Set<string>;
71
+ /** Wire field names required in the request body — asserted via matchingJsonPath. */
72
+ requiredBodyPaths: string[];
73
+ /** `name=value` pairs required on the query string — asserted via matchingRegex. */
74
+ requiredQueryAssertions: { name: string; valueRegex: string }[];
75
+ /** Assertions on response fields: { kotlinAccessor, expectedExpr }. */
76
+ responseAssertions: { accessor: string; expectedExpr: string }[];
77
+ }
78
+
79
+ function generateServiceTestClass(
80
+ mountName: string,
81
+ operations: Operation[],
82
+ ctx: EmitterContext,
83
+ resolvedLookup: Map<string, ResolvedOperation>,
84
+ ): string | null {
85
+ const imports = new Set<string>();
86
+ // Base JUnit/exception imports — always present.
87
+ imports.add('com.workos.common.exceptions.GenericServerException');
88
+ imports.add('com.workos.common.exceptions.NotFoundException');
89
+ imports.add('com.workos.common.exceptions.RateLimitException');
90
+ imports.add('com.workos.common.exceptions.UnauthorizedException');
91
+ imports.add('com.workos.test.TestBase');
92
+ imports.add('org.junit.jupiter.api.Assertions.assertNotNull');
93
+ imports.add('org.junit.jupiter.api.Assertions.assertThrows');
94
+ imports.add('org.junit.jupiter.api.Test');
95
+
96
+ const opTests: OpTest[] = [];
97
+
98
+ for (const op of operations) {
99
+ if (isHandwrittenOverride(op)) continue;
100
+ const resolved = lookupResolved(op, resolvedLookup);
101
+ const wrappers = resolved?.wrappers ?? [];
102
+ if (wrappers.length > 0) {
103
+ // Union-split operation — emit one test per wrapper.
104
+ for (const wrapper of wrappers) {
105
+ const test = buildWrapperTest(op, wrapper, ctx);
106
+ if (test) opTests.push(test);
107
+ }
108
+ continue;
109
+ }
110
+
111
+ const test = buildOperationTest(op, resolved, ctx);
112
+ if (test) opTests.push(test);
113
+ }
114
+
115
+ if (opTests.length === 0) return null;
116
+
117
+ // Deduplicate by method name (split operations map to distinct methods;
118
+ // non-wrapper operations already have unique names).
119
+ const seen = new Set<string>();
120
+ const uniqueTests = opTests.filter((t) => {
121
+ if (seen.has(t.method)) return false;
122
+ seen.add(t.method);
123
+ return true;
124
+ });
125
+
126
+ // Pick a "representative" op for error-mapping tests. Prefer the first op
127
+ // that has no path params (simplest URL to stub). Fall back to the first.
128
+ const repOp = uniqueTests.find((t) => !t.pathForWireMock.includes('sample-arg')) ?? uniqueTests[0];
129
+
130
+ // Only register per-op imports for tests that will actually emit a body.
131
+ // Ops that can't synthesize a happy path don't contribute to the file, so
132
+ // their imports (HTTP methods, payload types) would be unused.
133
+ const httpMethodsUsed = new Set<string>();
134
+ for (const t of uniqueTests) {
135
+ if (!t.canEmitHappyPath) continue;
136
+ t.imports.forEach((i) => imports.add(i));
137
+ httpMethodsUsed.add(t.httpMethod);
138
+ }
139
+ // The representative op is used for error-mapping tests regardless of its
140
+ // happy-path status, so its type imports are always needed.
141
+ repOp.imports.forEach((i) => imports.add(i));
142
+ httpMethodsUsed.add(repOp.httpMethod);
143
+
144
+ // Register request-verification imports only for operations that actually
145
+ // emit verify() calls (i.e., have body/query assertions). This avoids
146
+ // unused `*RequestedFor` and `urlPathMatching` imports in test files where
147
+ // no happy-path test has scalar required params.
148
+ const verifyMethods = new Set<string>();
149
+ for (const t of uniqueTests) {
150
+ if (!t.canEmitHappyPath) continue;
151
+ if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0) {
152
+ verifyMethods.add(t.httpMethod);
153
+ }
154
+ }
155
+ if (verifyMethods.size > 0) {
156
+ imports.add('com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching');
157
+ for (const m of verifyMethods) {
158
+ imports.add(`com.github.tomakehurst.wiremock.client.WireMock.${m}RequestedFor`);
159
+ }
160
+ }
161
+ const anyBody = uniqueTests.some((t) => t.canEmitHappyPath && t.requiredBodyPaths.length > 0);
162
+ const anyQuery = uniqueTests.some((t) => t.canEmitHappyPath && t.requiredQueryAssertions.length > 0);
163
+ if (anyBody) imports.add('com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath');
164
+ if (anyQuery) imports.add('com.github.tomakehurst.wiremock.client.WireMock.matching');
165
+ // assertEquals is needed when any test has response field assertions.
166
+ if (uniqueTests.some((t) => t.canEmitHappyPath && t.responseAssertions.length > 0)) {
167
+ imports.add('org.junit.jupiter.api.Assertions.assertEquals');
168
+ }
169
+
170
+ const pkg = packageSegment(mountName);
171
+ const apiCls = apiClassName(mountName);
172
+
173
+ // If any operation would emit a disabled placeholder test, preregister
174
+ // the `Disabled` import before we serialize the header.
175
+ if (uniqueTests.some((t) => !t.canEmitHappyPath)) {
176
+ imports.add('org.junit.jupiter.api.Disabled');
177
+ }
178
+
179
+ const lines: string[] = [];
180
+ lines.push(`package com.workos.${pkg}`);
181
+ lines.push('');
182
+ for (const imp of [...imports].sort()) {
183
+ lines.push(`import ${imp}`);
184
+ }
185
+ lines.push('');
186
+ lines.push(`class ${apiCls}Test : TestBase() {`);
187
+ lines.push(` private fun api() = ${apiCls}(createWorkOSClient())`);
188
+
189
+ for (const t of uniqueTests) {
190
+ if (t.canEmitHappyPath) {
191
+ emitHappyPathTest(lines, t);
192
+ } else {
193
+ // Previously these were silently dropped. Emitting a disabled test
194
+ // keeps the method visible in test reports so contributors know there
195
+ // is intentionally no synthesized coverage, rather than being surprised
196
+ // that the method has zero tests.
197
+ emitDisabledHappyPathTest(lines, t);
198
+ }
199
+ }
200
+
201
+ emitErrorTest(lines, '401', 'UnauthorizedException', repOp);
202
+ emitErrorTest(lines, '404', 'NotFoundException', repOp);
203
+ emitErrorTest(lines, '429', 'RateLimitException', repOp);
204
+ emitErrorTest(lines, '500', 'GenericServerException', repOp);
205
+
206
+ lines.push('}');
207
+ lines.push('');
208
+ return lines.join('\n');
209
+ }
210
+
211
+ function buildOperationTest(
212
+ op: Operation,
213
+ resolved: ResolvedOperation | undefined,
214
+ ctx: EmitterContext,
215
+ ): OpTest | null {
216
+ const svc = findService(ctx, op);
217
+ if (!svc) return null;
218
+ const method = resolveMethodName(op, svc, ctx);
219
+ const plan = planOperation(op);
220
+
221
+ const hidden = buildHiddenParams(resolved);
222
+
223
+ // Build call args in the order expected by the generated method signature:
224
+ // pathParams ++ requiredQuery ++ requiredBodyFields
225
+ const imports = new Set<string>();
226
+ const argParts: string[] = [];
227
+ const requiredBodyPaths: string[] = [];
228
+ const requiredQueryAssertions: { name: string; valueRegex: string }[] = [];
229
+
230
+ for (const _pp of op.pathParams) argParts.push(ktStringLiteral('sample-arg'));
231
+
232
+ const queryFields = op.queryParams.filter((p) => !hidden.has(p.name));
233
+ const sortedQuery = [...queryFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
234
+ for (const qp of sortedQuery) {
235
+ if (!qp.required) break;
236
+ const val = synthValue(qp.type, ctx, imports);
237
+ if (val === null) return null;
238
+ argParts.push(val);
239
+ // Best-effort wire assertion: for primitives/strings we know the synthesized
240
+ // value so we can assert equality; otherwise just assert presence.
241
+ const regex = queryValueRegexFor(qp.type);
242
+ if (regex !== null) requiredQueryAssertions.push({ name: qp.name, valueRegex: regex });
243
+ }
244
+
245
+ const bodyModel = resolveBodyModel(op, ctx);
246
+ if (bodyModel) {
247
+ // Body fields always pass; colliding names are renamed (e.g. slug →
248
+ // bodySlug) by the resources emitter, so every required body field still
249
+ // needs a test argument here.
250
+ const bodyFields = bodyModel.fields.filter((f) => !hidden.has(f.name));
251
+ const sortedBody = [...bodyFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
252
+ for (const bf of sortedBody) {
253
+ if (!bf.required) break;
254
+ const val = synthValue(bf.type, ctx, imports);
255
+ if (val === null) return null;
256
+ argParts.push(val);
257
+ // matchingJsonPath on an array/map body field fails on empty
258
+ // synthesized collections because JsonPath returns an empty result
259
+ // set. Scalar fields always materialize with a concrete value, so
260
+ // we only assert those paths.
261
+ if (isScalarBodyField(bf.type)) requiredBodyPaths.push(bf.name);
262
+ }
263
+ }
264
+
265
+ const plan2 = plan;
266
+ const responseClass = plan2.isPaginated
267
+ ? 'Page'
268
+ : plan2.responseModelName
269
+ ? className(plan2.responseModelName)
270
+ : null;
271
+
272
+ const minimalBody = buildResponseBody(plan2, ctx);
273
+
274
+ // Void/delete methods don't need a response class or body — they succeed
275
+ // when the call completes without throwing. We can emit a happy-path test
276
+ // as long as we were able to synthesize all required arguments.
277
+ const isVoidMethod = responseClass === null;
278
+ const canEmitHappyPath = isVoidMethod || (responseClass !== null && minimalBody !== null);
279
+
280
+ // Build response field assertions for non-paginated, non-array model responses.
281
+ // Array responses return List<T>, so `result.field` doesn't compile.
282
+ const responseAssertions =
283
+ !plan2.isPaginated && !plan2.isArrayResponse && plan2.responseModelName
284
+ ? buildResponseAssertions(plan2.responseModelName, ctx)
285
+ : [];
286
+
287
+ return {
288
+ method,
289
+ httpMethod: op.httpMethod.toLowerCase(),
290
+ pathForWireMock: op.path.replace(/\{[^}]+\}/g, 'sample-arg'),
291
+ callArgs: argParts.join(', '),
292
+ responseClass,
293
+ minimalResponseBody: minimalBody ?? '{}',
294
+ canEmitHappyPath,
295
+ imports,
296
+ requiredBodyPaths,
297
+ requiredQueryAssertions,
298
+ responseAssertions,
299
+ };
300
+ }
301
+
302
+ /** True if the synthesized body value serializes to a concrete JSON scalar. */
303
+ function isScalarBodyField(type: TypeRef): boolean {
304
+ const inner = type.kind === 'nullable' ? type.inner : type;
305
+ if (inner.kind === 'primitive') return inner.format !== 'binary';
306
+ if (inner.kind === 'enum') return true;
307
+ if (inner.kind === 'literal') return true;
308
+ return false;
309
+ }
310
+
311
+ /**
312
+ * When we can recognize the synthesized test value for a query param,
313
+ * return a regex that matches the expected serialized form. Returns null
314
+ * when the value is too complex to assert (e.g. arrays, models).
315
+ */
316
+ function queryValueRegexFor(type: TypeRef): string | null {
317
+ const inner = type.kind === 'nullable' ? type.inner : type;
318
+ if (inner.kind === 'primitive') {
319
+ if (inner.format === 'date-time') return null; // OffsetDateTime.now() — not reproducible
320
+ switch (inner.type) {
321
+ case 'string':
322
+ return 'sample-arg';
323
+ case 'integer':
324
+ return '0';
325
+ case 'number':
326
+ return '0\\.0';
327
+ case 'boolean':
328
+ return 'false';
329
+ }
330
+ return null;
331
+ }
332
+ return null;
333
+ }
334
+
335
+ function buildResponseBody(plan: ReturnType<typeof planOperation>, ctx: EmitterContext): string | null {
336
+ if (plan.isPaginated) {
337
+ return `{"data": [], "list_metadata": {"before": null, "after": null}}`;
338
+ }
339
+ if (!plan.responseModelName) return null;
340
+ const itemJson = synthJsonForModelName(plan.responseModelName, ctx, new Set());
341
+ if (itemJson === null) return null;
342
+ // For `type: array` responses, the Kotlin method returns `List<T>` and
343
+ // Jackson expects a JSON array on the wire, not a single object.
344
+ if (plan.isArrayResponse) return `[${itemJson}]`;
345
+ return itemJson;
346
+ }
347
+
348
+ function buildWrapperTest(op: Operation, wrapper: ResolvedWrapper, ctx: EmitterContext): OpTest | null {
349
+ const method = propertyName(wrapper.name);
350
+ const imports = new Set<string>();
351
+ const argParts: string[] = [];
352
+
353
+ for (const _pp of op.pathParams) argParts.push(ktStringLiteral('sample-arg'));
354
+
355
+ const resolved = resolveWrapperParams(wrapper, ctx);
356
+ for (const rp of resolved) {
357
+ if (rp.isOptional) continue;
358
+ if (!rp.field) {
359
+ argParts.push(ktStringLiteral('sample-arg'));
360
+ continue;
361
+ }
362
+ const val = synthValue(rp.field.type, ctx, imports);
363
+ if (val === null) return null;
364
+ argParts.push(val);
365
+ }
366
+
367
+ const responseClass = wrapper.responseModelName ? className(wrapper.responseModelName) : null;
368
+ const minimalBody = wrapper.responseModelName
369
+ ? synthJsonForModelName(wrapper.responseModelName, ctx, new Set())
370
+ : null;
371
+ const isVoidMethod = responseClass === null;
372
+ const canEmitHappyPath = isVoidMethod || (responseClass !== null && minimalBody !== null);
373
+ const responseAssertions = wrapper.responseModelName ? buildResponseAssertions(wrapper.responseModelName, ctx) : [];
374
+
375
+ return {
376
+ method,
377
+ httpMethod: op.httpMethod.toLowerCase(),
378
+ pathForWireMock: op.path.replace(/\{[^}]+\}/g, 'sample-arg'),
379
+ callArgs: argParts.join(', '),
380
+ responseClass,
381
+ minimalResponseBody: minimalBody ?? '{}',
382
+ canEmitHappyPath,
383
+ imports,
384
+ requiredBodyPaths: [],
385
+ requiredQueryAssertions: [],
386
+ responseAssertions,
387
+ };
388
+ }
389
+
390
+ /** Synthesize a Kotlin expression for a typed value. Returns null if we cannot. */
391
+ function synthValue(type: TypeRef, ctx: EmitterContext, imports: Set<string>): string | null {
392
+ if (type.kind === 'nullable') {
393
+ return 'null';
394
+ }
395
+ if (type.kind === 'primitive') {
396
+ if (type.format === 'binary') return 'ByteArray(0)';
397
+ if (type.format === 'date-time') {
398
+ imports.add('java.time.OffsetDateTime');
399
+ return 'OffsetDateTime.now()';
400
+ }
401
+ switch (type.type) {
402
+ case 'string':
403
+ return '"sample-arg"';
404
+ case 'integer':
405
+ if (type.format === 'int64') return '0L';
406
+ return '0';
407
+ case 'number':
408
+ return '0.0';
409
+ case 'boolean':
410
+ return 'false';
411
+ }
412
+ return null;
413
+ }
414
+ if (type.kind === 'enum') {
415
+ const cls = className(type.name);
416
+ imports.add(`com.workos.types.${cls}`);
417
+ // Skip `Unknown` (index 0) — serializing the Unknown sentinel throws
418
+ // because it exists only for forward-compat deserialization. Pick the
419
+ // first concrete variant instead.
420
+ return `${cls}.values().first { it != ${cls}.Unknown }`;
421
+ }
422
+ if (type.kind === 'array') {
423
+ // Empty list of the right item type. Kotlin's List<T> is invariant.
424
+ const itemType = renderTypeForSynthesis(type.items, ctx, imports);
425
+ if (itemType === null) return null;
426
+ return `emptyList<${itemType}>()`;
427
+ }
428
+ if (type.kind === 'map') {
429
+ const valueType = renderTypeForSynthesis(type.valueType, ctx, imports);
430
+ if (valueType === null) return null;
431
+ return `emptyMap<String, ${valueType}>()`;
432
+ }
433
+ if (type.kind === 'literal') {
434
+ if (typeof type.value === 'string') return ktStringLiteral(type.value);
435
+ if (typeof type.value === 'number') return String(type.value);
436
+ if (typeof type.value === 'boolean') return String(type.value);
437
+ return 'null';
438
+ }
439
+ // model / union — too complex to synthesize generically.
440
+ return null;
441
+ }
442
+
443
+ /**
444
+ * Render a Kotlin type string for use as a generic type parameter in a
445
+ * synthesized empty collection. Registers any required imports (enums,
446
+ * models). Returns null when the type can't be reduced to a concrete
447
+ * Kotlin class.
448
+ */
449
+ function renderTypeForSynthesis(type: TypeRef, ctx: EmitterContext, imports: Set<string>): string | null {
450
+ if (type.kind === 'model') {
451
+ const cls = className(type.name);
452
+ imports.add(`com.workos.models.${cls}`);
453
+ return cls;
454
+ }
455
+ if (type.kind === 'enum') {
456
+ const cls = className(type.name);
457
+ imports.add(`com.workos.types.${cls}`);
458
+ return cls;
459
+ }
460
+ if (type.kind === 'union') {
461
+ // Unions render as Any; an empty list is still valid.
462
+ return 'Any';
463
+ }
464
+ // For everything else (primitives, arrays, maps, literals) the IR mapping
465
+ // produces a self-contained Kotlin type expression.
466
+ return mapTypeRef(type);
467
+ }
468
+
469
+ function resolveBodyModel(op: Operation, ctx: EmitterContext): Model | null {
470
+ const body = op.requestBody;
471
+ if (!body) return null;
472
+ if (body.kind !== 'model') return null;
473
+ return ctx.spec.models.find((m) => m.name === body.name) ?? null;
474
+ }
475
+
476
+ /**
477
+ * Build a minimal JSON string whose required fields satisfy the model's
478
+ * contract. Nested model references are resolved recursively. Returns null
479
+ * if a required field has a type we can't synthesize (e.g. open union).
480
+ */
481
+ function synthJsonForModelName(name: string, ctx: EmitterContext, visited: Set<string>): string | null {
482
+ if (visited.has(name)) return null;
483
+ visited.add(name);
484
+ const model = ctx.spec.models.find((m) => m.name === name);
485
+ if (!model) return null;
486
+
487
+ const entries: string[] = [];
488
+ for (const field of model.fields) {
489
+ if (!field.required) continue;
490
+ const val = synthJsonValue(field.type, ctx, visited);
491
+ if (val === null) {
492
+ visited.delete(name);
493
+ return null;
494
+ }
495
+ entries.push(`${JSON.stringify(field.name)}: ${val}`);
496
+ }
497
+ visited.delete(name);
498
+ return `{${entries.join(', ')}}`;
499
+ }
500
+
501
+ /** Produce a JSON literal (string) for a given IR TypeRef, or null. */
502
+ function synthJsonValue(type: TypeRef, ctx: EmitterContext, visited: Set<string>): string | null {
503
+ if (type.kind === 'nullable') return 'null';
504
+ if (type.kind === 'primitive') {
505
+ if (type.format === 'binary') return '""';
506
+ if (type.format === 'date-time') return '"2024-01-01T00:00:00Z"';
507
+ if (type.format === 'date') return '"2024-01-01"';
508
+ switch (type.type) {
509
+ case 'string':
510
+ return '"sample"';
511
+ case 'integer':
512
+ case 'number':
513
+ return '0';
514
+ case 'boolean':
515
+ return 'false';
516
+ }
517
+ return null;
518
+ }
519
+ if (type.kind === 'enum') {
520
+ const em = ctx.spec.enums.find((e) => e.name === type.name);
521
+ if (em && em.values.length > 0) {
522
+ return JSON.stringify(String(em.values[0].value));
523
+ }
524
+ return '"unknown"';
525
+ }
526
+ if (type.kind === 'array') return '[]';
527
+ if (type.kind === 'map') return '{}';
528
+ if (type.kind === 'literal') {
529
+ if (typeof type.value === 'string') return JSON.stringify(type.value);
530
+ if (typeof type.value === 'number') return String(type.value);
531
+ if (typeof type.value === 'boolean') return String(type.value);
532
+ return 'null';
533
+ }
534
+ if (type.kind === 'model') {
535
+ return synthJsonForModelName(type.name, ctx, visited);
536
+ }
537
+ if (type.kind === 'union') {
538
+ // Try to pick a synthesizable variant.
539
+ for (const v of type.variants) {
540
+ const syn = synthJsonValue(v, ctx, visited);
541
+ if (syn !== null) return syn;
542
+ }
543
+ return null;
544
+ }
545
+ return null;
546
+ }
547
+
548
+ /**
549
+ * Build assertEquals assertions for required scalar fields on a response model.
550
+ * Returns `{ accessor, expectedExpr }` pairs for fields whose JSON value we
551
+ * synthesize and whose Kotlin type we can assert against.
552
+ *
553
+ * Only asserts fields present on ALL structurally-identical models in the
554
+ * dedup group. This avoids broken assertions when the Kotlin class is a
555
+ * typealias pointing at a canonical model with a different field set.
556
+ * As a practical heuristic we restrict to fields that appear on the
557
+ * response model itself (models that get deduplicated share the same fields).
558
+ */
559
+ const MAX_RESPONSE_ASSERTIONS = 5;
560
+
561
+ function buildResponseAssertions(
562
+ responseModelName: string | null,
563
+ ctx: EmitterContext,
564
+ ): { accessor: string; expectedExpr: string }[] {
565
+ if (!responseModelName) return [];
566
+ const model = ctx.spec.models.find((m) => m.name === responseModelName);
567
+ if (!model) return [];
568
+
569
+ const assertions: { accessor: string; expectedExpr: string }[] = [];
570
+ for (const field of model.fields) {
571
+ if (!field.required) continue;
572
+ if (assertions.length >= MAX_RESPONSE_ASSERTIONS) break;
573
+ const ktProp = propertyName(field.name);
574
+ const type = field.type;
575
+ if (type.kind === 'primitive') {
576
+ if (type.format === 'date-time') continue;
577
+ switch (type.type) {
578
+ case 'string':
579
+ assertions.push({ accessor: ktProp, expectedExpr: '"sample"' });
580
+ break;
581
+ case 'integer':
582
+ assertions.push({ accessor: ktProp, expectedExpr: type.format === 'int32' ? '0' : '0L' });
583
+ break;
584
+ case 'number':
585
+ assertions.push({ accessor: ktProp, expectedExpr: '0.0' });
586
+ break;
587
+ case 'boolean':
588
+ assertions.push({ accessor: ktProp, expectedExpr: 'false' });
589
+ break;
590
+ }
591
+ } else if (type.kind === 'literal') {
592
+ if (typeof type.value === 'string') {
593
+ assertions.push({ accessor: ktProp, expectedExpr: ktStringLiteral(type.value) });
594
+ } else if (typeof type.value === 'number') {
595
+ assertions.push({ accessor: ktProp, expectedExpr: String(type.value) });
596
+ } else if (typeof type.value === 'boolean') {
597
+ assertions.push({ accessor: ktProp, expectedExpr: String(type.value) });
598
+ }
599
+ }
600
+ }
601
+ return assertions;
602
+ }
603
+
604
+ function emitHappyPathTest(lines: string[], t: OpTest): void {
605
+ lines.push('');
606
+ lines.push(` @Test`);
607
+ const isVoid = t.responseClass === null;
608
+ const testLabel = isVoid ? `${t.method} completes without throwing` : `${t.method} returns a typed response`;
609
+ lines.push(` fun \`${testLabel}\`() {`);
610
+
611
+ // Void/delete methods don't return a body — stub with 200 and empty body.
612
+ const statusCode = isVoid ? (t.httpMethod === 'delete' ? 204 : 200) : 200;
613
+ if (isVoid) {
614
+ lines.push(
615
+ ` stubResponse(${ktStringLiteral(t.httpMethod.toUpperCase())}, ${ktStringLiteral(t.pathForWireMock)}, ${statusCode})`,
616
+ );
617
+ } else {
618
+ const bodyString = ktStringLiteral(t.minimalResponseBody);
619
+ const stubLine = ` stubResponse(${ktStringLiteral(t.httpMethod.toUpperCase())}, ${ktStringLiteral(t.pathForWireMock)}, ${statusCode}, ${bodyString})`;
620
+ if (stubLine.length <= KTLINT_MAX_LINE_LENGTH) {
621
+ lines.push(stubLine);
622
+ } else {
623
+ lines.push(' stubResponse(');
624
+ lines.push(` ${ktStringLiteral(t.httpMethod.toUpperCase())},`);
625
+ lines.push(` ${ktStringLiteral(t.pathForWireMock)},`);
626
+ lines.push(` ${statusCode},`);
627
+ emitStubResponseBody(lines, ' ', t.minimalResponseBody);
628
+ lines.push(' )');
629
+ }
630
+ }
631
+
632
+ if (isVoid) {
633
+ emitCall(lines, ' ', `api().${t.method}`, t.callArgs);
634
+ } else {
635
+ emitCall(lines, ' ', `val result = api().${t.method}`, t.callArgs);
636
+ lines.push(' assertNotNull(result)');
637
+ // Emit exact-value assertions for required scalar fields in the response.
638
+ for (const a of t.responseAssertions) {
639
+ lines.push(` assertEquals(${a.expectedExpr}, result.${a.accessor})`);
640
+ }
641
+ }
642
+
643
+ // Verify the outbound request shape. Body fields and query assertions
644
+ // live on the `OpTest` and are only emitted when we know the synthesized
645
+ // arguments produce a deterministic wire representation.
646
+ if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0) {
647
+ lines.push(' wireMockRule.verify(');
648
+ lines.push(` ${t.httpMethod}RequestedFor(urlPathMatching(${ktStringLiteral(t.pathForWireMock)}))`);
649
+ for (const path of t.requiredBodyPaths) {
650
+ lines.push(` .withRequestBody(matchingJsonPath(${ktStringLiteral(`$.${path}`)}))`);
651
+ }
652
+ for (const qa of t.requiredQueryAssertions) {
653
+ lines.push(` .withQueryParam(${ktStringLiteral(qa.name)}, matching(${ktStringLiteral(qa.valueRegex)}))`);
654
+ }
655
+ lines.push(' )');
656
+ }
657
+ lines.push(' }');
658
+ }
659
+
660
+ /**
661
+ * Emit a `@Disabled` placeholder for operations whose happy-path arguments
662
+ * could not be synthesized (for example, a required body union that the
663
+ * test generator cannot construct). The disabled test keeps the method in
664
+ * the test report so CI surfaces the coverage gap.
665
+ */
666
+ function emitDisabledHappyPathTest(lines: string[], t: OpTest): void {
667
+ lines.push('');
668
+ lines.push(` @Test`);
669
+ lines.push(` @Disabled("generator: could not synthesize required arguments for ${t.method}")`);
670
+ lines.push(` fun \`${t.method} returns a typed response\`() {`);
671
+ lines.push(` // Intentionally empty: the generator could not synthesize required arguments.`);
672
+ lines.push(' }');
673
+ }
674
+
675
+ function emitErrorTest(lines: string[], status: string, exceptionName: string, t: OpTest): void {
676
+ lines.push('');
677
+ lines.push(` @Test`);
678
+ lines.push(` fun \`${t.method} translates ${status} to ${exceptionName}\`() {`);
679
+ lines.push(
680
+ ` stubResponse(${ktStringLiteral(t.httpMethod.toUpperCase())}, ${ktStringLiteral(t.pathForWireMock)}, ${status})`,
681
+ );
682
+ lines.push(` assertThrows(${exceptionName}::class.java) {`);
683
+ emitCall(lines, ' ', `api().${t.method}`, t.callArgs);
684
+ lines.push(' }');
685
+ lines.push(' }');
686
+ }
687
+
688
+ /**
689
+ * Emit `val json = "..."` on a single line when it fits within KTLINT_MAX_LINE_LENGTH,
690
+ * otherwise split the string literal across lines joined with `+`.
691
+ */
692
+ function emitJsonVal(lines: string[], indent: string, rawJson: string): void {
693
+ const encoded = ktStringLiteral(rawJson);
694
+ const singleLine = `${indent}val json = ${encoded}`;
695
+ if (singleLine.length <= KTLINT_MAX_LINE_LENGTH) {
696
+ lines.push(singleLine);
697
+ return;
698
+ }
699
+ // ktlint: "A multiline expression should start on a new line"
700
+ lines.push(`${indent}val json =`);
701
+ // ktlint indent rules (with indent_size=2, continuation_indent=2):
702
+ // first continuation after `=`: indent + 2 (e.g. 6 spaces)
703
+ // subsequent `+` continuations: indent + 4 (e.g. 8 spaces)
704
+ const firstIndent = `${indent} `;
705
+ const restIndent = `${indent} `;
706
+ // Budget for the widest indent so every chunk fits.
707
+ const maxChunkLineLen = KTLINT_MAX_LINE_LENGTH - restIndent.length - 2; // 2 for " +"
708
+ const chunks = splitEscapedStringLiteral(encoded, maxChunkLineLen);
709
+ for (let i = 0; i < chunks.length; i++) {
710
+ const suffix = i === chunks.length - 1 ? '' : ' +';
711
+ const lineIndent = i === 0 ? firstIndent : restIndent;
712
+ lines.push(`${lineIndent}${chunks[i]}${suffix}`);
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Emit the body argument for a multi-line `stubResponse(...)` call. When the
718
+ * encoded literal fits on one line it is emitted directly; otherwise it is
719
+ * broken into string-plus-string chunks joined with `+`.
720
+ */
721
+ function emitStubResponseBody(lines: string[], indent: string, body: string): void {
722
+ const encoded = ktStringLiteral(body);
723
+ if (`${indent}${encoded}`.length <= KTLINT_MAX_LINE_LENGTH) {
724
+ lines.push(`${indent}${encoded}`);
725
+ return;
726
+ }
727
+ const continuationIndent = indent.length + 2;
728
+ const maxChunkLineLen = KTLINT_MAX_LINE_LENGTH - continuationIndent - 2;
729
+ const chunks = splitEscapedStringLiteral(encoded, maxChunkLineLen);
730
+ for (let i = 0; i < chunks.length; i++) {
731
+ const suffix = i === chunks.length - 1 ? '' : ' +';
732
+ const prefix = i === 0 ? '' : ' ';
733
+ lines.push(`${indent}${prefix}${chunks[i]}${suffix}`);
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Split a Kotlin string literal (including its wrapping quotes) into
739
+ * smaller literals such that each one is <= [maxChunkLen] characters. Splits
740
+ * preferentially after commas or spaces, and never inside a `\X` escape
741
+ * sequence.
742
+ */
743
+ function splitEscapedStringLiteral(literal: string, maxChunkLen: number): string[] {
744
+ // Strip the outer wrapping quotes.
745
+ const inner = literal.slice(1, -1);
746
+ const chunks: string[] = [];
747
+ // Reserve 2 chars for the wrapping quotes of each output chunk.
748
+ const target = Math.max(20, maxChunkLen - 2);
749
+ let i = 0;
750
+ while (i < inner.length) {
751
+ if (inner.length - i <= target) {
752
+ chunks.push(`"${inner.slice(i)}"`);
753
+ break;
754
+ }
755
+ // Prefer a split right after a comma or space within the window. The
756
+ // window is `[i, i + target - 1]` so `safeEnd = j + 1 <= i + target`,
757
+ // keeping the emitted chunk content <= `target` characters long.
758
+ const windowEnd = i + target - 1;
759
+ let safeEnd = -1;
760
+ for (let j = windowEnd; j > i; j--) {
761
+ const ch = inner[j];
762
+ if ((ch === ',' || ch === ' ') && !endsWithOddBackslash(inner, i, j)) {
763
+ safeEnd = j + 1;
764
+ break;
765
+ }
766
+ }
767
+ if (safeEnd === -1) {
768
+ // No comma/space — back up over any trailing backslash pair.
769
+ let end = i + target;
770
+ while (end > i && endsWithOddBackslash(inner, i, end)) end--;
771
+ safeEnd = end;
772
+ }
773
+ chunks.push(`"${inner.slice(i, safeEnd)}"`);
774
+ i = safeEnd;
775
+ }
776
+ return chunks;
777
+ }
778
+
779
+ /** True if the number of trailing `\` chars in `inner[start..pos-1]` is odd. */
780
+ function endsWithOddBackslash(inner: string, start: number, pos: number): boolean {
781
+ let count = 0;
782
+ for (let k = pos - 1; k >= start && inner[k] === '\\'; k--) count++;
783
+ return count % 2 === 1;
784
+ }
785
+
786
+ /**
787
+ * Emit `<prefix>(<args>)` either on a single line or, if that would exceed
788
+ * ktlint's 140-char limit, broken across multiple lines with one argument
789
+ * per line. [indent] is the leading whitespace on the expression line.
790
+ *
791
+ * When splitting a `val name = call.expr(...)` form, the assignment's RHS is
792
+ * moved to its own line (ktlint: "A multiline expression should start on a
793
+ * new line").
794
+ */
795
+ function emitCall(lines: string[], indent: string, prefix: string, args: string): void {
796
+ const single = `${indent}${prefix}(${args})`;
797
+ if (single.length <= KTLINT_MAX_LINE_LENGTH) {
798
+ lines.push(single);
799
+ return;
800
+ }
801
+ // If the prefix is an assignment (`val x = expr.call`), split the assignment
802
+ // so the expression starts on its own line with an extra indent level.
803
+ const assignMatch = /^((?:val|var) [^=]+=)\s*(.+)$/.exec(prefix);
804
+ const exprPrefix = assignMatch ? assignMatch[2] : prefix;
805
+ const exprIndent = assignMatch ? `${indent} ` : indent;
806
+ if (assignMatch) lines.push(`${indent}${assignMatch[1]}`);
807
+ lines.push(`${exprIndent}${exprPrefix}(`);
808
+ const argIndent = `${exprIndent} `;
809
+ const parts = splitTopLevelArgs(args);
810
+ for (let i = 0; i < parts.length; i++) {
811
+ const suffix = i === parts.length - 1 ? '' : ',';
812
+ lines.push(`${argIndent}${parts[i]}${suffix}`);
813
+ }
814
+ lines.push(`${exprIndent})`);
815
+ }
816
+
817
+ const KTLINT_MAX_LINE_LENGTH = 140;
818
+
819
+ /** Split a call-argument string on top-level commas (ignoring nested parens/quotes). */
820
+ function splitTopLevelArgs(args: string): string[] {
821
+ const out: string[] = [];
822
+ let depth = 0;
823
+ let inString = false;
824
+ let buf = '';
825
+ for (let i = 0; i < args.length; i++) {
826
+ const ch = args[i];
827
+ if (inString) {
828
+ buf += ch;
829
+ if (ch === '\\' && i + 1 < args.length) {
830
+ buf += args[++i];
831
+ } else if (ch === '"') {
832
+ inString = false;
833
+ }
834
+ continue;
835
+ }
836
+ if (ch === '"') {
837
+ inString = true;
838
+ buf += ch;
839
+ continue;
840
+ }
841
+ if (ch === '(' || ch === '<' || ch === '[' || ch === '{') depth++;
842
+ else if (ch === ')' || ch === '>' || ch === ']' || ch === '}') depth--;
843
+ if (ch === ',' && depth === 0) {
844
+ out.push(buf.trim());
845
+ buf = '';
846
+ continue;
847
+ }
848
+ buf += ch;
849
+ }
850
+ if (buf.trim()) out.push(buf.trim());
851
+ return out;
852
+ }
853
+
854
+ /**
855
+ * True when a TypeRef is safe for JSON round-trip testing: primitives,
856
+ * nullable wrappers around primitives, literals, and empty arrays/maps.
857
+ * Nested model and enum references are excluded because Jackson
858
+ * reserializes them with additional optional-field defaults that weren't
859
+ * in the original fixture JSON.
860
+ */
861
+ function isRoundTripSafeType(ref: TypeRef): boolean {
862
+ if (ref.kind === 'primitive') return true;
863
+ if (ref.kind === 'literal') return true;
864
+ if (ref.kind === 'nullable') return isRoundTripSafeType(ref.inner);
865
+ if (ref.kind === 'array') return isRoundTripSafeType(ref.items);
866
+ if (ref.kind === 'map') return isRoundTripSafeType(ref.valueType);
867
+ return false;
868
+ }
869
+
870
+ function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile | null {
871
+ // Collect round-trippable models: non-list-wrapper data classes for which
872
+ // we can synthesize a complete JSON fixture (required fields only).
873
+ // Uses synthJsonForModelName which handles primitives, enums, nested
874
+ // models, arrays, maps, and literals — much broader than the old
875
+ // primitives-only filter.
876
+ const targets: { model: Model; json: string }[] = [];
877
+ for (const m of spec.models) {
878
+ if (isListWrapperModel(m) || isListMetadataModel(m)) continue;
879
+ if (m.fields.length === 0) continue;
880
+ // Only include models where ALL fields are required AND all types are
881
+ // round-trip safe (primitives, nullable, literals, simple arrays/maps).
882
+ // Nested model/enum references break round-trip because Jackson
883
+ // reserializes with additional default fields not in the original JSON.
884
+ if (!m.fields.every((f) => f.required && isRoundTripSafeType(f.type))) continue;
885
+ const json = synthJsonForModelName(m.name, ctx, new Set());
886
+ if (json !== null) targets.push({ model: m, json });
887
+ }
888
+ if (targets.length === 0) return null;
889
+
890
+ const lines: string[] = [
891
+ 'package com.workos.models',
892
+ '',
893
+ 'import com.workos.common.json.ObjectMapperFactory',
894
+ 'import org.junit.jupiter.api.Assertions.assertEquals',
895
+ 'import org.junit.jupiter.api.Test',
896
+ '',
897
+ 'class GeneratedModelRoundTripTest {',
898
+ ' private val mapper = ObjectMapperFactory.create()',
899
+ ];
900
+
901
+ for (const { model, json } of targets) {
902
+ const cls = className(model.name);
903
+ lines.push('', ' @Test', ` fun \`${cls} round-trips through Jackson\`() {`);
904
+ emitJsonVal(lines, ' ', json);
905
+ lines.push(
906
+ ` val parsed = mapper.readValue(json, ${cls}::class.java)`,
907
+ ' val reserialized = mapper.writeValueAsString(parsed)',
908
+ ' val tree1 = mapper.readTree(json)',
909
+ ' val tree2 = mapper.readTree(reserialized)',
910
+ ' assertEquals(tree1, tree2)',
911
+ ' }',
912
+ );
913
+ }
914
+
915
+ lines.push('}', '');
916
+
917
+ return {
918
+ path: `${TEST_PREFIX}com/workos/models/GeneratedModelRoundTripTest.kt`,
919
+ content: lines.join('\n'),
920
+ overwriteExisting: true,
921
+ };
922
+ }
923
+
924
+ /**
925
+ * Emit a forward-compatibility suite that proves:
926
+ * - unrecognized enum wire values map to the `Unknown` sentinel rather
927
+ * than throwing (covers the Jackson @JsonEnumDefaultValue wiring);
928
+ * - unknown top-level JSON fields on a model do not fail deserialization
929
+ * (FAIL_ON_UNKNOWN_PROPERTIES=false);
930
+ * - ISO-8601 timestamps round-trip through `OffsetDateTime` without
931
+ * precision loss.
932
+ *
933
+ * Tests a representative set of enums (up to MAX_ENUM_FORWARD_COMPAT) and
934
+ * the first synthesizable model.
935
+ */
936
+ const MAX_ENUM_FORWARD_COMPAT = 15;
937
+
938
+ function generateForwardCompatTest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile | null {
939
+ // Select multiple enums for forward-compat testing, not just the first.
940
+ const enumTargets = spec.enums.filter((e) => e.values.length > 0).slice(0, MAX_ENUM_FORWARD_COMPAT);
941
+ const modelTarget = spec.models.find((m) => {
942
+ if (isListWrapperModel(m) || isListMetadataModel(m)) return false;
943
+ if (m.fields.length === 0) return false;
944
+ return synthJsonForModelName(m.name, ctx, new Set()) !== null;
945
+ });
946
+ if (enumTargets.length === 0 && !modelTarget) return null;
947
+
948
+ const enumImports = new Set<string>();
949
+ for (const e of enumTargets) enumImports.add(`com.workos.types.${className(e.name)}`);
950
+
951
+ const lines: string[] = [
952
+ 'package com.workos.models',
953
+ '',
954
+ 'import com.fasterxml.jackson.core.type.TypeReference',
955
+ 'import com.workos.common.json.ObjectMapperFactory',
956
+ ];
957
+ for (const imp of [...enumImports].sort()) lines.push(`import ${imp}`);
958
+ lines.push(
959
+ 'import org.junit.jupiter.api.Assertions.assertEquals',
960
+ 'import org.junit.jupiter.api.Assertions.assertNotNull',
961
+ 'import org.junit.jupiter.api.Test',
962
+ '',
963
+ 'class GeneratedForwardCompatTest {',
964
+ ' private val mapper = ObjectMapperFactory.create()',
965
+ );
966
+
967
+ for (const enumTarget of enumTargets) {
968
+ const enumCls = className(enumTarget.name);
969
+ lines.push(
970
+ '',
971
+ ` @Test`,
972
+ ` fun \`unknown ${enumCls} wire values deserialize to Unknown\`() {`,
973
+ ' // Simulates a future server release that introduces a new enum variant.',
974
+ ` val parsed = mapper.readValue(${ktStringLiteral('"__oagen_new_variant__"')}, ${enumCls}::class.java)`,
975
+ ` assertEquals(${enumCls}.Unknown, parsed)`,
976
+ ' }',
977
+ );
978
+ }
979
+
980
+ if (modelTarget) {
981
+ const modelCls = className(modelTarget.name);
982
+ const jsonLiteral = synthJsonForModelName(modelTarget.name, ctx, new Set())!;
983
+ const jsonWithExtra = jsonLiteral.replace('{', '{"__oagen_future_field__": "ignored", ');
984
+ lines.push('', ` @Test`, ` fun \`${modelCls} ignores unknown JSON fields\`() {`);
985
+ emitJsonVal(lines, ' ', jsonWithExtra);
986
+ lines.push(` val parsed = mapper.readValue(json, ${modelCls}::class.java)`, ' assertNotNull(parsed)', ' }');
987
+ }
988
+
989
+ lines.push(
990
+ '',
991
+ ' @Test',
992
+ ' fun `OffsetDateTime round-trips through the configured mapper`() {',
993
+ ' val jsonIn = "\\"2024-01-15T12:34:56.789Z\\""',
994
+ ' val parsed = mapper.readValue(jsonIn, object : TypeReference<java.time.OffsetDateTime>() {})',
995
+ ' val reserialized = mapper.writeValueAsString(parsed)',
996
+ ' // Jackson serializes OffsetDateTime as an ISO-8601 string when',
997
+ ' // WRITE_DATES_AS_TIMESTAMPS is disabled. The wire form may choose a',
998
+ ' // different offset representation (e.g. "+00:00" vs "Z") so compare',
999
+ ' // logical equality of the parsed value rather than the raw string.',
1000
+ ' val reparsed = mapper.readValue(reserialized, object : TypeReference<java.time.OffsetDateTime>() {})',
1001
+ ' assertEquals(parsed.toInstant(), reparsed.toInstant())',
1002
+ ' }',
1003
+ '}',
1004
+ '',
1005
+ );
1006
+
1007
+ return {
1008
+ path: `${TEST_PREFIX}com/workos/models/GeneratedForwardCompatTest.kt`,
1009
+ content: lines.join('\n'),
1010
+ overwriteExisting: true,
1011
+ };
1012
+ }
1013
+
1014
+ function findService(ctx: EmitterContext, op: Operation): Service | undefined {
1015
+ for (const service of ctx.spec.services) {
1016
+ if (service.operations.includes(op)) return service;
1017
+ }
1018
+ return undefined;
1019
+ }