@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,748 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
|
|
3
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
+
import { generateResources } from '../../src/rust/resources.js';
|
|
5
|
+
import { UnionRegistry } from '../../src/rust/type-map.js';
|
|
6
|
+
|
|
7
|
+
const emptySpec: ApiSpec = {
|
|
8
|
+
name: 'Test',
|
|
9
|
+
version: '1.0.0',
|
|
10
|
+
baseUrl: '',
|
|
11
|
+
services: [],
|
|
12
|
+
models: [],
|
|
13
|
+
enums: [],
|
|
14
|
+
sdk: defaultSdkBehavior(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ctx: EmitterContext = {
|
|
18
|
+
namespace: 'workos',
|
|
19
|
+
namespacePascal: 'WorkOS',
|
|
20
|
+
spec: emptySpec,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a resolved-operations-aware context. The Rust emitter groups
|
|
25
|
+
* operations by `mountOn`, which the resolver populates; tests that want to
|
|
26
|
+
* exercise the per-resource emitter need to seed those entries themselves.
|
|
27
|
+
*/
|
|
28
|
+
function ctxWithResolved(services: Service[]): EmitterContext {
|
|
29
|
+
return {
|
|
30
|
+
...ctx,
|
|
31
|
+
spec: { ...emptySpec, services },
|
|
32
|
+
resolvedOperations: services.flatMap((service) =>
|
|
33
|
+
service.operations.map((operation) => ({
|
|
34
|
+
service,
|
|
35
|
+
operation,
|
|
36
|
+
methodName: operation.name,
|
|
37
|
+
mountOn: service.name,
|
|
38
|
+
defaults: {},
|
|
39
|
+
inferFromClient: [],
|
|
40
|
+
urlBuilder: false,
|
|
41
|
+
})),
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('rust/resources', () => {
|
|
47
|
+
it('skips services with no operations', () => {
|
|
48
|
+
const services: Service[] = [{ name: 'Empty', operations: [] }];
|
|
49
|
+
const files = generateResources(services, ctxWithResolved(services), new UnionRegistry());
|
|
50
|
+
expect(files.find((f) => f.path.startsWith('src/resources/empty'))).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('emits a resource struct with async methods', () => {
|
|
54
|
+
const services: Service[] = [
|
|
55
|
+
{
|
|
56
|
+
name: 'Organizations',
|
|
57
|
+
operations: [
|
|
58
|
+
{
|
|
59
|
+
name: 'createOrganization',
|
|
60
|
+
httpMethod: 'post',
|
|
61
|
+
path: '/organizations',
|
|
62
|
+
pathParams: [],
|
|
63
|
+
queryParams: [],
|
|
64
|
+
headerParams: [],
|
|
65
|
+
response: { kind: 'model', name: 'Organization' },
|
|
66
|
+
errors: [],
|
|
67
|
+
injectIdempotencyKey: false,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
const files = generateResources(services, ctxWithResolved(services), new UnionRegistry());
|
|
73
|
+
const f = files.find((x) => x.path === 'src/resources/organizations.rs')!;
|
|
74
|
+
expect(f.content).toContain("pub struct OrganizationsApi<'a> {");
|
|
75
|
+
expect(f.content).toContain("pub(crate) client: &'a Client,");
|
|
76
|
+
expect(f.content).toContain('pub async fn create_organization(');
|
|
77
|
+
expect(f.content).toContain(' -> Result<Organization, Error>');
|
|
78
|
+
expect(f.content).toContain('http::Method::POST');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('treats request body as required by default and passes Some(&body)', () => {
|
|
82
|
+
const services: Service[] = [
|
|
83
|
+
{
|
|
84
|
+
name: 'Issues',
|
|
85
|
+
operations: [
|
|
86
|
+
{
|
|
87
|
+
name: 'createIssue',
|
|
88
|
+
httpMethod: 'post',
|
|
89
|
+
path: '/issues',
|
|
90
|
+
pathParams: [],
|
|
91
|
+
queryParams: [],
|
|
92
|
+
headerParams: [],
|
|
93
|
+
requestBody: { kind: 'model', name: 'CreateIssueRequest' },
|
|
94
|
+
response: { kind: 'model', name: 'Issue' },
|
|
95
|
+
errors: [],
|
|
96
|
+
injectIdempotencyKey: false,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
|
|
102
|
+
(x) => x.path === 'src/resources/issues.rs',
|
|
103
|
+
)!;
|
|
104
|
+
expect(f.content).toContain('pub struct CreateIssueParams {');
|
|
105
|
+
expect(f.content).toContain('pub body: CreateIssueRequest,');
|
|
106
|
+
expect(f.content).not.toContain('pub body: Option<CreateIssueRequest>');
|
|
107
|
+
expect(f.content).toContain('Some(¶ms.body)');
|
|
108
|
+
// Required body forbids the Default derive.
|
|
109
|
+
expect(f.content).toContain('#[derive(Debug, Clone, Serialize)]');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('treats nullable request body as optional and passes params.body.as_ref()', () => {
|
|
113
|
+
const services: Service[] = [
|
|
114
|
+
{
|
|
115
|
+
name: 'Issues',
|
|
116
|
+
operations: [
|
|
117
|
+
{
|
|
118
|
+
name: 'updateIssue',
|
|
119
|
+
httpMethod: 'patch',
|
|
120
|
+
path: '/issues/{id}',
|
|
121
|
+
pathParams: [
|
|
122
|
+
{
|
|
123
|
+
name: 'id',
|
|
124
|
+
type: { kind: 'primitive', type: 'string' },
|
|
125
|
+
required: true,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
queryParams: [],
|
|
129
|
+
headerParams: [],
|
|
130
|
+
requestBody: {
|
|
131
|
+
kind: 'nullable',
|
|
132
|
+
inner: { kind: 'model', name: 'UpdateIssueRequest' },
|
|
133
|
+
},
|
|
134
|
+
response: { kind: 'model', name: 'Issue' },
|
|
135
|
+
errors: [],
|
|
136
|
+
injectIdempotencyKey: false,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
|
|
142
|
+
(x) => x.path === 'src/resources/issues.rs',
|
|
143
|
+
)!;
|
|
144
|
+
expect(f.content).toContain('pub body: Option<UpdateIssueRequest>,');
|
|
145
|
+
expect(f.content).toContain('params.body.as_ref()');
|
|
146
|
+
expect(f.content).toContain('#[derive(Debug, Clone, Default, Serialize)]');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('renders multi-line operation descriptions as multi-line doc comments', () => {
|
|
150
|
+
const services: Service[] = [
|
|
151
|
+
{
|
|
152
|
+
name: 'Users',
|
|
153
|
+
operations: [
|
|
154
|
+
{
|
|
155
|
+
name: 'listUsers',
|
|
156
|
+
description: 'List all users.\n\nSupports cursor pagination via `after`.',
|
|
157
|
+
httpMethod: 'get',
|
|
158
|
+
path: '/users',
|
|
159
|
+
pathParams: [],
|
|
160
|
+
queryParams: [],
|
|
161
|
+
headerParams: [],
|
|
162
|
+
response: { kind: 'model', name: 'UsersList' },
|
|
163
|
+
errors: [],
|
|
164
|
+
injectIdempotencyKey: false,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
|
|
170
|
+
(x) => x.path === 'src/resources/users.rs',
|
|
171
|
+
)!;
|
|
172
|
+
expect(f.content).toContain(' /// List all users.');
|
|
173
|
+
expect(f.content).toContain(' ///');
|
|
174
|
+
expect(f.content).toContain(' /// Supports cursor pagination via `after`.');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('reads inferFromClient body fields from the runtime client', () => {
|
|
178
|
+
const services: Service[] = [
|
|
179
|
+
{
|
|
180
|
+
name: 'UserManagement',
|
|
181
|
+
operations: [
|
|
182
|
+
{
|
|
183
|
+
name: 'authenticate',
|
|
184
|
+
httpMethod: 'post',
|
|
185
|
+
path: '/user_management/authenticate',
|
|
186
|
+
pathParams: [],
|
|
187
|
+
queryParams: [],
|
|
188
|
+
headerParams: [],
|
|
189
|
+
response: { kind: 'model', name: 'AuthenticateResponse' },
|
|
190
|
+
errors: [],
|
|
191
|
+
injectIdempotencyKey: false,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
const baseCtx = ctxWithResolved(services);
|
|
197
|
+
const ctxWithWrapper: EmitterContext = {
|
|
198
|
+
...baseCtx,
|
|
199
|
+
resolvedOperations: baseCtx.resolvedOperations!.map((r) => ({
|
|
200
|
+
...r,
|
|
201
|
+
wrappers: [
|
|
202
|
+
{
|
|
203
|
+
name: 'authenticate_with_code',
|
|
204
|
+
targetVariant: 'AuthorizationCodeSessionAuthenticateRequest',
|
|
205
|
+
defaults: { grant_type: 'authorization_code' },
|
|
206
|
+
inferFromClient: ['client_id', 'client_secret'],
|
|
207
|
+
exposedParams: ['code'],
|
|
208
|
+
optionalParams: [],
|
|
209
|
+
responseModelName: null,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
})),
|
|
213
|
+
};
|
|
214
|
+
const f = generateResources(services, ctxWithWrapper, new UnionRegistry()).find(
|
|
215
|
+
(x) => x.path === 'src/resources/user_management.rs',
|
|
216
|
+
)!;
|
|
217
|
+
// Inferred fields read from the runtime client, not empty literals.
|
|
218
|
+
expect(f.content).toContain('"client_id": self.client.client_id()');
|
|
219
|
+
expect(f.content).toContain('"client_secret": self.client.api_key()');
|
|
220
|
+
expect(f.content).not.toContain('"client_id": "",');
|
|
221
|
+
expect(f.content).not.toContain('"client_secret": "",');
|
|
222
|
+
// Defaults are still emitted as literal JSON values.
|
|
223
|
+
expect(f.content).toContain('"grant_type": "authorization_code"');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('renders spec-level parameter defaults as doc comments', () => {
|
|
227
|
+
const services: Service[] = [
|
|
228
|
+
{
|
|
229
|
+
name: 'Events',
|
|
230
|
+
operations: [
|
|
231
|
+
{
|
|
232
|
+
name: 'listEvents',
|
|
233
|
+
httpMethod: 'get',
|
|
234
|
+
path: '/events',
|
|
235
|
+
pathParams: [],
|
|
236
|
+
queryParams: [
|
|
237
|
+
{
|
|
238
|
+
name: 'limit',
|
|
239
|
+
type: { kind: 'primitive', type: 'integer' },
|
|
240
|
+
required: false,
|
|
241
|
+
description: 'Upper limit.',
|
|
242
|
+
default: 10,
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: 'order',
|
|
246
|
+
type: { kind: 'enum', name: 'PaginationOrder' },
|
|
247
|
+
required: false,
|
|
248
|
+
description: 'Order the results.',
|
|
249
|
+
default: 'desc',
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'enabled',
|
|
253
|
+
type: { kind: 'primitive', type: 'boolean' },
|
|
254
|
+
required: false,
|
|
255
|
+
default: true,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
headerParams: [],
|
|
259
|
+
response: { kind: 'model', name: 'EventsList' },
|
|
260
|
+
errors: [],
|
|
261
|
+
injectIdempotencyKey: false,
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
|
|
267
|
+
(x) => x.path === 'src/resources/events.rs',
|
|
268
|
+
)!;
|
|
269
|
+
expect(f.content).toContain(' /// Upper limit.\n ///\n /// Defaults to `10`.');
|
|
270
|
+
expect(f.content).toContain(' /// Order the results.\n ///\n /// Defaults to `desc`.');
|
|
271
|
+
expect(f.content).toContain(' /// Defaults to `true`.');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('interpolates path parameters via format!', () => {
|
|
275
|
+
const services: Service[] = [
|
|
276
|
+
{
|
|
277
|
+
name: 'Users',
|
|
278
|
+
operations: [
|
|
279
|
+
{
|
|
280
|
+
name: 'getUser',
|
|
281
|
+
httpMethod: 'get',
|
|
282
|
+
path: '/users/{id}',
|
|
283
|
+
pathParams: [
|
|
284
|
+
{
|
|
285
|
+
name: 'id',
|
|
286
|
+
type: { kind: 'primitive', type: 'string' },
|
|
287
|
+
required: true,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
queryParams: [],
|
|
291
|
+
headerParams: [],
|
|
292
|
+
response: { kind: 'model', name: 'User' },
|
|
293
|
+
errors: [],
|
|
294
|
+
injectIdempotencyKey: false,
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
];
|
|
299
|
+
const files = generateResources(services, ctxWithResolved(services), new UnionRegistry());
|
|
300
|
+
const f = files.find((x) => x.path === 'src/resources/users.rs')!;
|
|
301
|
+
expect(f.content).toContain('let id = crate::client::path_segment(id);');
|
|
302
|
+
expect(f.content).toContain('let path = format!("/users/{id}");');
|
|
303
|
+
expect(f.content).toContain('pub async fn get_user(&self, id: &str');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('emits a URL-builder method when resolved.urlBuilder is true', () => {
|
|
307
|
+
const services: Service[] = [
|
|
308
|
+
{
|
|
309
|
+
name: 'SSO',
|
|
310
|
+
operations: [
|
|
311
|
+
{
|
|
312
|
+
name: 'getAuthorizationUrl',
|
|
313
|
+
httpMethod: 'get',
|
|
314
|
+
path: '/sso/authorize',
|
|
315
|
+
pathParams: [],
|
|
316
|
+
queryParams: [
|
|
317
|
+
{
|
|
318
|
+
name: 'redirect_uri',
|
|
319
|
+
type: { kind: 'primitive', type: 'string' },
|
|
320
|
+
required: true,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: 'state',
|
|
324
|
+
type: { kind: 'primitive', type: 'string' },
|
|
325
|
+
required: false,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
headerParams: [],
|
|
329
|
+
response: { kind: 'model', name: 'SsoAuthorizeUrlResponse' },
|
|
330
|
+
errors: [],
|
|
331
|
+
injectIdempotencyKey: false,
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
const baseCtx = ctxWithResolved(services);
|
|
337
|
+
const ctx: EmitterContext = {
|
|
338
|
+
...baseCtx,
|
|
339
|
+
resolvedOperations: baseCtx.resolvedOperations!.map((r) => ({
|
|
340
|
+
...r,
|
|
341
|
+
urlBuilder: true,
|
|
342
|
+
})),
|
|
343
|
+
};
|
|
344
|
+
const f = generateResources(services, ctx, new UnionRegistry()).find((x) => x.path === 'src/resources/sso.rs')!;
|
|
345
|
+
// URL builders are sync `pub fn`, return `Result<String, Error>`, and
|
|
346
|
+
// never emit an `_with_options` variant or an HTTP issuer.
|
|
347
|
+
expect(f.content).toContain('pub fn get_authorization_url(');
|
|
348
|
+
expect(f.content).toContain('-> Result<String, Error>');
|
|
349
|
+
expect(f.content).toContain('let qs = crate::query::encode_query');
|
|
350
|
+
expect(f.content).not.toContain('get_authorization_url_with_options');
|
|
351
|
+
expect(f.content).not.toContain('request_with_query_opts');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('emits a bearer-override token parameter when op.security has a non-bearer scheme', () => {
|
|
355
|
+
const services: Service[] = [
|
|
356
|
+
{
|
|
357
|
+
name: 'SSO',
|
|
358
|
+
operations: [
|
|
359
|
+
{
|
|
360
|
+
name: 'getProfile',
|
|
361
|
+
httpMethod: 'get',
|
|
362
|
+
path: '/sso/profile',
|
|
363
|
+
pathParams: [],
|
|
364
|
+
queryParams: [],
|
|
365
|
+
headerParams: [],
|
|
366
|
+
response: { kind: 'model', name: 'Profile' },
|
|
367
|
+
errors: [],
|
|
368
|
+
injectIdempotencyKey: false,
|
|
369
|
+
security: [{ schemeName: 'access_token', scopes: [] }],
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
|
|
375
|
+
(x) => x.path === 'src/resources/sso.rs',
|
|
376
|
+
)!;
|
|
377
|
+
// The method takes `access_token: impl Into<String>` and overrides the
|
|
378
|
+
// Authorization header in-place via a merged RequestOptions clone.
|
|
379
|
+
expect(f.content).toContain('access_token: impl Into<String>');
|
|
380
|
+
expect(f.content).toContain('let access_token: String = access_token.into();');
|
|
381
|
+
expect(f.content).toContain('http::header::AUTHORIZATION');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('emits a parameter-group enum and a single flattened field on the params struct', () => {
|
|
385
|
+
const services: Service[] = [
|
|
386
|
+
{
|
|
387
|
+
name: 'Authorization',
|
|
388
|
+
operations: [
|
|
389
|
+
{
|
|
390
|
+
name: 'check',
|
|
391
|
+
httpMethod: 'post',
|
|
392
|
+
path: '/authorization/check',
|
|
393
|
+
pathParams: [],
|
|
394
|
+
queryParams: [],
|
|
395
|
+
headerParams: [],
|
|
396
|
+
requestBody: { kind: 'model', name: 'CheckAuthorization' },
|
|
397
|
+
response: { kind: 'model', name: 'AuthorizationCheck' },
|
|
398
|
+
errors: [],
|
|
399
|
+
injectIdempotencyKey: false,
|
|
400
|
+
parameterGroups: [
|
|
401
|
+
{
|
|
402
|
+
name: 'resource_target',
|
|
403
|
+
optional: false,
|
|
404
|
+
variants: [
|
|
405
|
+
{
|
|
406
|
+
name: 'by_id',
|
|
407
|
+
parameters: [
|
|
408
|
+
{
|
|
409
|
+
name: 'resource_id',
|
|
410
|
+
type: { kind: 'primitive', type: 'string' },
|
|
411
|
+
required: false,
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: 'by_external_id',
|
|
417
|
+
parameters: [
|
|
418
|
+
{
|
|
419
|
+
name: 'resource_external_id',
|
|
420
|
+
type: { kind: 'primitive', type: 'string' },
|
|
421
|
+
required: false,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: 'resource_type_slug',
|
|
425
|
+
type: { kind: 'primitive', type: 'string' },
|
|
426
|
+
required: false,
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
},
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
},
|
|
436
|
+
];
|
|
437
|
+
const baseCtx = ctxWithResolved(services);
|
|
438
|
+
const ctx: EmitterContext = {
|
|
439
|
+
...baseCtx,
|
|
440
|
+
spec: {
|
|
441
|
+
...baseCtx.spec,
|
|
442
|
+
models: [
|
|
443
|
+
{
|
|
444
|
+
name: 'CheckAuthorization',
|
|
445
|
+
fields: [
|
|
446
|
+
{
|
|
447
|
+
name: 'permission_slug',
|
|
448
|
+
type: { kind: 'primitive', type: 'string' },
|
|
449
|
+
required: true,
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: 'resource_id',
|
|
453
|
+
type: { kind: 'primitive', type: 'string' },
|
|
454
|
+
required: false,
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
name: 'resource_external_id',
|
|
458
|
+
type: { kind: 'primitive', type: 'string' },
|
|
459
|
+
required: false,
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: 'resource_type_slug',
|
|
463
|
+
type: { kind: 'primitive', type: 'string' },
|
|
464
|
+
required: false,
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
],
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
const f = generateResources(services, ctx, new UnionRegistry()).find(
|
|
472
|
+
(x) => x.path === 'src/resources/authorization.rs',
|
|
473
|
+
)!;
|
|
474
|
+
// Enum is generated with untagged variants whose fields flatten cleanly.
|
|
475
|
+
expect(f.content).toContain('pub enum ResourceTarget {');
|
|
476
|
+
expect(f.content).toContain('#[serde(untagged)]');
|
|
477
|
+
expect(f.content).toContain('ById {');
|
|
478
|
+
expect(f.content).toContain('resource_id: String,');
|
|
479
|
+
expect(f.content).toContain('ByExternalId {');
|
|
480
|
+
// The synthetic body keeps non-grouped fields flat and folds the enum
|
|
481
|
+
// in via `serde(flatten)`.
|
|
482
|
+
expect(f.content).toContain('pub struct CheckParamsBody {');
|
|
483
|
+
expect(f.content).toContain('pub permission_slug: String,');
|
|
484
|
+
expect(f.content).toContain('#[serde(flatten)]\n pub resource_target: ResourceTarget,');
|
|
485
|
+
// The params struct's `body` field points at the synthetic type, not the
|
|
486
|
+
// original model.
|
|
487
|
+
expect(f.content).toContain('pub body: CheckParamsBody,');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('drives auto-paging from op.pagination and uses the IR cursor param name', () => {
|
|
491
|
+
const services: Service[] = [
|
|
492
|
+
{
|
|
493
|
+
name: 'Widgets',
|
|
494
|
+
operations: [
|
|
495
|
+
{
|
|
496
|
+
name: 'listWidgets',
|
|
497
|
+
httpMethod: 'get',
|
|
498
|
+
path: '/widgets',
|
|
499
|
+
pathParams: [],
|
|
500
|
+
queryParams: [
|
|
501
|
+
{
|
|
502
|
+
name: 'before',
|
|
503
|
+
type: { kind: 'primitive', type: 'string' },
|
|
504
|
+
required: false,
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
name: 'after',
|
|
508
|
+
type: { kind: 'primitive', type: 'string' },
|
|
509
|
+
required: false,
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: 'limit',
|
|
513
|
+
type: { kind: 'primitive', type: 'integer' },
|
|
514
|
+
required: false,
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
headerParams: [],
|
|
518
|
+
response: { kind: 'model', name: 'WidgetList' },
|
|
519
|
+
errors: [],
|
|
520
|
+
injectIdempotencyKey: false,
|
|
521
|
+
pagination: {
|
|
522
|
+
strategy: 'cursor',
|
|
523
|
+
param: 'before',
|
|
524
|
+
itemType: { kind: 'model', name: 'WidgetList' },
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
},
|
|
529
|
+
];
|
|
530
|
+
const baseCtx = ctxWithResolved(services);
|
|
531
|
+
const ctx: EmitterContext = {
|
|
532
|
+
...baseCtx,
|
|
533
|
+
spec: {
|
|
534
|
+
...baseCtx.spec,
|
|
535
|
+
models: [
|
|
536
|
+
{
|
|
537
|
+
name: 'WidgetList',
|
|
538
|
+
fields: [
|
|
539
|
+
{
|
|
540
|
+
name: 'data',
|
|
541
|
+
type: {
|
|
542
|
+
kind: 'array',
|
|
543
|
+
items: { kind: 'model', name: 'Widget' },
|
|
544
|
+
},
|
|
545
|
+
required: true,
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: 'list_metadata',
|
|
549
|
+
type: { kind: 'model', name: 'WidgetListListMetadata' },
|
|
550
|
+
required: true,
|
|
551
|
+
},
|
|
552
|
+
],
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: 'WidgetListListMetadata',
|
|
556
|
+
fields: [
|
|
557
|
+
{
|
|
558
|
+
name: 'before',
|
|
559
|
+
type: { kind: 'primitive', type: 'string' },
|
|
560
|
+
required: false,
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
name: 'after',
|
|
564
|
+
type: { kind: 'primitive', type: 'string' },
|
|
565
|
+
required: false,
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
},
|
|
569
|
+
{ name: 'Widget', fields: [] },
|
|
570
|
+
],
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
const f = generateResources(services, ctx, new UnionRegistry()).find((x) => x.path === 'src/resources/widgets.rs')!;
|
|
574
|
+
// The IR's cursor param wins over the old hardcoded `after` — both the
|
|
575
|
+
// params side and the list-metadata side reference `before`.
|
|
576
|
+
expect(f.content).toContain('list_widgets_auto_paging');
|
|
577
|
+
expect(f.content).toContain('params.before = after;');
|
|
578
|
+
expect(f.content).toContain('page.list_metadata.before');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('skips auto-paging when the IR cursor field is missing from the response metadata', () => {
|
|
582
|
+
// If the spec/IR is internally inconsistent (request says cursor is
|
|
583
|
+
// `weird_cursor` but the list-metadata model has no such field) we'd emit
|
|
584
|
+
// code that references a nonexistent field. Bail out instead — callers
|
|
585
|
+
// can paginate manually.
|
|
586
|
+
const services: Service[] = [
|
|
587
|
+
{
|
|
588
|
+
name: 'Events',
|
|
589
|
+
operations: [
|
|
590
|
+
{
|
|
591
|
+
name: 'listEvents',
|
|
592
|
+
httpMethod: 'get',
|
|
593
|
+
path: '/events',
|
|
594
|
+
pathParams: [],
|
|
595
|
+
queryParams: [
|
|
596
|
+
{
|
|
597
|
+
name: 'weird_cursor',
|
|
598
|
+
type: { kind: 'primitive', type: 'string' },
|
|
599
|
+
required: false,
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
headerParams: [],
|
|
603
|
+
response: { kind: 'model', name: 'EventList' },
|
|
604
|
+
errors: [],
|
|
605
|
+
injectIdempotencyKey: false,
|
|
606
|
+
pagination: {
|
|
607
|
+
strategy: 'cursor',
|
|
608
|
+
param: 'weird_cursor',
|
|
609
|
+
itemType: { kind: 'model', name: 'EventList' },
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
},
|
|
614
|
+
];
|
|
615
|
+
const baseCtx = ctxWithResolved(services);
|
|
616
|
+
const ctx: EmitterContext = {
|
|
617
|
+
...baseCtx,
|
|
618
|
+
spec: {
|
|
619
|
+
...baseCtx.spec,
|
|
620
|
+
models: [
|
|
621
|
+
{
|
|
622
|
+
name: 'EventList',
|
|
623
|
+
fields: [
|
|
624
|
+
{
|
|
625
|
+
name: 'data',
|
|
626
|
+
type: {
|
|
627
|
+
kind: 'array',
|
|
628
|
+
items: { kind: 'model', name: 'Event' },
|
|
629
|
+
},
|
|
630
|
+
required: true,
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
name: 'list_metadata',
|
|
634
|
+
type: { kind: 'model', name: 'EventListListMetadata' },
|
|
635
|
+
required: true,
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
name: 'EventListListMetadata',
|
|
641
|
+
fields: [
|
|
642
|
+
{
|
|
643
|
+
name: 'after',
|
|
644
|
+
type: { kind: 'primitive', type: 'string' },
|
|
645
|
+
required: false,
|
|
646
|
+
},
|
|
647
|
+
],
|
|
648
|
+
},
|
|
649
|
+
{ name: 'Event', fields: [] },
|
|
650
|
+
],
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
const f = generateResources(services, ctx, new UnionRegistry()).find((x) => x.path === 'src/resources/events.rs')!;
|
|
654
|
+
expect(f.content).not.toContain('list_events_auto_paging');
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('adds serialize_with attribute on Vec query params with explode=false', () => {
|
|
658
|
+
const services: Service[] = [
|
|
659
|
+
{
|
|
660
|
+
name: 'Events',
|
|
661
|
+
operations: [
|
|
662
|
+
{
|
|
663
|
+
name: 'listEvents',
|
|
664
|
+
httpMethod: 'get',
|
|
665
|
+
path: '/events',
|
|
666
|
+
pathParams: [],
|
|
667
|
+
queryParams: [
|
|
668
|
+
{
|
|
669
|
+
name: 'events',
|
|
670
|
+
type: {
|
|
671
|
+
kind: 'array',
|
|
672
|
+
items: { kind: 'primitive', type: 'string' },
|
|
673
|
+
},
|
|
674
|
+
required: false,
|
|
675
|
+
style: 'form',
|
|
676
|
+
explode: false,
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
name: 'tags',
|
|
680
|
+
type: {
|
|
681
|
+
kind: 'array',
|
|
682
|
+
items: { kind: 'primitive', type: 'string' },
|
|
683
|
+
},
|
|
684
|
+
required: false,
|
|
685
|
+
style: 'form',
|
|
686
|
+
explode: true,
|
|
687
|
+
},
|
|
688
|
+
],
|
|
689
|
+
headerParams: [],
|
|
690
|
+
response: { kind: 'model', name: 'EventList' },
|
|
691
|
+
errors: [],
|
|
692
|
+
injectIdempotencyKey: false,
|
|
693
|
+
},
|
|
694
|
+
],
|
|
695
|
+
},
|
|
696
|
+
];
|
|
697
|
+
const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
|
|
698
|
+
(x) => x.path === 'src/resources/events.rs',
|
|
699
|
+
)!;
|
|
700
|
+
// explode=false → comma-joined serializer; explode=true (default) leaves
|
|
701
|
+
// the field alone so the runtime query encoder unrolls it to repeated keys.
|
|
702
|
+
expect(f.content).toContain(
|
|
703
|
+
'#[serde(serialize_with = "crate::query::serialize_comma_separated_opt")]\n pub events:',
|
|
704
|
+
);
|
|
705
|
+
expect(f.content).not.toContain('"crate::query::serialize_comma_separated_opt")]\n pub tags:');
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('iterates cookieParams alongside path/query/header params', () => {
|
|
709
|
+
// Forward-compatibility: ensure the iteration site picks up cookie
|
|
710
|
+
// params so a future spec doesn't silently drop them.
|
|
711
|
+
const services: Service[] = [
|
|
712
|
+
{
|
|
713
|
+
name: 'Widgets',
|
|
714
|
+
operations: [
|
|
715
|
+
{
|
|
716
|
+
name: 'getWidget',
|
|
717
|
+
httpMethod: 'get',
|
|
718
|
+
path: '/widgets/{id}',
|
|
719
|
+
pathParams: [
|
|
720
|
+
{
|
|
721
|
+
name: 'id',
|
|
722
|
+
type: { kind: 'primitive', type: 'string' },
|
|
723
|
+
required: true,
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
queryParams: [],
|
|
727
|
+
headerParams: [],
|
|
728
|
+
cookieParams: [
|
|
729
|
+
{
|
|
730
|
+
name: 'session_id',
|
|
731
|
+
type: { kind: 'primitive', type: 'string' },
|
|
732
|
+
required: false,
|
|
733
|
+
description: 'Tracking cookie.',
|
|
734
|
+
},
|
|
735
|
+
],
|
|
736
|
+
response: { kind: 'model', name: 'Widget' },
|
|
737
|
+
errors: [],
|
|
738
|
+
injectIdempotencyKey: false,
|
|
739
|
+
},
|
|
740
|
+
],
|
|
741
|
+
},
|
|
742
|
+
];
|
|
743
|
+
const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
|
|
744
|
+
(x) => x.path === 'src/resources/widgets.rs',
|
|
745
|
+
)!;
|
|
746
|
+
expect(f.content).toContain('pub session_id: Option<String>,');
|
|
747
|
+
});
|
|
748
|
+
});
|