@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.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14549 -3385
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- 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 +328 -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 +45 -12
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- 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 +84 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +179 -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 +4 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +128 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +3 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +527 -397
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +69 -19
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +179 -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 +279 -0
- package/src/php/resources.ts +636 -0
- package/src/php/tests.ts +609 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +152 -0
- package/src/python/client.ts +345 -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 +189 -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 +472 -0
- package/src/shared/naming-utils.ts +154 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +70 -0
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -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/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +92 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +315 -84
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +95 -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 +682 -0
- package/test/php/tests.test.ts +185 -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 -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
|
+
}
|