@workos/oagen-emitters 0.12.0 → 0.12.2
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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint-pr-title.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-C408Wh-o.mjs → plugin-eCuvoL1T.mjs} +3914 -2121
- package/dist/plugin-eCuvoL1T.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/package.json +10 -10
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +345 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +540 -351
- package/src/node/naming.ts +119 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/resources.ts +455 -46
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +108 -83
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/src/rust/fixtures.ts +87 -1
- package/src/rust/models.ts +17 -2
- package/src/rust/resources.ts +697 -62
- package/src/rust/tests.ts +540 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +376 -2036
- package/test/node/tests.test.ts +119 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/test/rust/fixtures.test.ts +227 -0
- package/test/rust/models.test.ts +38 -0
- package/test/rust/resources.test.ts +505 -2
- package/test/rust/tests.test.ts +504 -0
- package/dist/plugin-C408Wh-o.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
package/src/rust/tests.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
GeneratedFile,
|
|
6
6
|
Model,
|
|
7
7
|
Operation,
|
|
8
|
+
Parameter,
|
|
8
9
|
ResolvedOperation,
|
|
9
10
|
ResolvedWrapper,
|
|
10
11
|
TypeRef,
|
|
@@ -16,9 +17,19 @@ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Generate integration tests under `tests/`. Each mount group gets one
|
|
19
|
-
* `tests/{mount}_test.rs` file.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
22
33
|
*/
|
|
23
34
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
24
35
|
const files: GeneratedFile[] = [];
|
|
@@ -84,6 +95,7 @@ function renderMountTest(
|
|
|
84
95
|
lines.push('');
|
|
85
96
|
lines.push('use wiremock::matchers::{method, path as path_matcher};');
|
|
86
97
|
lines.push('use wiremock::{Mock, MockServer, ResponseTemplate};');
|
|
98
|
+
lines.push(`use ${crate}::Error;`);
|
|
87
99
|
lines.push('');
|
|
88
100
|
|
|
89
101
|
const seen = new Set<string>();
|
|
@@ -113,6 +125,32 @@ function renderMountTest(
|
|
|
113
125
|
return lines.join('\n') + '\n';
|
|
114
126
|
}
|
|
115
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
|
+
|
|
116
154
|
/** Test for a non-wrapper operation. */
|
|
117
155
|
function renderRegularTest(
|
|
118
156
|
op: Operation,
|
|
@@ -126,30 +164,143 @@ function renderRegularTest(
|
|
|
126
164
|
const literalPath = op.path.replace(/\{[^}]+\}/g, 'test_id');
|
|
127
165
|
const httpMethod = op.httpMethod.toUpperCase();
|
|
128
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('}');
|
|
129
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[] {
|
|
130
263
|
const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
|
|
131
|
-
|
|
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));
|
|
132
271
|
const visibleHeader = op.headerParams.filter((p) => !hidden.has(p.name));
|
|
133
272
|
const visibleParams = [...visibleQuery, ...visibleHeader];
|
|
134
273
|
const requiredParams = visibleParams.filter((p) => p.required);
|
|
135
274
|
const hasBody = op.requestBody !== undefined;
|
|
136
275
|
const bodyRequired = hasBody && op.requestBody!.kind !== 'nullable';
|
|
137
|
-
const
|
|
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;
|
|
138
282
|
|
|
139
283
|
const callArgs: string[] = [];
|
|
140
284
|
for (const _ of op.pathParams) callArgs.push('"test_id"');
|
|
141
285
|
|
|
142
286
|
if (!emptyParams) {
|
|
143
287
|
const paramsType = `${crate}::${accessor}::${typeName(resolved.methodName)}Params`;
|
|
144
|
-
|
|
288
|
+
const noCtorRequired = requiredParams.length === 0 && !bodyRequired && requiredQueryGroups.length === 0;
|
|
289
|
+
if (noCtorRequired) {
|
|
145
290
|
callArgs.push(`${paramsType}::default()`);
|
|
146
291
|
} else {
|
|
147
292
|
const ctorArgs: string[] = [];
|
|
148
293
|
for (const p of requiredParams) {
|
|
149
294
|
ctorArgs.push(stubExpr(p.type, p.name, modelMap, enumMap));
|
|
150
295
|
}
|
|
151
|
-
|
|
152
|
-
|
|
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 {
|
|
153
304
|
ctorArgs.push(stubExpr(op.requestBody!, 'body', modelMap, enumMap));
|
|
154
305
|
}
|
|
155
306
|
}
|
|
@@ -157,28 +308,372 @@ function renderRegularTest(
|
|
|
157
308
|
}
|
|
158
309
|
}
|
|
159
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[] {
|
|
160
364
|
const lines: string[] = [];
|
|
161
365
|
lines.push('#[tokio::test]');
|
|
162
|
-
lines.push(`async fn ${accessor}_${
|
|
366
|
+
lines.push(`async fn ${accessor}_${shape.methodIdent}_${category}() {`);
|
|
163
367
|
lines.push(' let server = MockServer::start().await;');
|
|
164
|
-
lines.push(`
|
|
165
|
-
|
|
166
|
-
|
|
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)');
|
|
167
376
|
lines.push(' .expect(1)');
|
|
168
377
|
lines.push(' .mount(&server)');
|
|
169
378
|
lines.push(' .await;');
|
|
170
379
|
lines.push(' let client = common::test_client(&server).await;');
|
|
171
|
-
lines.push(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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");`);
|
|
178
450
|
lines.push('}');
|
|
179
451
|
return lines;
|
|
180
452
|
}
|
|
181
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
|
+
|
|
182
677
|
/** Test for a wrapper-method operation. */
|
|
183
678
|
function renderWrapperTest(
|
|
184
679
|
op: Operation,
|
|
@@ -215,6 +710,8 @@ function renderWrapperTest(
|
|
|
215
710
|
callArgs.push(`${paramsType}::new(${ctorArgs.join(', ')})`);
|
|
216
711
|
}
|
|
217
712
|
|
|
713
|
+
const callArgsStr = callArgs.join(', ');
|
|
714
|
+
|
|
218
715
|
const lines: string[] = [];
|
|
219
716
|
lines.push('#[tokio::test]');
|
|
220
717
|
lines.push(`async fn ${accessor}_${m}_round_trip() {`);
|
|
@@ -226,9 +723,32 @@ function renderWrapperTest(
|
|
|
226
723
|
lines.push(' .mount(&server)');
|
|
227
724
|
lines.push(' .await;');
|
|
228
725
|
lines.push(' let client = common::test_client(&server).await;');
|
|
229
|
-
lines.push(` let _ = client.${accessor}().${m}(${
|
|
726
|
+
lines.push(` let _ = client.${accessor}().${m}(${callArgsStr}).await;`);
|
|
230
727
|
// Drop assertion on `Mock::expect(1)` validates path/method.
|
|
231
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
|
+
|
|
232
752
|
return lines;
|
|
233
753
|
}
|
|
234
754
|
|