@workos/oagen-emitters 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{plugin-DW3cnedr.mjs → plugin-CmfzawTp.mjs} +2851 -524
- package/dist/plugin-CmfzawTp.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +323 -0
- package/package.json +9 -9
- package/src/index.ts +1 -0
- package/src/plugin.ts +2 -1
- package/src/python/path-expression.ts +75 -26
- package/src/python/resources.ts +0 -9
- package/src/rust/client.ts +62 -0
- package/src/rust/enums.ts +201 -0
- package/src/rust/fixtures.ts +196 -0
- package/src/rust/index.ts +95 -0
- package/src/rust/manifest.ts +31 -0
- package/src/rust/models.ts +165 -0
- package/src/rust/naming.ts +131 -0
- package/src/rust/resources.ts +1324 -0
- package/src/rust/secret.ts +59 -0
- package/src/rust/tests.ts +818 -0
- package/src/rust/type-map.ts +225 -0
- package/test/entrypoint.test.ts +1 -0
- package/test/plugin.test.ts +2 -1
- package/test/python/resources.test.ts +2 -2
- package/test/rust/client.test.ts +62 -0
- package/test/rust/enums.test.ts +117 -0
- package/test/rust/fixtures.test.ts +227 -0
- package/test/rust/manifest.test.ts +73 -0
- package/test/rust/models.test.ts +177 -0
- package/test/rust/resources.test.ts +748 -0
- package/test/rust/tests.test.ts +504 -0
- package/test/rust/type-map.test.ts +83 -0
- package/dist/plugin-DW3cnedr.mjs.map +0 -1
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiSpec,
|
|
3
|
+
EmitterContext,
|
|
4
|
+
Enum,
|
|
5
|
+
GeneratedFile,
|
|
6
|
+
Model,
|
|
7
|
+
Operation,
|
|
8
|
+
Parameter,
|
|
9
|
+
ResolvedOperation,
|
|
10
|
+
ResolvedWrapper,
|
|
11
|
+
TypeRef,
|
|
12
|
+
} from '@workos/oagen';
|
|
13
|
+
import { methodName, moduleName, typeName } from './naming.js';
|
|
14
|
+
import { groupByMount } from '../shared/resolved-ops.js';
|
|
15
|
+
import { exampleFor, generateFixtures } from './fixtures.js';
|
|
16
|
+
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate integration tests under `tests/`. Each mount group gets one
|
|
20
|
+
* `tests/{mount}_test.rs` file. Per operation the generator emits:
|
|
21
|
+
*
|
|
22
|
+
* - `_round_trip`: happy-path 200 mock + call.
|
|
23
|
+
* - `_unauthorized`, `_not_found`, `_rate_limited`, `_server_error`: error
|
|
24
|
+
* paths for every HTTP-calling op.
|
|
25
|
+
* - `_bad_request`, `_unprocessable`: additional 4xx error paths for write
|
|
26
|
+
* ops (POST/PUT/PATCH/DELETE).
|
|
27
|
+
* - `_empty_page`: empty `data: []` response for paginated ops.
|
|
28
|
+
* - `_encodes_query_params`: outbound query-string assertion for ops with
|
|
29
|
+
* array query params (`explode: true` repeated keys, `explode: false`
|
|
30
|
+
* comma-joined).
|
|
31
|
+
*
|
|
32
|
+
* URL-builder ops (no HTTP call) only get the round-trip test.
|
|
33
|
+
*/
|
|
34
|
+
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
35
|
+
const files: GeneratedFile[] = [];
|
|
36
|
+
|
|
37
|
+
files.push(...generateFixtures(spec));
|
|
38
|
+
|
|
39
|
+
files.push({
|
|
40
|
+
path: 'tests/common/mod.rs',
|
|
41
|
+
content: renderCommon(ctx),
|
|
42
|
+
overwriteExisting: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const groups = groupByMount(ctx);
|
|
46
|
+
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
47
|
+
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
48
|
+
|
|
49
|
+
for (const [mountName, group] of groups) {
|
|
50
|
+
if (group.operations.length === 0) continue;
|
|
51
|
+
files.push({
|
|
52
|
+
path: `tests/${moduleName(mountName)}_test.rs`,
|
|
53
|
+
content: renderMountTest(mountName, group.resolvedOps, ctx, modelMap, enumMap),
|
|
54
|
+
overwriteExisting: true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return files;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderCommon(ctx: EmitterContext): string {
|
|
62
|
+
const crate = crateName(ctx);
|
|
63
|
+
const imports = [
|
|
64
|
+
{ path: 'wiremock::MockServer', sort: 'wiremock::MockServer' },
|
|
65
|
+
{ path: `${crate}::Client`, sort: `${crate}::Client` },
|
|
66
|
+
].sort((a, b) => a.sort.localeCompare(b.sort));
|
|
67
|
+
|
|
68
|
+
const useLines = imports.map((i) => `use ${i.path};`).join('\n');
|
|
69
|
+
|
|
70
|
+
return `#![allow(dead_code)]
|
|
71
|
+
|
|
72
|
+
${useLines}
|
|
73
|
+
|
|
74
|
+
pub async fn test_client(server: &MockServer) -> Client {
|
|
75
|
+
Client::builder()
|
|
76
|
+
.api_key("test_api_key")
|
|
77
|
+
.base_url(server.uri())
|
|
78
|
+
.max_retries(0)
|
|
79
|
+
.build()
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderMountTest(
|
|
85
|
+
mountName: string,
|
|
86
|
+
resolvedOps: ResolvedOperation[],
|
|
87
|
+
ctx: EmitterContext,
|
|
88
|
+
modelMap: Map<string, Model>,
|
|
89
|
+
enumMap: Map<string, Enum>,
|
|
90
|
+
): string {
|
|
91
|
+
const accessor = moduleName(mountName);
|
|
92
|
+
const crate = crateName(ctx);
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
lines.push('mod common;');
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push('use wiremock::matchers::{method, path as path_matcher};');
|
|
97
|
+
lines.push('use wiremock::{Mock, MockServer, ResponseTemplate};');
|
|
98
|
+
lines.push(`use ${crate}::Error;`);
|
|
99
|
+
lines.push('');
|
|
100
|
+
|
|
101
|
+
const seen = new Set<string>();
|
|
102
|
+
|
|
103
|
+
for (const r of resolvedOps) {
|
|
104
|
+
const op = r.operation;
|
|
105
|
+
if ((r.wrappers?.length ?? 0) > 0) {
|
|
106
|
+
for (const w of r.wrappers!) {
|
|
107
|
+
const m = methodName(w.name);
|
|
108
|
+
if (seen.has(m)) continue;
|
|
109
|
+
seen.add(m);
|
|
110
|
+
lines.push(...renderWrapperTest(op, w, ctx, accessor, crate, modelMap, enumMap));
|
|
111
|
+
lines.push('');
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const m = methodName(r.methodName);
|
|
116
|
+
if (seen.has(m)) continue;
|
|
117
|
+
seen.add(m);
|
|
118
|
+
lines.push(...renderRegularTest(op, r, accessor, crate, modelMap, enumMap));
|
|
119
|
+
lines.push('');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Trim a trailing blank line.
|
|
123
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
124
|
+
|
|
125
|
+
return lines.join('\n') + '\n';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Bundle of inputs computed once per operation and reused across the various
|
|
130
|
+
* test categories (round-trip, error tests, encoding tests). All test
|
|
131
|
+
* categories share the same params construction, path, method, and accessor
|
|
132
|
+
* call shape — the only thing that varies is the response template and the
|
|
133
|
+
* assertion.
|
|
134
|
+
*/
|
|
135
|
+
interface CallShape {
|
|
136
|
+
/** Operation method name, e.g. `list_events`. */
|
|
137
|
+
methodIdent: string;
|
|
138
|
+
/** Literal path with `{id}` placeholders substituted to `test_id`. */
|
|
139
|
+
literalPath: string;
|
|
140
|
+
/** Upper-case HTTP verb, e.g. `GET`. */
|
|
141
|
+
httpMethod: string;
|
|
142
|
+
/** The args passed to the SDK method call, joined with `, `. */
|
|
143
|
+
callArgs: string;
|
|
144
|
+
/** True when the op is a URL-builder (no HTTP call). */
|
|
145
|
+
isUrlBuilder: boolean;
|
|
146
|
+
/** True for HTTP methods that mutate state. */
|
|
147
|
+
isWrite: boolean;
|
|
148
|
+
/** True when the op declares cursor pagination. */
|
|
149
|
+
isPaginated: boolean;
|
|
150
|
+
/** When non-null, the op has a synthetic body group enum. */
|
|
151
|
+
hasBodyGroup: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Test for a non-wrapper operation. */
|
|
155
|
+
function renderRegularTest(
|
|
156
|
+
op: Operation,
|
|
157
|
+
resolved: ResolvedOperation,
|
|
158
|
+
accessor: string,
|
|
159
|
+
crate: string,
|
|
160
|
+
modelMap: Map<string, Model>,
|
|
161
|
+
enumMap: Map<string, Enum>,
|
|
162
|
+
): string[] {
|
|
163
|
+
const m = methodName(resolved.methodName);
|
|
164
|
+
const literalPath = op.path.replace(/\{[^}]+\}/g, 'test_id');
|
|
165
|
+
const httpMethod = op.httpMethod.toUpperCase();
|
|
166
|
+
const responseExpr = responseBodyExpr(op.response, modelMap, enumMap);
|
|
167
|
+
const isUrlBuilder = resolved.urlBuilder === true;
|
|
168
|
+
|
|
169
|
+
const callArgs = buildCallArgs(op, resolved, crate, accessor, modelMap, enumMap).join(', ');
|
|
170
|
+
const shape: CallShape = {
|
|
171
|
+
methodIdent: m,
|
|
172
|
+
literalPath,
|
|
173
|
+
httpMethod,
|
|
174
|
+
callArgs,
|
|
175
|
+
isUrlBuilder,
|
|
176
|
+
isWrite: isWriteMethod(op.httpMethod),
|
|
177
|
+
isPaginated: !!op.pagination,
|
|
178
|
+
hasBodyGroup: !!op.requestBody && hasBodyParameterGroup(op),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const lines: string[] = [];
|
|
182
|
+
|
|
183
|
+
// Round-trip (happy path).
|
|
184
|
+
lines.push('#[tokio::test]');
|
|
185
|
+
lines.push(`async fn ${accessor}_${m}_round_trip() {`);
|
|
186
|
+
if (isUrlBuilder) {
|
|
187
|
+
// URL-builder ops don't issue HTTP requests; there's nothing to mock.
|
|
188
|
+
lines.push(' let server = MockServer::start().await;');
|
|
189
|
+
lines.push(' let client = common::test_client(&server).await;');
|
|
190
|
+
lines.push(` let _ = client.${accessor}().${m}(${callArgs});`);
|
|
191
|
+
} else {
|
|
192
|
+
lines.push(' let server = MockServer::start().await;');
|
|
193
|
+
lines.push(` Mock::given(method(${JSON.stringify(httpMethod)}))`);
|
|
194
|
+
lines.push(` .and(path_matcher(${JSON.stringify(literalPath)}))`);
|
|
195
|
+
lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${responseExpr}))`);
|
|
196
|
+
lines.push(' .expect(1)');
|
|
197
|
+
lines.push(' .mount(&server)');
|
|
198
|
+
lines.push(' .await;');
|
|
199
|
+
lines.push(' let client = common::test_client(&server).await;');
|
|
200
|
+
lines.push(` let _ = client.${accessor}().${m}(${callArgs}).await;`);
|
|
201
|
+
}
|
|
202
|
+
// wiremock asserts on drop that the `.expect(1)` mock was matched once;
|
|
203
|
+
// mismatched method/path produces a panic at end-of-test. We deliberately
|
|
204
|
+
// ignore the deserialised response: stub fixtures cover the common path
|
|
205
|
+
// (typed model deserialisation), but discriminated-union responses can't
|
|
206
|
+
// always be reproduced from a generated fixture without bespoke schema
|
|
207
|
+
// awareness, so a strict `is_ok()` would over-trigger.
|
|
208
|
+
lines.push('}');
|
|
209
|
+
|
|
210
|
+
if (isUrlBuilder) {
|
|
211
|
+
// No HTTP call to mock — the round-trip is all we can sensibly emit.
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Error-path tests — same path/method, different response status.
|
|
216
|
+
for (const errTest of standardErrorTests(shape, accessor)) {
|
|
217
|
+
lines.push('');
|
|
218
|
+
lines.push(...errTest);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (shape.isWrite) {
|
|
222
|
+
for (const errTest of writeErrorTests(shape, accessor)) {
|
|
223
|
+
lines.push('');
|
|
224
|
+
lines.push(...errTest);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Empty-page test for paginated list ops.
|
|
229
|
+
if (shape.isPaginated) {
|
|
230
|
+
const emptyTest = emptyPageTest(op, shape, accessor);
|
|
231
|
+
if (emptyTest) {
|
|
232
|
+
lines.push('');
|
|
233
|
+
lines.push(...emptyTest);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Query-string encoding assertion when an array query param is present.
|
|
238
|
+
const encodingTest = encodesQueryParamsTest(op, resolved, accessor, crate, modelMap, enumMap);
|
|
239
|
+
if (encodingTest) {
|
|
240
|
+
lines.push('');
|
|
241
|
+
lines.push(...encodingTest);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return lines;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build the positional args for the resource method call: path-param literals,
|
|
249
|
+
* then a single params struct (when the op has any), then a token-string for
|
|
250
|
+
* non-bearer security overrides. This factors the constructor logic out of
|
|
251
|
+
* the round-trip renderer so the error/encoding tests can reuse it verbatim
|
|
252
|
+
* — every test category sends the same request, only the mocked response and
|
|
253
|
+
* assertion differ.
|
|
254
|
+
*/
|
|
255
|
+
function buildCallArgs(
|
|
256
|
+
op: Operation,
|
|
257
|
+
resolved: ResolvedOperation,
|
|
258
|
+
crate: string,
|
|
259
|
+
accessor: string,
|
|
260
|
+
modelMap: Map<string, Model>,
|
|
261
|
+
enumMap: Map<string, Enum>,
|
|
262
|
+
): string[] {
|
|
263
|
+
const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
|
|
264
|
+
// Names of query/header params that fold into a parameter-group enum and
|
|
265
|
+
// therefore must not be passed individually to the params constructor.
|
|
266
|
+
const groupedNames = new Set<string>();
|
|
267
|
+
for (const g of op.parameterGroups ?? []) {
|
|
268
|
+
for (const v of g.variants) for (const p of v.parameters) groupedNames.add(p.name);
|
|
269
|
+
}
|
|
270
|
+
const visibleQuery = op.queryParams.filter((p) => !hidden.has(p.name) && !groupedNames.has(p.name));
|
|
271
|
+
const visibleHeader = op.headerParams.filter((p) => !hidden.has(p.name));
|
|
272
|
+
const visibleParams = [...visibleQuery, ...visibleHeader];
|
|
273
|
+
const requiredParams = visibleParams.filter((p) => p.required);
|
|
274
|
+
const hasBody = op.requestBody !== undefined;
|
|
275
|
+
const bodyRequired = hasBody && op.requestBody!.kind !== 'nullable';
|
|
276
|
+
const queryNames = new Set(op.queryParams.map((p) => p.name));
|
|
277
|
+
const requiredGroups = (op.parameterGroups ?? []).filter((g) => !g.optional);
|
|
278
|
+
const requiredQueryGroups = requiredGroups.filter((g) =>
|
|
279
|
+
g.variants.every((v) => v.parameters.every((p) => queryNames.has(p.name))),
|
|
280
|
+
);
|
|
281
|
+
const emptyParams = !hasBody && visibleParams.length === 0 && (op.parameterGroups?.length ?? 0) === 0;
|
|
282
|
+
|
|
283
|
+
const callArgs: string[] = [];
|
|
284
|
+
for (const _ of op.pathParams) callArgs.push('"test_id"');
|
|
285
|
+
|
|
286
|
+
if (!emptyParams) {
|
|
287
|
+
const paramsType = `${crate}::${accessor}::${typeName(resolved.methodName)}Params`;
|
|
288
|
+
const noCtorRequired = requiredParams.length === 0 && !bodyRequired && requiredQueryGroups.length === 0;
|
|
289
|
+
if (noCtorRequired) {
|
|
290
|
+
callArgs.push(`${paramsType}::default()`);
|
|
291
|
+
} else {
|
|
292
|
+
const ctorArgs: string[] = [];
|
|
293
|
+
for (const p of requiredParams) {
|
|
294
|
+
ctorArgs.push(stubExpr(p.type, p.name, modelMap, enumMap));
|
|
295
|
+
}
|
|
296
|
+
for (const g of requiredQueryGroups) {
|
|
297
|
+
ctorArgs.push(parameterGroupStubExpr(g, crate, accessor));
|
|
298
|
+
}
|
|
299
|
+
if (hasBody && bodyRequired) {
|
|
300
|
+
const hasBodyGroup = hasBodyParameterGroup(op);
|
|
301
|
+
if (hasBodyGroup) {
|
|
302
|
+
ctorArgs.push(syntheticBodyStubExpr(op, resolved, crate, accessor, modelMap));
|
|
303
|
+
} else {
|
|
304
|
+
ctorArgs.push(stubExpr(op.requestBody!, 'body', modelMap, enumMap));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
callArgs.push(`${paramsType}::new(${ctorArgs.join(', ')})`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Per-operation bearer override (e.g. `GET /sso/profile`) — append the
|
|
312
|
+
// access-token positional arg before the awaiter.
|
|
313
|
+
const tokenName = bearerOverrideTokenName(op);
|
|
314
|
+
if (tokenName) {
|
|
315
|
+
callArgs.push(`"stub_${tokenName}".to_string()`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return callArgs;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Build the four common-error test bodies (401/404/429/500). Each one mocks a
|
|
323
|
+
* single response with the given status, calls the SDK method (which should
|
|
324
|
+
* fail), and asserts on the unwrapped `Error::Api` payload.
|
|
325
|
+
*/
|
|
326
|
+
function standardErrorTests(shape: CallShape, accessor: string): string[][] {
|
|
327
|
+
const tests: string[][] = [];
|
|
328
|
+
tests.push(errorTestBody(shape, accessor, 'unauthorized', 401, '{"message":"Unauthorized"}', undefined));
|
|
329
|
+
tests.push(errorTestBody(shape, accessor, 'not_found', 404, '{"message":"Not found"}', undefined));
|
|
330
|
+
tests.push(
|
|
331
|
+
errorTestBody(shape, accessor, 'rate_limited', 429, '{"message":"Slow down"}', {
|
|
332
|
+
retryAfterSeconds: 1,
|
|
333
|
+
assertRetryAfter: true,
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
tests.push(errorTestBody(shape, accessor, 'server_error', 500, '{"message":"Internal error"}', undefined));
|
|
337
|
+
return tests;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Build the two write-op-only error tests (400/422). */
|
|
341
|
+
function writeErrorTests(shape: CallShape, accessor: string): string[][] {
|
|
342
|
+
return [
|
|
343
|
+
errorTestBody(shape, accessor, 'bad_request', 400, '{"code":"validation_error","message":"Bad request"}', {
|
|
344
|
+
assertCode: 'validation_error',
|
|
345
|
+
}),
|
|
346
|
+
errorTestBody(shape, accessor, 'unprocessable', 422, '{"message":"Unprocessable"}', undefined),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface ErrorOptions {
|
|
351
|
+
retryAfterSeconds?: number;
|
|
352
|
+
assertRetryAfter?: boolean;
|
|
353
|
+
assertCode?: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function errorTestBody(
|
|
357
|
+
shape: CallShape,
|
|
358
|
+
accessor: string,
|
|
359
|
+
category: string,
|
|
360
|
+
status: number,
|
|
361
|
+
body: string,
|
|
362
|
+
opts: ErrorOptions | undefined,
|
|
363
|
+
): string[] {
|
|
364
|
+
const lines: string[] = [];
|
|
365
|
+
lines.push('#[tokio::test]');
|
|
366
|
+
lines.push(`async fn ${accessor}_${shape.methodIdent}_${category}() {`);
|
|
367
|
+
lines.push(' let server = MockServer::start().await;');
|
|
368
|
+
lines.push(` let template = ResponseTemplate::new(${status})`);
|
|
369
|
+
if (opts?.retryAfterSeconds !== undefined) {
|
|
370
|
+
lines.push(` .insert_header("retry-after", ${JSON.stringify(String(opts.retryAfterSeconds))})`);
|
|
371
|
+
}
|
|
372
|
+
lines.push(` .set_body_string(${JSON.stringify(body)});`);
|
|
373
|
+
lines.push(` Mock::given(method(${JSON.stringify(shape.httpMethod)}))`);
|
|
374
|
+
lines.push(` .and(path_matcher(${JSON.stringify(shape.literalPath)}))`);
|
|
375
|
+
lines.push(' .respond_with(template)');
|
|
376
|
+
lines.push(' .expect(1)');
|
|
377
|
+
lines.push(' .mount(&server)');
|
|
378
|
+
lines.push(' .await;');
|
|
379
|
+
lines.push(' let client = common::test_client(&server).await;');
|
|
380
|
+
lines.push(
|
|
381
|
+
` let err = client.${accessor}().${shape.methodIdent}(${shape.callArgs}).await.expect_err("expected error");`,
|
|
382
|
+
);
|
|
383
|
+
lines.push(' let api = match &err {');
|
|
384
|
+
lines.push(' Error::Api(api) => api.as_ref(),');
|
|
385
|
+
lines.push(' other => panic!("expected Error::Api, got {other:?}"),');
|
|
386
|
+
lines.push(' };');
|
|
387
|
+
lines.push(` assert_eq!(api.status, ${status});`);
|
|
388
|
+
if (opts?.assertRetryAfter) {
|
|
389
|
+
lines.push(
|
|
390
|
+
` assert_eq!(api.retry_after, Some(std::time::Duration::from_secs(${opts.retryAfterSeconds ?? 0})));`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (opts?.assertCode) {
|
|
394
|
+
lines.push(` assert_eq!(api.code.as_deref(), Some(${JSON.stringify(opts.assertCode)}));`);
|
|
395
|
+
}
|
|
396
|
+
lines.push('}');
|
|
397
|
+
return lines;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Return an `_empty_page` test for a paginated list op. Two shapes are
|
|
402
|
+
* supported:
|
|
403
|
+
*
|
|
404
|
+
* - Wrapper model: `{"data": [], "list_metadata": {...}}`, accessed via
|
|
405
|
+
* `resp.data` on the returned struct.
|
|
406
|
+
* - Bare array: `[]`, accessed via `resp.is_empty()` directly (the SDK
|
|
407
|
+
* returns `Vec<T>` for paginated ops without a wrapper model).
|
|
408
|
+
*
|
|
409
|
+
* Returns null when the response shape isn't recognised (e.g. a primitive
|
|
410
|
+
* or unknown shape we can't safely assert against).
|
|
411
|
+
*/
|
|
412
|
+
function emptyPageTest(op: Operation, shape: CallShape, accessor: string): string[] | null {
|
|
413
|
+
const responseKind = op.response.kind;
|
|
414
|
+
let body: string;
|
|
415
|
+
let dataAccessor: string;
|
|
416
|
+
if (responseKind === 'array') {
|
|
417
|
+
// Bare-array paginated response: SDK returns Vec<T>.
|
|
418
|
+
body = '[]';
|
|
419
|
+
dataAccessor = 'resp';
|
|
420
|
+
} else if (responseKind === 'model') {
|
|
421
|
+
// Wrapper-model paginated response: SDK returns the wrapper struct.
|
|
422
|
+
// `list_metadata` is always an object — its required field set depends
|
|
423
|
+
// on the response model, but for the empty case both `before` and `after`
|
|
424
|
+
// are either present-as-null or absent. We emit both keys as null to
|
|
425
|
+
// satisfy any shape with optional cursor fields without hard-coding
|
|
426
|
+
// per-op knowledge.
|
|
427
|
+
// The wrapper model has a required `object` discriminator field, e.g.
|
|
428
|
+
// `"list"`. Include it so the response deserialises against any
|
|
429
|
+
// generated wrapper struct.
|
|
430
|
+
body = '{"object":"list","data":[],"list_metadata":{"before":null,"after":null}}';
|
|
431
|
+
dataAccessor = 'resp.data';
|
|
432
|
+
} else {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
const lines: string[] = [];
|
|
436
|
+
lines.push('#[tokio::test]');
|
|
437
|
+
lines.push(`async fn ${accessor}_${shape.methodIdent}_empty_page() {`);
|
|
438
|
+
lines.push(' let server = MockServer::start().await;');
|
|
439
|
+
lines.push(` Mock::given(method(${JSON.stringify(shape.httpMethod)}))`);
|
|
440
|
+
lines.push(` .and(path_matcher(${JSON.stringify(shape.literalPath)}))`);
|
|
441
|
+
lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${JSON.stringify(body)}))`);
|
|
442
|
+
lines.push(' .expect(1)');
|
|
443
|
+
lines.push(' .mount(&server)');
|
|
444
|
+
lines.push(' .await;');
|
|
445
|
+
lines.push(' let client = common::test_client(&server).await;');
|
|
446
|
+
lines.push(
|
|
447
|
+
` let resp = client.${accessor}().${shape.methodIdent}(${shape.callArgs}).await.expect("expected success");`,
|
|
448
|
+
);
|
|
449
|
+
lines.push(` assert!(${dataAccessor}.is_empty(), "expected empty data array");`);
|
|
450
|
+
lines.push('}');
|
|
451
|
+
return lines;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build an `_encodes_query_params` test when the op declares at least one
|
|
456
|
+
* array query param. Constructs a params struct with a known Vec value on
|
|
457
|
+
* each array field and asserts the actual outbound query string contains
|
|
458
|
+
* either repeated keys (`events=foo&events=bar`, default for `explode: true`)
|
|
459
|
+
* or a comma-joined value (`events=foo%2Cbar`, when `explode: false`).
|
|
460
|
+
*
|
|
461
|
+
* Returns null when no array query params apply — those ops have nothing
|
|
462
|
+
* interesting to encode.
|
|
463
|
+
*/
|
|
464
|
+
function encodesQueryParamsTest(
|
|
465
|
+
op: Operation,
|
|
466
|
+
resolved: ResolvedOperation,
|
|
467
|
+
accessor: string,
|
|
468
|
+
crate: string,
|
|
469
|
+
modelMap: Map<string, Model>,
|
|
470
|
+
enumMap: Map<string, Enum>,
|
|
471
|
+
): string[] | null {
|
|
472
|
+
const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
|
|
473
|
+
const groupedNames = new Set<string>();
|
|
474
|
+
for (const g of op.parameterGroups ?? []) {
|
|
475
|
+
for (const v of g.variants) for (const p of v.parameters) groupedNames.add(p.name);
|
|
476
|
+
}
|
|
477
|
+
// Find at most one Vec<String> query param to drive the assertion. The
|
|
478
|
+
// order is stable (it follows `op.queryParams`), and one is enough — the
|
|
479
|
+
// encoder applies the same rule to every array field. We restrict the
|
|
480
|
+
// assertion to string-element arrays because non-string arrays (e.g.
|
|
481
|
+
// enums) require per-type constructors we can't reliably synthesise from
|
|
482
|
+
// the IR alone, and serializing a Vec<EnumX> via vec!["foo".into(), ..]
|
|
483
|
+
// wouldn't type-check.
|
|
484
|
+
const arrayParams = op.queryParams.filter(
|
|
485
|
+
(p) => !hidden.has(p.name) && !groupedNames.has(p.name) && isStringArrayParam(p),
|
|
486
|
+
);
|
|
487
|
+
if (arrayParams.length === 0) return null;
|
|
488
|
+
const target = arrayParams[0];
|
|
489
|
+
|
|
490
|
+
// The encoded form for both variants. The test inspects the literal query
|
|
491
|
+
// string returned by wiremock; `assert!(query.contains(...))` matches the
|
|
492
|
+
// serialized output regardless of where in the string the param sits, so
|
|
493
|
+
// we don't need to predict the rest of the params' order.
|
|
494
|
+
const exploded = (target as { explode?: boolean }).explode !== false;
|
|
495
|
+
const expectedFragment = exploded ? `${target.name}=foo&${target.name}=bar` : `${target.name}=foo%2Cbar`;
|
|
496
|
+
|
|
497
|
+
// For the call, we want to set `target` to ["foo", "bar"] on the params
|
|
498
|
+
// struct. Build the standard call args, then mutate the params struct to
|
|
499
|
+
// override the field.
|
|
500
|
+
const callArgs = buildCallArgs(op, resolved, crate, accessor, modelMap, enumMap);
|
|
501
|
+
// Locate which entry in callArgs is the params struct so we can mutate it
|
|
502
|
+
// post-construction. The buildCallArgs result is:
|
|
503
|
+
// path_arg_1, path_arg_2, ..., paramsExpr, [token]
|
|
504
|
+
// Path args are always `"test_id"` literals; the params expr is the first
|
|
505
|
+
// entry past the path arg count.
|
|
506
|
+
const pathArgCount = op.pathParams.length;
|
|
507
|
+
const tokenName = bearerOverrideTokenName(op);
|
|
508
|
+
const expectsParams = callArgs.length > pathArgCount && (tokenName ? callArgs.length > pathArgCount + 1 : true);
|
|
509
|
+
if (!expectsParams) return null;
|
|
510
|
+
|
|
511
|
+
// Build the test body. We materialise the params as a mutable local so we
|
|
512
|
+
// can set the target array field, then pass it (by value) to the SDK call.
|
|
513
|
+
const tokenArg = tokenName ? callArgs[callArgs.length - 1] : null;
|
|
514
|
+
const paramsExpr = callArgs[pathArgCount];
|
|
515
|
+
|
|
516
|
+
const lines: string[] = [];
|
|
517
|
+
const respExpr = encodingResponseExpr(op, modelMap, enumMap);
|
|
518
|
+
lines.push('#[tokio::test]');
|
|
519
|
+
lines.push(`async fn ${accessor}_${methodName(resolved.methodName)}_encodes_query_params() {`);
|
|
520
|
+
lines.push(' let server = MockServer::start().await;');
|
|
521
|
+
lines.push(` Mock::given(method(${JSON.stringify('GET'.replace('GET', op.httpMethod.toUpperCase()))}))`);
|
|
522
|
+
lines.push(` .and(path_matcher(${JSON.stringify(op.path.replace(/\{[^}]+\}/g, 'test_id'))}))`);
|
|
523
|
+
lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${respExpr}))`);
|
|
524
|
+
lines.push(' .mount(&server)');
|
|
525
|
+
lines.push(' .await;');
|
|
526
|
+
lines.push(' let client = common::test_client(&server).await;');
|
|
527
|
+
// Clippy's `field_reassign_with_default` fires when fields are mutated on a
|
|
528
|
+
// value created via `T::default()`. Use struct-update syntax in that case;
|
|
529
|
+
// otherwise (`Type::new(...)` etc.), fall back to the mutable-binding form
|
|
530
|
+
// since the lint doesn't apply.
|
|
531
|
+
if (paramsExpr.endsWith('::default()')) {
|
|
532
|
+
const ty = paramsExpr.slice(0, -'::default()'.length);
|
|
533
|
+
lines.push(` let params = ${ty} {`);
|
|
534
|
+
lines.push(` ${fieldIdent(target.name)}: Some(vec!["foo".to_string(), "bar".to_string()]),`);
|
|
535
|
+
lines.push(' ..Default::default()');
|
|
536
|
+
lines.push(' };');
|
|
537
|
+
} else {
|
|
538
|
+
lines.push(` let mut params = ${paramsExpr};`);
|
|
539
|
+
lines.push(` params.${fieldIdent(target.name)} = Some(vec!["foo".to_string(), "bar".to_string()]);`);
|
|
540
|
+
}
|
|
541
|
+
// Drop the array param onto the params; ignore any required-cursor fields
|
|
542
|
+
// — they're already populated by buildCallArgs.
|
|
543
|
+
const passArgs: string[] = [];
|
|
544
|
+
for (let i = 0; i < pathArgCount; i++) passArgs.push(callArgs[i]);
|
|
545
|
+
passArgs.push('params');
|
|
546
|
+
if (tokenArg) passArgs.push(tokenArg);
|
|
547
|
+
lines.push(` let _ = client.${accessor}().${methodName(resolved.methodName)}(${passArgs.join(', ')}).await;`);
|
|
548
|
+
lines.push(' let received = server.received_requests().await.expect("recorded requests");');
|
|
549
|
+
lines.push(' let request = received.first().expect("at least one request");');
|
|
550
|
+
lines.push(' let query = request.url.query().unwrap_or("");');
|
|
551
|
+
lines.push(
|
|
552
|
+
` assert!(query.contains(${JSON.stringify(expectedFragment)}), "expected query to contain {:?}, got {:?}", ${JSON.stringify(expectedFragment)}, query);`,
|
|
553
|
+
);
|
|
554
|
+
lines.push('}');
|
|
555
|
+
return lines;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Body expression for the encoding-test response (success, ignored). */
|
|
559
|
+
function encodingResponseExpr(op: Operation, modelMap: Map<string, Model>, enumMap: Map<string, Enum>): string {
|
|
560
|
+
// For paginated ops we serve an empty page so the call succeeds. Use the
|
|
561
|
+
// bare-array shape for `Vec<T>` responses, the wrapper shape otherwise.
|
|
562
|
+
if (op.pagination) {
|
|
563
|
+
if (op.response.kind === 'array') return JSON.stringify('[]');
|
|
564
|
+
return JSON.stringify('{"object":"list","data":[],"list_metadata":{"before":null,"after":null}}');
|
|
565
|
+
}
|
|
566
|
+
return responseBodyExpr(op.response, modelMap, enumMap);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* True if `param.type` is `Vec<String>` (or `Option<Vec<String>>`). Restricts
|
|
571
|
+
* the encoding test to string arrays because non-string arrays would need
|
|
572
|
+
* per-type constructors we can't synthesise reliably.
|
|
573
|
+
*/
|
|
574
|
+
function isStringArrayParam(p: Parameter): boolean {
|
|
575
|
+
let t: TypeRef = p.type;
|
|
576
|
+
while (t.kind === 'nullable') t = t.inner;
|
|
577
|
+
if (t.kind !== 'array') return false;
|
|
578
|
+
let inner: TypeRef = t.items;
|
|
579
|
+
while (inner.kind === 'nullable') inner = inner.inner;
|
|
580
|
+
return inner.kind === 'primitive' && inner.type === 'string';
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Snake-case field accessor matching the resources emitter's naming. */
|
|
584
|
+
function fieldIdent(name: string): string {
|
|
585
|
+
// The Rust emitter snake-cases field names via `methodName`. Reuse it so
|
|
586
|
+
// the generated field name matches the params struct.
|
|
587
|
+
return methodName(name);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** True for HTTP methods that mutate state and should retry-defensively. */
|
|
591
|
+
function isWriteMethod(method: string): boolean {
|
|
592
|
+
return method !== 'get' && method !== 'head';
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** True when the op has at least one body-side parameter group. */
|
|
596
|
+
function hasBodyParameterGroup(op: Operation): boolean {
|
|
597
|
+
const queryNames = new Set(op.queryParams.map((p) => p.name));
|
|
598
|
+
return (op.parameterGroups ?? []).some((g) =>
|
|
599
|
+
g.variants.every((v) => v.parameters.every((p) => !queryNames.has(p.name))),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Pick the snake_case token-arg name for an op with a non-bearer security override. */
|
|
604
|
+
function bearerOverrideTokenName(op: Operation): string | null {
|
|
605
|
+
const override = op.security?.find((s) => s.schemeName !== 'bearerAuth');
|
|
606
|
+
if (!override) return null;
|
|
607
|
+
return override.schemeName.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`).replace(/^_/, '');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Construct a synthetic body type via its `new(...)` constructor. The
|
|
612
|
+
* resources emitter passes required flat fields and required flatten enums
|
|
613
|
+
* positionally; mirror that ordering here so the stub compiles against the
|
|
614
|
+
* generated `impl <Type> { fn new(...) }`.
|
|
615
|
+
*/
|
|
616
|
+
function syntheticBodyStubExpr(
|
|
617
|
+
op: Operation,
|
|
618
|
+
resolved: ResolvedOperation,
|
|
619
|
+
crate: string,
|
|
620
|
+
accessor: string,
|
|
621
|
+
modelMap: Map<string, Model>,
|
|
622
|
+
): string {
|
|
623
|
+
const bodyRef = op.requestBody!;
|
|
624
|
+
const bodyName = `${typeName(resolved.methodName)}ParamsBody`;
|
|
625
|
+
const fqn = `${crate}::${accessor}::${bodyName}`;
|
|
626
|
+
const model = bodyRef.kind === 'model' ? (modelMap.get(bodyRef.name) ?? null) : null;
|
|
627
|
+
const queryNames = new Set(op.queryParams.map((p) => p.name));
|
|
628
|
+
const bodyGroupNames = new Set<string>();
|
|
629
|
+
for (const g of op.parameterGroups ?? []) {
|
|
630
|
+
if (g.variants.every((v) => v.parameters.every((p) => !queryNames.has(p.name)))) {
|
|
631
|
+
for (const v of g.variants) for (const p of v.parameters) bodyGroupNames.add(p.name);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Iteration order must match resources.ts: required flat model fields, then
|
|
635
|
+
// each parameter-group field (in the order returned by `op.parameterGroups`).
|
|
636
|
+
const args: string[] = [];
|
|
637
|
+
if (model) {
|
|
638
|
+
for (const f of model.fields) {
|
|
639
|
+
if (bodyGroupNames.has(f.name)) continue;
|
|
640
|
+
const isRequired = !!f.required && f.type.kind !== 'nullable';
|
|
641
|
+
if (!isRequired) continue;
|
|
642
|
+
args.push(`${JSON.stringify(`stub_${f.name}`)}.to_string()`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
for (const g of op.parameterGroups ?? []) {
|
|
646
|
+
const isBodyGroup = g.variants.every((v) => v.parameters.every((p) => !queryNames.has(p.name)));
|
|
647
|
+
if (!isBodyGroup) continue;
|
|
648
|
+
if (g.optional) continue;
|
|
649
|
+
args.push(parameterGroupStubExpr(g, crate, accessor));
|
|
650
|
+
}
|
|
651
|
+
return `${fqn}::new(${args.join(', ')})`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** First-variant stub for a parameter-group enum, fully crate-qualified. */
|
|
655
|
+
function parameterGroupStubExpr(
|
|
656
|
+
group: import('@workos/oagen').ParameterGroup,
|
|
657
|
+
crate: string,
|
|
658
|
+
accessor: string,
|
|
659
|
+
): string {
|
|
660
|
+
const enumName = group.name
|
|
661
|
+
.split('_')
|
|
662
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
663
|
+
.join('');
|
|
664
|
+
const firstVariant = group.variants[0];
|
|
665
|
+
const variantName = firstVariant.name
|
|
666
|
+
.split('_')
|
|
667
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
668
|
+
.join('');
|
|
669
|
+
const fqn = `${crate}::${accessor}::${enumName}`;
|
|
670
|
+
if (firstVariant.parameters.length === 0) return `${fqn}::${variantName}`;
|
|
671
|
+
const fields = firstVariant.parameters
|
|
672
|
+
.map((p) => `${p.name}: ${JSON.stringify(`stub_${p.name}`)}.to_string()`)
|
|
673
|
+
.join(', ');
|
|
674
|
+
return `${fqn}::${variantName} { ${fields} }`;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Test for a wrapper-method operation. */
|
|
678
|
+
function renderWrapperTest(
|
|
679
|
+
op: Operation,
|
|
680
|
+
wrapper: ResolvedWrapper,
|
|
681
|
+
ctx: EmitterContext,
|
|
682
|
+
accessor: string,
|
|
683
|
+
crate: string,
|
|
684
|
+
modelMap: Map<string, Model>,
|
|
685
|
+
enumMap: Map<string, Enum>,
|
|
686
|
+
): string[] {
|
|
687
|
+
const m = methodName(wrapper.name);
|
|
688
|
+
const literalPath = op.path.replace(/\{[^}]+\}/g, 'test_id');
|
|
689
|
+
const httpMethod = op.httpMethod.toUpperCase();
|
|
690
|
+
// Wrapper response is the wrapper's responseModelName (or the operation's
|
|
691
|
+
// declared response when none is overridden).
|
|
692
|
+
const responseExpr = wrapper.responseModelName
|
|
693
|
+
? responseBodyExpr({ kind: 'model', name: wrapper.responseModelName }, modelMap, enumMap)
|
|
694
|
+
: responseBodyExpr(op.response, modelMap, enumMap);
|
|
695
|
+
|
|
696
|
+
const params = resolveWrapperParams(wrapper, ctx);
|
|
697
|
+
const callArgs: string[] = [];
|
|
698
|
+
for (const _ of op.pathParams) callArgs.push('"test_id"');
|
|
699
|
+
|
|
700
|
+
const paramsType = `${crate}::${accessor}::${typeName(wrapper.name)}Params`;
|
|
701
|
+
const requiredParams = params.filter((rp) => !rp.isOptional);
|
|
702
|
+
|
|
703
|
+
if (requiredParams.length === 0) {
|
|
704
|
+
callArgs.push(`${paramsType}::default()`);
|
|
705
|
+
} else {
|
|
706
|
+
const ctorArgs = requiredParams.map((rp) => {
|
|
707
|
+
if (!rp.field) return `"stub_${rp.paramName}".to_string()`;
|
|
708
|
+
return stubExpr(rp.field.type, rp.paramName, modelMap, enumMap);
|
|
709
|
+
});
|
|
710
|
+
callArgs.push(`${paramsType}::new(${ctorArgs.join(', ')})`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const callArgsStr = callArgs.join(', ');
|
|
714
|
+
|
|
715
|
+
const lines: string[] = [];
|
|
716
|
+
lines.push('#[tokio::test]');
|
|
717
|
+
lines.push(`async fn ${accessor}_${m}_round_trip() {`);
|
|
718
|
+
lines.push(' let server = MockServer::start().await;');
|
|
719
|
+
lines.push(` Mock::given(method(${JSON.stringify(httpMethod)}))`);
|
|
720
|
+
lines.push(` .and(path_matcher(${JSON.stringify(literalPath)}))`);
|
|
721
|
+
lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${responseExpr}))`);
|
|
722
|
+
lines.push(' .expect(1)');
|
|
723
|
+
lines.push(' .mount(&server)');
|
|
724
|
+
lines.push(' .await;');
|
|
725
|
+
lines.push(' let client = common::test_client(&server).await;');
|
|
726
|
+
lines.push(` let _ = client.${accessor}().${m}(${callArgsStr}).await;`);
|
|
727
|
+
// Drop assertion on `Mock::expect(1)` validates path/method.
|
|
728
|
+
lines.push('}');
|
|
729
|
+
|
|
730
|
+
// Error tests for wrapper variants — same path/method, different response.
|
|
731
|
+
const shape: CallShape = {
|
|
732
|
+
methodIdent: m,
|
|
733
|
+
literalPath,
|
|
734
|
+
httpMethod,
|
|
735
|
+
callArgs: callArgsStr,
|
|
736
|
+
isUrlBuilder: false,
|
|
737
|
+
isWrite: isWriteMethod(op.httpMethod),
|
|
738
|
+
isPaginated: !!op.pagination,
|
|
739
|
+
hasBodyGroup: false,
|
|
740
|
+
};
|
|
741
|
+
for (const errTest of standardErrorTests(shape, accessor)) {
|
|
742
|
+
lines.push('');
|
|
743
|
+
lines.push(...errTest);
|
|
744
|
+
}
|
|
745
|
+
if (shape.isWrite) {
|
|
746
|
+
for (const errTest of writeErrorTests(shape, accessor)) {
|
|
747
|
+
lines.push('');
|
|
748
|
+
lines.push(...errTest);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return lines;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Rust string-expression for the mock response body. */
|
|
756
|
+
function responseBodyExpr(ref: TypeRef | undefined, modelMap: Map<string, Model>, enumMap: Map<string, Enum>): string {
|
|
757
|
+
if (!ref) return JSON.stringify('{}');
|
|
758
|
+
if (ref.kind === 'primitive' && ref.type === 'unknown') return JSON.stringify('{}');
|
|
759
|
+
if (ref.kind === 'model') {
|
|
760
|
+
const m = modelMap.get(ref.name);
|
|
761
|
+
if (!m || m.fields.length === 0 || m.fields.every((f) => !f.required)) {
|
|
762
|
+
return JSON.stringify('{}');
|
|
763
|
+
}
|
|
764
|
+
return modelFixtureExpr(ref.name);
|
|
765
|
+
}
|
|
766
|
+
if (ref.kind === 'nullable') return responseBodyExpr(ref.inner, modelMap, enumMap);
|
|
767
|
+
// For arrays/primitives/maps/enums/literals/unions, synthesise inline JSON.
|
|
768
|
+
const example = exampleFor(ref, modelMap, enumMap, new Set(), 'value');
|
|
769
|
+
return JSON.stringify(JSON.stringify(example));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** `include_str!("fixtures/<snake>.json")` for a model name. */
|
|
773
|
+
function modelFixtureExpr(name: string): string {
|
|
774
|
+
return `include_str!(${JSON.stringify(`fixtures/${moduleName(name)}.json`)})`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Rust expression for an instance of `type` that satisfies its declared shape.
|
|
779
|
+
* Used to construct required constructor arguments at test-build time.
|
|
780
|
+
*
|
|
781
|
+
* For models we deserialize a JSON fixture; for everything else we synthesise
|
|
782
|
+
* a small example with `serde_json::from_str`. `String` is the one exception:
|
|
783
|
+
* the generator's `new(...)` constructor takes `impl Into<String>`, so we
|
|
784
|
+
* pass a string literal directly to keep type inference happy.
|
|
785
|
+
*/
|
|
786
|
+
function stubExpr(ref: TypeRef, hint: string, modelMap: Map<string, Model>, enumMap: Map<string, Enum>): string {
|
|
787
|
+
// Strings: emit `"stub_x".to_string()`. Works in struct-literal contexts
|
|
788
|
+
// (which need `String`) as well as `new(...)` constructors that take
|
|
789
|
+
// `impl Into<String>`. Avoiding `from_str` keeps type inference simple.
|
|
790
|
+
if (ref.kind === 'primitive' && ref.type === 'string') {
|
|
791
|
+
return `${JSON.stringify(`stub_${hint}`)}.to_string()`;
|
|
792
|
+
}
|
|
793
|
+
if (ref.kind === 'nullable') return stubExpr(ref.inner, hint, modelMap, enumMap);
|
|
794
|
+
if (ref.kind === 'model') {
|
|
795
|
+
// Fixture generator skips models with no required fields; fall back to
|
|
796
|
+
// an inline `{}` so the test still compiles.
|
|
797
|
+
const m = modelMap.get(ref.name);
|
|
798
|
+
if (!m || m.fields.length === 0 || m.fields.every((f) => !f.required)) {
|
|
799
|
+
return `serde_json::from_str("{}").expect("parse stub for ${ref.name}")`;
|
|
800
|
+
}
|
|
801
|
+
return `serde_json::from_str(${modelFixtureExpr(ref.name)}).expect("parse fixture for ${ref.name}")`;
|
|
802
|
+
}
|
|
803
|
+
// For other shapes, JSON-serialise an example value and deserialise at
|
|
804
|
+
// runtime. The generator's constructors fully specify each parameter type,
|
|
805
|
+
// so type inference flows from the constructor signature back into
|
|
806
|
+
// `serde_json::from_str`.
|
|
807
|
+
const example = exampleFor(ref, modelMap, enumMap, new Set(), hint);
|
|
808
|
+
const json = JSON.stringify(example);
|
|
809
|
+
return `serde_json::from_str(${JSON.stringify(json)}).expect("parse stub")`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function crateName(ctx: EmitterContext): string {
|
|
813
|
+
// Cargo crate names are conventionally lowercase with no separators (e.g.
|
|
814
|
+
// `workos`). The IR's snake-cased namespace ("work_os") inserts an
|
|
815
|
+
// underscore around the "os" acronym, so derive from `namespacePascal` —
|
|
816
|
+
// the verbatim user-supplied namespace — and lowercase it.
|
|
817
|
+
return ctx.namespacePascal.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
818
|
+
}
|