@contractkit/plugin-typescript 0.16.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/.turbo/turbo-build$colon$ci.log +35 -0
- package/.turbo/turbo-build.log +15 -0
- package/.turbo/turbo-test$colon$ci.log +81 -0
- package/.turbo/turbo-test.log +19 -0
- package/CHANGELOG.md +151 -0
- package/README.md +153 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +1882 -0
- package/coverage/coverage-final.json +9 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/codegen-contract.ts.html +3331 -0
- package/coverage/src/codegen-operation.ts.html +2530 -0
- package/coverage/src/codegen-plain-types.ts.html +901 -0
- package/coverage/src/codegen-sdk.ts.html +2797 -0
- package/coverage/src/index.html +206 -0
- package/coverage/src/index.ts.html +1360 -0
- package/coverage/src/path-utils.ts.html +649 -0
- package/coverage/src/ts-render.ts.html +592 -0
- package/coverage/tests/helpers.ts.html +826 -0
- package/coverage/tests/index.html +116 -0
- package/dist/codegen-contract.d.ts +56 -0
- package/dist/codegen-contract.d.ts.map +1 -0
- package/dist/codegen-operation.d.ts +25 -0
- package/dist/codegen-operation.d.ts.map +1 -0
- package/dist/codegen-plain-types.d.ts +10 -0
- package/dist/codegen-plain-types.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +38 -0
- package/dist/codegen-sdk.d.ts.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3162 -0
- package/dist/index.js.map +1 -0
- package/dist/path-utils.d.ts +15 -0
- package/dist/path-utils.d.ts.map +1 -0
- package/dist/ts-render.d.ts +20 -0
- package/dist/ts-render.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +43 -0
- package/src/codegen-contract.ts +1082 -0
- package/src/codegen-operation.ts +815 -0
- package/src/codegen-plain-types.ts +272 -0
- package/src/codegen-sdk.ts +904 -0
- package/src/index.ts +425 -0
- package/src/path-utils.ts +188 -0
- package/src/ts-render.ts +169 -0
- package/tests/codegen-contract.test.ts +1004 -0
- package/tests/codegen-operation.test.ts +939 -0
- package/tests/codegen-plain-types.test.ts +636 -0
- package/tests/codegen-sdk.test.ts +1500 -0
- package/tests/codegen-server.test.ts +192 -0
- package/tests/helpers.ts +247 -0
- package/tests/pipeline.test.ts +372 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
generateSdk,
|
|
4
|
+
generateSdkOptions,
|
|
5
|
+
generateSdkAggregator,
|
|
6
|
+
deriveClientClassName,
|
|
7
|
+
deriveClientPropertyName,
|
|
8
|
+
hasPublicOperations,
|
|
9
|
+
} from '../src/codegen-sdk.js';
|
|
10
|
+
import { collectPublicTypeNames } from '@contractkit/core';
|
|
11
|
+
import { renderTsType, renderInputTsType } from '../src/ts-render.js';
|
|
12
|
+
import {
|
|
13
|
+
opRoot,
|
|
14
|
+
opRoute,
|
|
15
|
+
opOperation,
|
|
16
|
+
opParam,
|
|
17
|
+
opRequest,
|
|
18
|
+
opMultiRequest,
|
|
19
|
+
opResponse,
|
|
20
|
+
scalarType,
|
|
21
|
+
refType,
|
|
22
|
+
arrayType,
|
|
23
|
+
inlineObjectType,
|
|
24
|
+
tupleType,
|
|
25
|
+
recordType,
|
|
26
|
+
enumType,
|
|
27
|
+
literalType,
|
|
28
|
+
lazyType,
|
|
29
|
+
unionType,
|
|
30
|
+
field,
|
|
31
|
+
} from './helpers.js';
|
|
32
|
+
|
|
33
|
+
describe('generateSdk', () => {
|
|
34
|
+
describe('sdk class naming', () => {
|
|
35
|
+
it('derives sdk class name from filename', () => {
|
|
36
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])], 'users.op');
|
|
37
|
+
const out = generateSdk(root);
|
|
38
|
+
expect(out).toContain('export class UsersClient');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('handles dotted filenames', () => {
|
|
42
|
+
const root = opRoot(
|
|
43
|
+
[opRoute('/ledger/categories', [opOperation('get', { responses: [opResponse(200, 'Category', 'application/json')] })])],
|
|
44
|
+
'ledger.categories.op',
|
|
45
|
+
);
|
|
46
|
+
const out = generateSdk(root);
|
|
47
|
+
expect(out).toContain('export class LedgerCategoriesClient');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('method names', () => {
|
|
52
|
+
it('uses sdk field when provided', () => {
|
|
53
|
+
const root = opRoot([
|
|
54
|
+
opRoute('/users', [
|
|
55
|
+
opOperation('get', {
|
|
56
|
+
sdk: 'listAllUsers',
|
|
57
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
58
|
+
}),
|
|
59
|
+
]),
|
|
60
|
+
]);
|
|
61
|
+
const out = generateSdk(root);
|
|
62
|
+
expect(out).toContain('async listAllUsers(');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('falls back to method + path inference', () => {
|
|
66
|
+
const root = opRoot([
|
|
67
|
+
opRoute('/users', [
|
|
68
|
+
opOperation('get', {
|
|
69
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
70
|
+
}),
|
|
71
|
+
]),
|
|
72
|
+
]);
|
|
73
|
+
const out = generateSdk(root);
|
|
74
|
+
expect(out).toContain('async getUsers(');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('includes path param segments in inferred name', () => {
|
|
78
|
+
const root = opRoot([
|
|
79
|
+
opRoute(
|
|
80
|
+
'/users/{id}',
|
|
81
|
+
[
|
|
82
|
+
opOperation('get', {
|
|
83
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
84
|
+
}),
|
|
85
|
+
],
|
|
86
|
+
[opParam('id', scalarType('uuid'))],
|
|
87
|
+
),
|
|
88
|
+
]);
|
|
89
|
+
const out = generateSdk(root);
|
|
90
|
+
expect(out).toContain('async getUsersById(');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('infers POST method name', () => {
|
|
94
|
+
const root = opRoot([
|
|
95
|
+
opRoute('/users', [
|
|
96
|
+
opOperation('post', {
|
|
97
|
+
request: opRequest('CreateUserInput'),
|
|
98
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
99
|
+
}),
|
|
100
|
+
]),
|
|
101
|
+
]);
|
|
102
|
+
const out = generateSdk(root);
|
|
103
|
+
expect(out).toContain('async postUsers(');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('GET with path params', () => {
|
|
108
|
+
it('generates method with path params and correct return type', () => {
|
|
109
|
+
const root = opRoot([
|
|
110
|
+
opRoute(
|
|
111
|
+
'/users/{id}',
|
|
112
|
+
[
|
|
113
|
+
opOperation('get', {
|
|
114
|
+
sdk: 'getUser',
|
|
115
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
[opParam('id', scalarType('uuid'))],
|
|
119
|
+
),
|
|
120
|
+
]);
|
|
121
|
+
const out = generateSdk(root);
|
|
122
|
+
expect(out).toContain('async getUser(id: string): Promise<User>');
|
|
123
|
+
expect(out).toContain('encodeURIComponent(id)');
|
|
124
|
+
expect(out).toContain("method: 'GET'");
|
|
125
|
+
expect(out).toContain('return await parseJson<User>(result)');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('POST with JSON body', () => {
|
|
130
|
+
it('sends body with correct Content-Type', () => {
|
|
131
|
+
const root = opRoot([
|
|
132
|
+
opRoute('/users', [
|
|
133
|
+
opOperation('post', {
|
|
134
|
+
sdk: 'createUser',
|
|
135
|
+
request: opRequest('CreateUserInput'),
|
|
136
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
137
|
+
}),
|
|
138
|
+
]),
|
|
139
|
+
]);
|
|
140
|
+
const out = generateSdk(root);
|
|
141
|
+
expect(out).toContain('async createUser(body: CreateUserInput): Promise<User>');
|
|
142
|
+
expect(out).toContain("'Content-Type': 'application/json'");
|
|
143
|
+
expect(out).toContain('JSON.stringify(body, bigIntReplacer)');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('includeInternal flag', () => {
|
|
148
|
+
it('omits internal operations from the SDK by default', () => {
|
|
149
|
+
const root = opRoot([
|
|
150
|
+
opRoute('/public', [opOperation('get', { sdk: 'getPublic', responses: [opResponse(200, 'User')] })]),
|
|
151
|
+
opRoute('/secret', [opOperation('get', { sdk: 'getSecret', responses: [opResponse(200, 'User')] })], undefined, ['internal']),
|
|
152
|
+
]);
|
|
153
|
+
const out = generateSdk(root);
|
|
154
|
+
expect(out).toContain('async getPublic(');
|
|
155
|
+
expect(out).not.toContain('async getSecret(');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('emits internal operations when includeInternal is true', () => {
|
|
159
|
+
const root = opRoot([
|
|
160
|
+
opRoute('/public', [opOperation('get', { sdk: 'getPublic', responses: [opResponse(200, 'User')] })]),
|
|
161
|
+
opRoute('/secret', [opOperation('get', { sdk: 'getSecret', responses: [opResponse(200, 'User')] })], undefined, ['internal']),
|
|
162
|
+
]);
|
|
163
|
+
const out = generateSdk(root, { includeInternal: true });
|
|
164
|
+
expect(out).toContain('async getPublic(');
|
|
165
|
+
expect(out).toContain('async getSecret(');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('hasPublicOperations reports true on an internal-only root when includeInternal is true', () => {
|
|
169
|
+
const root = opRoot([opRoute('/secret', [opOperation('get', { responses: [opResponse(200, 'User')] })], undefined, ['internal'])]);
|
|
170
|
+
expect(hasPublicOperations(root)).toBe(false);
|
|
171
|
+
expect(hasPublicOperations(root, true)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('vendor JSON content types', () => {
|
|
176
|
+
it('emits the literal +json mime on the Content-Type header and serializes as JSON', () => {
|
|
177
|
+
const root = opRoot([
|
|
178
|
+
opRoute('/users', [
|
|
179
|
+
opOperation('post', {
|
|
180
|
+
sdk: 'createUser',
|
|
181
|
+
request: opRequest('CreateUserInput', 'application/vnd.api+json'),
|
|
182
|
+
responses: [opResponse(201, 'User', 'application/vnd.api+json')],
|
|
183
|
+
}),
|
|
184
|
+
]),
|
|
185
|
+
]);
|
|
186
|
+
const out = generateSdk(root);
|
|
187
|
+
expect(out).toContain("'Content-Type': 'application/vnd.api+json'");
|
|
188
|
+
expect(out).toContain('JSON.stringify(body, bigIntReplacer)');
|
|
189
|
+
expect(out).toContain('return await parseJson<User>(result)');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('text and binary content types', () => {
|
|
194
|
+
it('returns string and reads result.text() for text/* responses', () => {
|
|
195
|
+
const root = opRoot([
|
|
196
|
+
opRoute('/notes', [
|
|
197
|
+
opOperation('get', {
|
|
198
|
+
sdk: 'getNote',
|
|
199
|
+
responses: [opResponse(200, 'Note', 'text/plain')],
|
|
200
|
+
}),
|
|
201
|
+
]),
|
|
202
|
+
]);
|
|
203
|
+
const out = generateSdk(root);
|
|
204
|
+
expect(out).toContain('Promise<string>');
|
|
205
|
+
expect(out).toContain('return await result.text()');
|
|
206
|
+
expect(out).not.toContain('parseJson<Note>');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('returns Blob and reads result.blob() for non-text non-JSON responses', () => {
|
|
210
|
+
const root = opRoot([
|
|
211
|
+
opRoute('/files', [
|
|
212
|
+
opOperation('get', {
|
|
213
|
+
sdk: 'downloadFile',
|
|
214
|
+
responses: [opResponse(200, 'Blob', 'application/octet-stream')],
|
|
215
|
+
}),
|
|
216
|
+
]),
|
|
217
|
+
]);
|
|
218
|
+
const out = generateSdk(root);
|
|
219
|
+
expect(out).toContain('Promise<Blob>');
|
|
220
|
+
expect(out).toContain('return await result.blob()');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('takes a string body and forwards it as-is for text/* requests', () => {
|
|
224
|
+
const root = opRoot([
|
|
225
|
+
opRoute('/notes', [
|
|
226
|
+
opOperation('post', {
|
|
227
|
+
sdk: 'putNote',
|
|
228
|
+
request: opRequest('Note', 'text/plain'),
|
|
229
|
+
responses: [opResponse(204)],
|
|
230
|
+
}),
|
|
231
|
+
]),
|
|
232
|
+
]);
|
|
233
|
+
const out = generateSdk(root);
|
|
234
|
+
expect(out).toContain('body: string');
|
|
235
|
+
expect(out).toContain("'Content-Type': 'text/plain'");
|
|
236
|
+
expect(out).toContain('body: body');
|
|
237
|
+
expect(out).not.toContain('JSON.stringify(body');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('takes a Blob/ArrayBuffer body for binary requests', () => {
|
|
241
|
+
const root = opRoot([
|
|
242
|
+
opRoute('/files', [
|
|
243
|
+
opOperation('post', {
|
|
244
|
+
sdk: 'uploadFile',
|
|
245
|
+
request: opRequest('Blob', 'application/octet-stream'),
|
|
246
|
+
responses: [opResponse(204)],
|
|
247
|
+
}),
|
|
248
|
+
]),
|
|
249
|
+
]);
|
|
250
|
+
const out = generateSdk(root);
|
|
251
|
+
expect(out).toContain('body: Blob | ArrayBuffer | Uint8Array | string');
|
|
252
|
+
expect(out).toContain("'Content-Type': 'application/octet-stream'");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('multi-MIME request bodies', () => {
|
|
257
|
+
it('emits an optional contentType option when bodies are structurally equal', () => {
|
|
258
|
+
const root = opRoot([
|
|
259
|
+
opRoute('/auth/token', [
|
|
260
|
+
opOperation('post', {
|
|
261
|
+
sdk: 'requestToken',
|
|
262
|
+
request: opMultiRequest([
|
|
263
|
+
['application/json', 'AuthRequestInput'],
|
|
264
|
+
['application/x-www-form-urlencoded', 'AuthRequestInput'],
|
|
265
|
+
]),
|
|
266
|
+
responses: [opResponse(201, 'AuthToken', 'application/json')],
|
|
267
|
+
}),
|
|
268
|
+
]),
|
|
269
|
+
]);
|
|
270
|
+
const out = generateSdk(root);
|
|
271
|
+
expect(out).toContain("options?: { contentType?: 'application/json' | 'application/x-www-form-urlencoded' }");
|
|
272
|
+
expect(out).toContain("const __contentType = options?.contentType ?? 'application/json'");
|
|
273
|
+
expect(out).toContain('new URLSearchParams(body as unknown as Record<string, string>).toString()');
|
|
274
|
+
expect(out).toContain('JSON.stringify(body, bigIntReplacer)');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('detects FormData at runtime when JSON and multipart both accepted', () => {
|
|
278
|
+
const root = opRoot([
|
|
279
|
+
opRoute('/uploads', [
|
|
280
|
+
opOperation('post', {
|
|
281
|
+
sdk: 'upload',
|
|
282
|
+
request: opMultiRequest([
|
|
283
|
+
['application/json', 'UploadMetaInput'],
|
|
284
|
+
['multipart/form-data', 'UploadFileInput'],
|
|
285
|
+
]),
|
|
286
|
+
responses: [opResponse(201)],
|
|
287
|
+
}),
|
|
288
|
+
]),
|
|
289
|
+
]);
|
|
290
|
+
const out = generateSdk(root);
|
|
291
|
+
expect(out).toContain('body: UploadMetaInput | FormData');
|
|
292
|
+
expect(out).toContain('const __isFormData = body instanceof FormData');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('requires contentType arg when bodies differ and neither is multipart', () => {
|
|
296
|
+
const root = opRoot([
|
|
297
|
+
opRoute('/foo', [
|
|
298
|
+
opOperation('post', {
|
|
299
|
+
sdk: 'foo',
|
|
300
|
+
request: opMultiRequest([
|
|
301
|
+
['application/json', 'AInput'],
|
|
302
|
+
['application/x-www-form-urlencoded', 'BInput'],
|
|
303
|
+
]),
|
|
304
|
+
responses: [opResponse(201)],
|
|
305
|
+
}),
|
|
306
|
+
]),
|
|
307
|
+
]);
|
|
308
|
+
const out = generateSdk(root);
|
|
309
|
+
expect(out).toContain('body: AInput | BInput');
|
|
310
|
+
expect(out).toContain("options: { contentType: 'application/json' | 'application/x-www-form-urlencoded' }");
|
|
311
|
+
expect(out).toContain('const __contentType = options.contentType');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('DELETE with 204', () => {
|
|
316
|
+
it('returns void for empty responses', () => {
|
|
317
|
+
const root = opRoot([
|
|
318
|
+
opRoute(
|
|
319
|
+
'/users/{id}',
|
|
320
|
+
[
|
|
321
|
+
opOperation('delete', {
|
|
322
|
+
sdk: 'deleteUser',
|
|
323
|
+
responses: [opResponse(204)],
|
|
324
|
+
}),
|
|
325
|
+
],
|
|
326
|
+
[opParam('id', scalarType('uuid'))],
|
|
327
|
+
),
|
|
328
|
+
]);
|
|
329
|
+
const out = generateSdk(root);
|
|
330
|
+
expect(out).toContain('async deleteUser(id: string): Promise<void>');
|
|
331
|
+
expect(out).not.toContain('await result.text()');
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('response headers', () => {
|
|
336
|
+
it('returns { data, headers } when response declares headers', () => {
|
|
337
|
+
const root = opRoot([
|
|
338
|
+
opRoute(
|
|
339
|
+
'/transfers/{id}',
|
|
340
|
+
[
|
|
341
|
+
opOperation('get', {
|
|
342
|
+
sdk: 'getTransfer',
|
|
343
|
+
responses: [
|
|
344
|
+
{
|
|
345
|
+
statusCode: 200,
|
|
346
|
+
contentType: 'application/json',
|
|
347
|
+
bodyType: { kind: 'ref', name: 'Transfer' },
|
|
348
|
+
headers: [
|
|
349
|
+
{ name: 'preference-applied', optional: true, type: scalarType('string') },
|
|
350
|
+
{ name: 'etag', optional: false, type: scalarType('string') },
|
|
351
|
+
],
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
}),
|
|
355
|
+
],
|
|
356
|
+
[opParam('id', scalarType('uuid'))],
|
|
357
|
+
),
|
|
358
|
+
]);
|
|
359
|
+
const out = generateSdk(root);
|
|
360
|
+
expect(out).toContain('Promise<{ data: Transfer; headers: { preferenceApplied?: string; etag: string } }>');
|
|
361
|
+
expect(out).toContain("preferenceApplied: result.headers.get('preference-applied') ?? undefined");
|
|
362
|
+
expect(out).toContain("etag: result.headers.get('etag') ?? undefined");
|
|
363
|
+
expect(out).toContain('return { data, headers:');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('returns { headers } for void operations with declared response headers', () => {
|
|
367
|
+
const root = opRoot([
|
|
368
|
+
opRoute(
|
|
369
|
+
'/resources/{id}',
|
|
370
|
+
[
|
|
371
|
+
opOperation('delete', {
|
|
372
|
+
sdk: 'deleteResource',
|
|
373
|
+
responses: [
|
|
374
|
+
{
|
|
375
|
+
statusCode: 204,
|
|
376
|
+
headers: [{ name: 'x-deleted-at', optional: false, type: scalarType('string') }],
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
}),
|
|
380
|
+
],
|
|
381
|
+
[opParam('id', scalarType('uuid'))],
|
|
382
|
+
),
|
|
383
|
+
]);
|
|
384
|
+
const out = generateSdk(root);
|
|
385
|
+
expect(out).toContain('Promise<{ headers: { xDeletedAt: string } }>');
|
|
386
|
+
expect(out).toContain("xDeletedAt: result.headers.get('x-deleted-at') ?? undefined");
|
|
387
|
+
expect(out).toContain('return { headers:');
|
|
388
|
+
expect(out).not.toContain('parseJson<void>');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('preserves plain return type when no response headers are declared', () => {
|
|
392
|
+
const root = opRoot([
|
|
393
|
+
opRoute(
|
|
394
|
+
'/users/{id}',
|
|
395
|
+
[opOperation('get', { sdk: 'getUser', responses: [opResponse(200, 'User', 'application/json')] })],
|
|
396
|
+
[opParam('id', scalarType('uuid'))],
|
|
397
|
+
),
|
|
398
|
+
]);
|
|
399
|
+
const out = generateSdk(root);
|
|
400
|
+
expect(out).toContain('Promise<User>');
|
|
401
|
+
expect(out).not.toContain('result.headers.get');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('query params', () => {
|
|
406
|
+
it('adds query parameter to method signature', () => {
|
|
407
|
+
const root = opRoot([
|
|
408
|
+
opRoute('/users', [
|
|
409
|
+
opOperation('get', {
|
|
410
|
+
sdk: 'listUsers',
|
|
411
|
+
query: [opParam('page', scalarType('int')), opParam('limit', scalarType('int'))],
|
|
412
|
+
responses: [opResponse(200, 'array(User)', 'application/json')],
|
|
413
|
+
}),
|
|
414
|
+
]),
|
|
415
|
+
]);
|
|
416
|
+
const out = generateSdk(root);
|
|
417
|
+
expect(out).toContain('query?: { page?: number; limit?: number }');
|
|
418
|
+
expect(out).toContain('URLSearchParams');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('appends qs directly to URL', () => {
|
|
422
|
+
const root = opRoot([
|
|
423
|
+
opRoute('/users', [
|
|
424
|
+
opOperation('get', {
|
|
425
|
+
sdk: 'listUsers',
|
|
426
|
+
query: [opParam('page', scalarType('int'))],
|
|
427
|
+
responses: [opResponse(200, 'array(User)', 'application/json')],
|
|
428
|
+
}),
|
|
429
|
+
]),
|
|
430
|
+
]);
|
|
431
|
+
const out = generateSdk(root);
|
|
432
|
+
expect(out).toContain('`/users${qs}`');
|
|
433
|
+
expect(out).not.toContain('qs ? `/users');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('buildQueryString returns ? prefix or empty string', () => {
|
|
437
|
+
const root = opRoot([
|
|
438
|
+
opRoute('/users', [
|
|
439
|
+
opOperation('get', {
|
|
440
|
+
sdk: 'listUsers',
|
|
441
|
+
query: [opParam('page', scalarType('int'))],
|
|
442
|
+
responses: [opResponse(200, 'array(User)', 'application/json')],
|
|
443
|
+
}),
|
|
444
|
+
]),
|
|
445
|
+
]);
|
|
446
|
+
const out = generateSdk(root);
|
|
447
|
+
expect(out).toContain("return qs ? `?${qs}` : ''");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('handles array query params with append instead of set', () => {
|
|
451
|
+
const root = opRoot([
|
|
452
|
+
opRoute('/users', [
|
|
453
|
+
opOperation('get', {
|
|
454
|
+
sdk: 'listUsers',
|
|
455
|
+
query: [opParam('status', arrayType(refType('Status'))), opParam('limit', scalarType('int'))],
|
|
456
|
+
responses: [opResponse(200, 'array(User)', 'application/json')],
|
|
457
|
+
}),
|
|
458
|
+
]),
|
|
459
|
+
]);
|
|
460
|
+
const out = generateSdk(root);
|
|
461
|
+
expect(out).toContain('Array.isArray(v)');
|
|
462
|
+
expect(out).toContain('searchParams.append(k, String(item))');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('handles type-ref query params', () => {
|
|
466
|
+
const root = opRoot([
|
|
467
|
+
opRoute('/users', [
|
|
468
|
+
opOperation('get', {
|
|
469
|
+
sdk: 'listUsers',
|
|
470
|
+
query: 'PaginationQuery',
|
|
471
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
472
|
+
}),
|
|
473
|
+
]),
|
|
474
|
+
]);
|
|
475
|
+
const out = generateSdk(root);
|
|
476
|
+
expect(out).toContain('query?: PaginationQuery');
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe('headers', () => {
|
|
481
|
+
it('adds custom headers parameter', () => {
|
|
482
|
+
const root = opRoot([
|
|
483
|
+
opRoute('/users', [
|
|
484
|
+
opOperation('get', {
|
|
485
|
+
sdk: 'listUsers',
|
|
486
|
+
headers: [opParam('x-api-key', scalarType('string'))],
|
|
487
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
488
|
+
}),
|
|
489
|
+
]),
|
|
490
|
+
]);
|
|
491
|
+
const out = generateSdk(root);
|
|
492
|
+
expect(out).toContain("customHeaders?: { 'x-api-key'?: string }");
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('array response', () => {
|
|
497
|
+
it('returns typed array', () => {
|
|
498
|
+
const root = opRoot([
|
|
499
|
+
opRoute('/users', [
|
|
500
|
+
opOperation('get', {
|
|
501
|
+
sdk: 'listUsers',
|
|
502
|
+
responses: [opResponse(200, arrayType(refType('User')), 'application/json')],
|
|
503
|
+
}),
|
|
504
|
+
]),
|
|
505
|
+
]);
|
|
506
|
+
const out = generateSdk(root);
|
|
507
|
+
expect(out).toContain('Promise<User[]>');
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe('format(output=...) response', () => {
|
|
512
|
+
it('return type uses Output variant when response model has output transform', () => {
|
|
513
|
+
const root = opRoot([
|
|
514
|
+
opRoute('/auth/token', [
|
|
515
|
+
opOperation('post', {
|
|
516
|
+
sdk: 'requestToken',
|
|
517
|
+
responses: [opResponse(200, 'AuthToken', 'application/json')],
|
|
518
|
+
}),
|
|
519
|
+
]),
|
|
520
|
+
]);
|
|
521
|
+
const out = generateSdk(root, { modelsWithOutput: new Set(['AuthToken']) });
|
|
522
|
+
expect(out).toContain('Promise<AuthTokenOutput>');
|
|
523
|
+
// The Output variant gets imported alongside the base.
|
|
524
|
+
expect(out).toMatch(/import type \{[^}]*AuthTokenOutput/);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('return type uses Output variant for arrays', () => {
|
|
528
|
+
const root = opRoot([
|
|
529
|
+
opRoute('/auth/tokens', [
|
|
530
|
+
opOperation('get', {
|
|
531
|
+
sdk: 'listTokens',
|
|
532
|
+
responses: [opResponse(200, arrayType(refType('AuthToken')), 'application/json')],
|
|
533
|
+
}),
|
|
534
|
+
]),
|
|
535
|
+
]);
|
|
536
|
+
const out = generateSdk(root, { modelsWithOutput: new Set(['AuthToken']) });
|
|
537
|
+
expect(out).toContain('Promise<AuthTokenOutput[]>');
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe('inline object response', () => {
|
|
542
|
+
it('renders inline TS type', () => {
|
|
543
|
+
const root = opRoot([
|
|
544
|
+
opRoute('/users', [
|
|
545
|
+
opOperation('get', {
|
|
546
|
+
sdk: 'listUsers',
|
|
547
|
+
responses: [
|
|
548
|
+
opResponse(
|
|
549
|
+
200,
|
|
550
|
+
inlineObjectType([field('data', arrayType(refType('User'))), field('total', scalarType('int'))]),
|
|
551
|
+
'application/json',
|
|
552
|
+
),
|
|
553
|
+
],
|
|
554
|
+
}),
|
|
555
|
+
]),
|
|
556
|
+
]);
|
|
557
|
+
const out = generateSdk(root);
|
|
558
|
+
expect(out).toContain('Promise<{ data: User[]; total: number }>');
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe('type imports', () => {
|
|
563
|
+
it('generates type-only imports', () => {
|
|
564
|
+
const root = opRoot([
|
|
565
|
+
opRoute('/users', [
|
|
566
|
+
opOperation('post', {
|
|
567
|
+
sdk: 'createUser',
|
|
568
|
+
request: opRequest('CreateUserInput'),
|
|
569
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
570
|
+
}),
|
|
571
|
+
]),
|
|
572
|
+
]);
|
|
573
|
+
const out = generateSdk(root);
|
|
574
|
+
expect(out).toContain('import type {');
|
|
575
|
+
expect(out).toContain('CreateUserInput');
|
|
576
|
+
expect(out).toContain('User');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('uses modelOutPaths for relative imports', () => {
|
|
580
|
+
const root = opRoot([
|
|
581
|
+
opRoute('/users', [
|
|
582
|
+
opOperation('get', {
|
|
583
|
+
sdk: 'getUser',
|
|
584
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
585
|
+
}),
|
|
586
|
+
]),
|
|
587
|
+
]);
|
|
588
|
+
const out = generateSdk(root, {
|
|
589
|
+
outPath: '/out/clients/users.sdk.ts',
|
|
590
|
+
modelOutPaths: new Map([['User', '/out/types/user.ts']]),
|
|
591
|
+
});
|
|
592
|
+
expect(out).toContain("from '../types/user.js'");
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
describe('multiple routes and operations', () => {
|
|
597
|
+
it('generates all methods on one class', () => {
|
|
598
|
+
const root = opRoot([
|
|
599
|
+
opRoute('/users', [
|
|
600
|
+
opOperation('get', {
|
|
601
|
+
sdk: 'listUsers',
|
|
602
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
603
|
+
}),
|
|
604
|
+
opOperation('post', {
|
|
605
|
+
sdk: 'createUser',
|
|
606
|
+
request: opRequest('CreateUserInput'),
|
|
607
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
608
|
+
}),
|
|
609
|
+
]),
|
|
610
|
+
opRoute(
|
|
611
|
+
'/users/{id}',
|
|
612
|
+
[
|
|
613
|
+
opOperation('get', {
|
|
614
|
+
sdk: 'getUser',
|
|
615
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
616
|
+
}),
|
|
617
|
+
opOperation('delete', {
|
|
618
|
+
sdk: 'deleteUser',
|
|
619
|
+
responses: [opResponse(204)],
|
|
620
|
+
}),
|
|
621
|
+
],
|
|
622
|
+
[opParam('id', scalarType('uuid'))],
|
|
623
|
+
),
|
|
624
|
+
]);
|
|
625
|
+
const out = generateSdk(root);
|
|
626
|
+
expect(out).toContain('async listUsers(');
|
|
627
|
+
expect(out).toContain('async createUser(');
|
|
628
|
+
expect(out).toContain('async getUser(');
|
|
629
|
+
expect(out).toContain('async deleteUser(');
|
|
630
|
+
// All inside one class
|
|
631
|
+
const classMatch = out.match(/export class \w+Client \{/g);
|
|
632
|
+
expect(classMatch).toHaveLength(1);
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe('SdkOptions interface', () => {
|
|
637
|
+
it('emits SdkOptions interface with baseUrl, headers, fetch and SdkError', () => {
|
|
638
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
639
|
+
const out = generateSdk(root);
|
|
640
|
+
expect(out).toContain('export interface SdkOptions');
|
|
641
|
+
expect(out).toContain('baseUrl: string');
|
|
642
|
+
expect(out).toContain('headers?:');
|
|
643
|
+
expect(out).toContain('fetch?: SdkFetch');
|
|
644
|
+
expect(out).toContain('export class SdkError extends Error');
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
describe('requestIdFactory', () => {
|
|
649
|
+
it('emits requestIdFactory on SdkOptions', () => {
|
|
650
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
651
|
+
const out = generateSdk(root);
|
|
652
|
+
expect(out).toContain('requestIdFactory?: () => string');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('does not add per-call options parameter to methods', () => {
|
|
656
|
+
const root = opRoot([
|
|
657
|
+
opRoute('/users', [
|
|
658
|
+
opOperation('get', { sdk: 'listUsers', responses: [opResponse(200, 'User', 'application/json')] }),
|
|
659
|
+
opOperation('post', {
|
|
660
|
+
sdk: 'createUser',
|
|
661
|
+
request: opRequest('CreateUserInput'),
|
|
662
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
663
|
+
}),
|
|
664
|
+
]),
|
|
665
|
+
opRoute(
|
|
666
|
+
'/users/{id}',
|
|
667
|
+
[opOperation('delete', { sdk: 'deleteUser', responses: [opResponse(204)] })],
|
|
668
|
+
[opParam('id', scalarType('uuid'))],
|
|
669
|
+
),
|
|
670
|
+
]);
|
|
671
|
+
const out = generateSdk(root);
|
|
672
|
+
expect(out).toContain('async listUsers(): Promise');
|
|
673
|
+
expect(out).toContain('async createUser(body: CreateUserInput): Promise');
|
|
674
|
+
expect(out).toContain('async deleteUser(id: string): Promise');
|
|
675
|
+
expect(out).not.toContain('SdkCallOptions');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('defaults to crypto.randomUUID and injects X-Request-ID per request', () => {
|
|
679
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
680
|
+
const out = generateSdk(root);
|
|
681
|
+
expect(out).toContain('requestIdFactory ?? (() => crypto.randomUUID())');
|
|
682
|
+
expect(out).toContain("'X-Request-ID': getRequestId()");
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe('fetch usage', () => {
|
|
687
|
+
it('calls this.fetch in methods', () => {
|
|
688
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
689
|
+
const out = generateSdk(root);
|
|
690
|
+
expect(out).toContain('this.fetch(');
|
|
691
|
+
expect(out).toContain('constructor(private fetch: SdkFetch)');
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
describe('SdkOptions import from shared file', () => {
|
|
696
|
+
it('imports SdkOptions when sdkOptionsPath is provided', () => {
|
|
697
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
698
|
+
const out = generateSdk(root, {
|
|
699
|
+
outPath: '/sdk/src/users.client.ts',
|
|
700
|
+
sdkOptionsPath: '/sdk/sdk-options.ts',
|
|
701
|
+
});
|
|
702
|
+
expect(out).toContain("import type { SdkFetch } from '../sdk-options.js'");
|
|
703
|
+
// GET-only: parseJson needed (response body), bigIntReplacer not needed (no request body), SdkError never used in client methods
|
|
704
|
+
expect(out).toContain("import { parseJson } from '../sdk-options.js'");
|
|
705
|
+
expect(out).not.toContain('bigIntReplacer');
|
|
706
|
+
expect(out).not.toContain('SdkError');
|
|
707
|
+
expect(out).not.toContain('export interface SdkOptions');
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('emits inline SdkOptions, SdkError, SdkFetch and createSdkFetch when sdkOptionsPath not provided', () => {
|
|
711
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
712
|
+
const out = generateSdk(root);
|
|
713
|
+
expect(out).toContain('export interface SdkOptions');
|
|
714
|
+
expect(out).toContain('export class SdkError extends Error');
|
|
715
|
+
expect(out).toContain('export type SdkFetch');
|
|
716
|
+
expect(out).toContain('export function createSdkFetch(');
|
|
717
|
+
expect(out).toContain('fetch?: SdkFetch');
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
describe('deriveClientClassName', () => {
|
|
723
|
+
it('derives UsersClient from users.op', () => {
|
|
724
|
+
expect(deriveClientClassName('users.op')).toBe('UsersClient');
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('handles dotted filenames', () => {
|
|
728
|
+
expect(deriveClientClassName('ledger.categories.op')).toBe('LedgerCategoriesClient');
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
describe('deriveClientPropertyName', () => {
|
|
733
|
+
it('derives camelCase property name', () => {
|
|
734
|
+
expect(deriveClientPropertyName('users.op')).toBe('users');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('handles dotted filenames', () => {
|
|
738
|
+
expect(deriveClientPropertyName('ledger.categories.op')).toBe('ledgerCategories');
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
describe('generateSdkOptions', () => {
|
|
743
|
+
it('emits SdkOptions interface, SdkError, SdkFetch and createSdkFetch', () => {
|
|
744
|
+
const out = generateSdkOptions();
|
|
745
|
+
expect(out).toContain('export interface SdkOptions');
|
|
746
|
+
expect(out).toContain('baseUrl: string');
|
|
747
|
+
expect(out).toContain('headers?:');
|
|
748
|
+
expect(out).toContain('fetch?: SdkFetch');
|
|
749
|
+
expect(out).toContain('requestIdFactory?: () => string');
|
|
750
|
+
expect(out).toContain('export class SdkError extends Error');
|
|
751
|
+
expect(out).toContain('public readonly status: number');
|
|
752
|
+
expect(out).toContain('public readonly body: unknown');
|
|
753
|
+
expect(out).toContain('throw new SdkError(');
|
|
754
|
+
expect(out).toContain('export type SdkFetch');
|
|
755
|
+
expect(out).toContain('export function createSdkFetch(options: SdkOptions): SdkFetch');
|
|
756
|
+
expect(out).toContain('requestIdFactory ?? (() => crypto.randomUUID())');
|
|
757
|
+
expect(out).toContain("'X-Request-ID': getRequestId()");
|
|
758
|
+
// SecurityContext/securityHandler removed — auth is handled via headers option
|
|
759
|
+
expect(out).not.toContain('SecurityContext');
|
|
760
|
+
expect(out).not.toContain('securityHandler');
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
describe('generateSdkAggregator', () => {
|
|
765
|
+
it('generates Sdk class wrapping all clients', () => {
|
|
766
|
+
const out = generateSdkAggregator([
|
|
767
|
+
{ className: 'UsersClient', propertyName: 'users', importPath: './src/users.client.js' },
|
|
768
|
+
{ className: 'CategoriesClient', propertyName: 'categories', importPath: './src/categories.client.js' },
|
|
769
|
+
]);
|
|
770
|
+
expect(out).toContain("import type { SdkOptions } from './sdk-options.js'");
|
|
771
|
+
expect(out).toContain("import { createSdkFetch } from './sdk-options.js'");
|
|
772
|
+
expect(out).toContain("import { UsersClient } from './src/users.client.js'");
|
|
773
|
+
expect(out).toContain("import { CategoriesClient } from './src/categories.client.js'");
|
|
774
|
+
expect(out).toContain('export class Sdk {');
|
|
775
|
+
expect(out).toContain('readonly users: UsersClient');
|
|
776
|
+
expect(out).toContain('readonly categories: CategoriesClient');
|
|
777
|
+
expect(out).toContain('constructor(options: SdkOptions)');
|
|
778
|
+
expect(out).toContain('options.fetch ?? createSdkFetch(options)');
|
|
779
|
+
expect(out).toContain('this.users = new UsersClient(sdkFetch)');
|
|
780
|
+
expect(out).toContain('this.categories = new CategoriesClient(sdkFetch)');
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('uses custom sdkOptionsImportPath', () => {
|
|
784
|
+
const out = generateSdkAggregator(
|
|
785
|
+
[{ className: 'UsersClient', propertyName: 'users', importPath: './users.client.js' }],
|
|
786
|
+
'../shared/sdk-options.js',
|
|
787
|
+
);
|
|
788
|
+
expect(out).toContain("from '../shared/sdk-options.js'");
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe('generateSdk — route modifiers', () => {
|
|
793
|
+
it('excludes internal operation from SDK output', () => {
|
|
794
|
+
const root = opRoot([
|
|
795
|
+
opRoute('/users', [
|
|
796
|
+
opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] }),
|
|
797
|
+
opOperation('post', { modifiers: ['internal'], responses: [opResponse(201, 'User', 'application/json')] }),
|
|
798
|
+
]),
|
|
799
|
+
]);
|
|
800
|
+
const out = generateSdk(root);
|
|
801
|
+
expect(out).toContain('getUsers('); // public GET is present
|
|
802
|
+
expect(out).not.toContain('postUsers('); // internal POST is absent
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('excludes all operations when route is internal', () => {
|
|
806
|
+
const root = opRoot([
|
|
807
|
+
opRoute(
|
|
808
|
+
'/admin/users',
|
|
809
|
+
[
|
|
810
|
+
opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] }),
|
|
811
|
+
opOperation('delete', { responses: [opResponse(204)] }),
|
|
812
|
+
],
|
|
813
|
+
undefined,
|
|
814
|
+
['internal'],
|
|
815
|
+
),
|
|
816
|
+
]);
|
|
817
|
+
const out = generateSdk(root);
|
|
818
|
+
expect(out).not.toContain('async getAdminUsers(');
|
|
819
|
+
expect(out).not.toContain('async deleteAdminUsers(');
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('operation modifier overrides route-level internal — operation becomes visible', () => {
|
|
823
|
+
const root = opRoot([
|
|
824
|
+
opRoute(
|
|
825
|
+
'/admin/users',
|
|
826
|
+
[
|
|
827
|
+
opOperation('get', { modifiers: ['deprecated'], responses: [opResponse(200, 'User', 'application/json')] }),
|
|
828
|
+
opOperation('post', { responses: [opResponse(201, 'User', 'application/json')] }),
|
|
829
|
+
],
|
|
830
|
+
undefined,
|
|
831
|
+
['internal'],
|
|
832
|
+
),
|
|
833
|
+
]);
|
|
834
|
+
const out = generateSdk(root);
|
|
835
|
+
// GET has explicit modifiers=['deprecated'] (overrides internal) → included
|
|
836
|
+
expect(out).toContain('async getAdminUsers(');
|
|
837
|
+
expect(out).toContain('/** @deprecated */');
|
|
838
|
+
// POST inherits internal from route → excluded
|
|
839
|
+
expect(out).not.toContain('async postAdminUsers(');
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('adds @deprecated jsdoc for deprecated operation', () => {
|
|
843
|
+
const root = opRoot([
|
|
844
|
+
opRoute('/users', [opOperation('get', { modifiers: ['deprecated'], responses: [opResponse(200, 'User', 'application/json')] })]),
|
|
845
|
+
]);
|
|
846
|
+
const out = generateSdk(root);
|
|
847
|
+
expect(out).toContain('/** @deprecated */');
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('uses op.name as method name when op.sdk is not set', () => {
|
|
851
|
+
const root = opRoot([
|
|
852
|
+
opRoute('/offers', [opOperation('post', { name: 'Create an Offer', responses: [opResponse(201, 'Offer', 'application/json')] })]),
|
|
853
|
+
]);
|
|
854
|
+
const out = generateSdk(root);
|
|
855
|
+
expect(out).toContain('async createAnOffer(');
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('prefers op.sdk over op.name as method name', () => {
|
|
859
|
+
const root = opRoot([
|
|
860
|
+
opRoute('/offers', [
|
|
861
|
+
opOperation('post', { sdk: 'makeOffer', name: 'Create an Offer', responses: [opResponse(201, 'Offer', 'application/json')] }),
|
|
862
|
+
]),
|
|
863
|
+
]);
|
|
864
|
+
const out = generateSdk(root);
|
|
865
|
+
expect(out).toContain('async makeOffer(');
|
|
866
|
+
expect(out).not.toContain('async createAnOffer(');
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('falls back to inferred method name when neither op.sdk nor op.name is set', () => {
|
|
870
|
+
const root = opRoot([opRoute('/offers', [opOperation('post', { responses: [opResponse(201, 'Offer', 'application/json')] })])]);
|
|
871
|
+
const out = generateSdk(root);
|
|
872
|
+
expect(out).toContain('async postOffers(');
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it('adds @name jsdoc tag when op.name is set', () => {
|
|
876
|
+
const root = opRoot([
|
|
877
|
+
opRoute('/offers', [opOperation('post', { name: 'Create an Offer', responses: [opResponse(201, 'Offer', 'application/json')] })]),
|
|
878
|
+
]);
|
|
879
|
+
const out = generateSdk(root);
|
|
880
|
+
expect(out).toContain('/** @name Create an Offer */');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('emits multi-line jsdoc when both description and name are set', () => {
|
|
884
|
+
const root = opRoot([
|
|
885
|
+
opRoute('/offers', [
|
|
886
|
+
opOperation('post', {
|
|
887
|
+
name: 'Create an Offer',
|
|
888
|
+
description: 'Creates a new offer',
|
|
889
|
+
responses: [opResponse(201, 'Offer', 'application/json')],
|
|
890
|
+
}),
|
|
891
|
+
]),
|
|
892
|
+
]);
|
|
893
|
+
const out = generateSdk(root);
|
|
894
|
+
expect(out).toContain('* @name Create an Offer');
|
|
895
|
+
expect(out).toContain('* @description Creates a new offer');
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('emits single-line description jsdoc when only description is set', () => {
|
|
899
|
+
const root = opRoot([
|
|
900
|
+
opRoute('/offers', [
|
|
901
|
+
opOperation('post', { description: 'Creates a new offer', responses: [opResponse(201, 'Offer', 'application/json')] }),
|
|
902
|
+
]),
|
|
903
|
+
]);
|
|
904
|
+
const out = generateSdk(root);
|
|
905
|
+
expect(out).toContain('/** @description Creates a new offer */');
|
|
906
|
+
expect(out).not.toContain('@name');
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
describe('hasPublicOperations', () => {
|
|
911
|
+
it('returns true when at least one operation is not internal', () => {
|
|
912
|
+
const root = opRoot([
|
|
913
|
+
opRoute('/users', [
|
|
914
|
+
opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] }),
|
|
915
|
+
opOperation('post', { modifiers: ['internal'], responses: [opResponse(201, 'User', 'application/json')] }),
|
|
916
|
+
]),
|
|
917
|
+
]);
|
|
918
|
+
expect(hasPublicOperations(root)).toBe(true);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('returns false when all operations are internal via route modifier', () => {
|
|
922
|
+
const root = opRoot([
|
|
923
|
+
opRoute(
|
|
924
|
+
'/admin/users',
|
|
925
|
+
[
|
|
926
|
+
opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] }),
|
|
927
|
+
opOperation('post', { responses: [opResponse(201, 'User', 'application/json')] }),
|
|
928
|
+
],
|
|
929
|
+
undefined,
|
|
930
|
+
['internal'],
|
|
931
|
+
),
|
|
932
|
+
]);
|
|
933
|
+
expect(hasPublicOperations(root)).toBe(false);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('returns false when all operations have explicit internal modifier', () => {
|
|
937
|
+
const root = opRoot([
|
|
938
|
+
opRoute('/users', [opOperation('get', { modifiers: ['internal'], responses: [opResponse(200, 'User', 'application/json')] })]),
|
|
939
|
+
]);
|
|
940
|
+
expect(hasPublicOperations(root)).toBe(false);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('returns true when an operation overrides route-level internal with empty modifiers', () => {
|
|
944
|
+
const root = opRoot([
|
|
945
|
+
opRoute('/admin/users', [opOperation('get', { modifiers: [], responses: [opResponse(200, 'User', 'application/json')] })], undefined, [
|
|
946
|
+
'internal',
|
|
947
|
+
]),
|
|
948
|
+
]);
|
|
949
|
+
expect(hasPublicOperations(root)).toBe(true);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it('does not include types from internal-only operations in SDK output', () => {
|
|
953
|
+
const root = opRoot([
|
|
954
|
+
opRoute('/admin/users', [opOperation('get', { modifiers: ['internal'], responses: [opResponse(200, 'AdminUser', 'application/json')] })]),
|
|
955
|
+
opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })]),
|
|
956
|
+
]);
|
|
957
|
+
const out = generateSdk(root);
|
|
958
|
+
expect(out).toContain('User');
|
|
959
|
+
expect(out).not.toContain('AdminUser');
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
describe('collectPublicTypeNames', () => {
|
|
964
|
+
it('returns types from public ops only', () => {
|
|
965
|
+
const root = opRoot([
|
|
966
|
+
opRoute('/admin', [opOperation('get', { modifiers: ['internal'], responses: [opResponse(200, 'AdminReport', 'application/json')] })]),
|
|
967
|
+
opRoute('/users', [
|
|
968
|
+
opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] }),
|
|
969
|
+
opOperation('post', { modifiers: ['internal'], responses: [opResponse(201, 'InternalAudit', 'application/json')] }),
|
|
970
|
+
]),
|
|
971
|
+
]);
|
|
972
|
+
const types = collectPublicTypeNames(root);
|
|
973
|
+
expect(types.has('User')).toBe(true);
|
|
974
|
+
expect(types.has('AdminReport')).toBe(false);
|
|
975
|
+
expect(types.has('InternalAudit')).toBe(false);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it('returns empty set when all ops are internal', () => {
|
|
979
|
+
const root = opRoot([
|
|
980
|
+
opRoute(
|
|
981
|
+
'/admin',
|
|
982
|
+
[opOperation('get', { modifiers: ['internal'], responses: [opResponse(200, 'AdminReport', 'application/json')] })],
|
|
983
|
+
undefined,
|
|
984
|
+
['internal'],
|
|
985
|
+
),
|
|
986
|
+
]);
|
|
987
|
+
const types = collectPublicTypeNames(root);
|
|
988
|
+
expect(types.size).toBe(0);
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
describe('generateSdk — route-level deprecated cascade', () => {
|
|
993
|
+
it('adds @deprecated jsdoc when route is deprecated and operation has no modifiers', () => {
|
|
994
|
+
const root = opRoot([
|
|
995
|
+
opRoute(
|
|
996
|
+
'/users',
|
|
997
|
+
[
|
|
998
|
+
opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] }),
|
|
999
|
+
opOperation('post', { responses: [opResponse(201, 'User', 'application/json')] }),
|
|
1000
|
+
],
|
|
1001
|
+
undefined,
|
|
1002
|
+
['deprecated'],
|
|
1003
|
+
),
|
|
1004
|
+
]);
|
|
1005
|
+
const out = generateSdk(root);
|
|
1006
|
+
// Both operations inherit deprecated from the route
|
|
1007
|
+
const deprecatedCount = (out.match(/\/\*\* @deprecated \*\//g) ?? []).length;
|
|
1008
|
+
expect(deprecatedCount).toBe(2);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('operation-level modifiers override route-level deprecated', () => {
|
|
1012
|
+
const root = opRoot([
|
|
1013
|
+
opRoute('/users', [opOperation('get', { modifiers: [], responses: [opResponse(200, 'User', 'application/json')] })], undefined, [
|
|
1014
|
+
'deprecated',
|
|
1015
|
+
]),
|
|
1016
|
+
]);
|
|
1017
|
+
const out = generateSdk(root);
|
|
1018
|
+
// GET has explicit empty modifiers — overrides route deprecated → no jsdoc
|
|
1019
|
+
expect(out).not.toContain('/** @deprecated */');
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
describe('generateSdk — json type', () => {
|
|
1024
|
+
it('emits JsonValue type declaration when response uses json scalar', () => {
|
|
1025
|
+
const root = opRoot([opRoute('/data', [opOperation('get', { responses: [opResponse(200, scalarType('json'), 'application/json')] })])]);
|
|
1026
|
+
const out = generateSdk(root);
|
|
1027
|
+
expect(out).toContain('export type JsonValue =');
|
|
1028
|
+
expect(out).toContain('async getData(');
|
|
1029
|
+
expect(out).toContain('Promise<JsonValue>');
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('emits JsonValue type declaration when request body uses json scalar', () => {
|
|
1033
|
+
const root = opRoot([
|
|
1034
|
+
opRoute('/data', [
|
|
1035
|
+
opOperation('post', {
|
|
1036
|
+
request: { bodies: [{ bodyType: scalarType('json'), contentType: 'application/json' }] },
|
|
1037
|
+
responses: [opResponse(201)],
|
|
1038
|
+
}),
|
|
1039
|
+
]),
|
|
1040
|
+
]);
|
|
1041
|
+
const out = generateSdk(root);
|
|
1042
|
+
expect(out).toContain('export type JsonValue =');
|
|
1043
|
+
expect(out).toContain('body: JsonValue');
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('omits JsonValue type declaration when no json scalar used', () => {
|
|
1047
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
1048
|
+
const out = generateSdk(root);
|
|
1049
|
+
expect(out).not.toContain('JsonValue');
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
describe('hasPublicOperations — edge cases', () => {
|
|
1054
|
+
it('returns false for a root with no routes', () => {
|
|
1055
|
+
const root = opRoot([]);
|
|
1056
|
+
expect(hasPublicOperations(root)).toBe(false);
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// ─── renderTsType — type kind coverage ────────────────────────────────────
|
|
1061
|
+
|
|
1062
|
+
describe('renderTsType', () => {
|
|
1063
|
+
describe('scalar mappings', () => {
|
|
1064
|
+
it('maps email, url, uuid to string', () => {
|
|
1065
|
+
expect(renderTsType(scalarType('email'))).toBe('string');
|
|
1066
|
+
expect(renderTsType(scalarType('url'))).toBe('string');
|
|
1067
|
+
expect(renderTsType(scalarType('uuid'))).toBe('string');
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it('maps date and datetime to string', () => {
|
|
1071
|
+
expect(renderTsType(scalarType('date'))).toBe('string');
|
|
1072
|
+
expect(renderTsType(scalarType('datetime'))).toBe('string');
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it('maps bigint to bigint', () => {
|
|
1076
|
+
expect(renderTsType(scalarType('bigint'))).toBe('bigint');
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('maps null to null', () => {
|
|
1080
|
+
expect(renderTsType(scalarType('null'))).toBe('null');
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('maps unknown to unknown', () => {
|
|
1084
|
+
expect(renderTsType(scalarType('unknown'))).toBe('unknown');
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it('maps object to Record<string, unknown>', () => {
|
|
1088
|
+
expect(renderTsType(scalarType('object'))).toBe('Record<string, unknown>');
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('maps binary to Blob', () => {
|
|
1092
|
+
expect(renderTsType(scalarType('binary'))).toBe('Blob');
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('maps json to JsonValue', () => {
|
|
1096
|
+
expect(renderTsType(scalarType('json'))).toBe('JsonValue');
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('renders tuple type', () => {
|
|
1101
|
+
expect(renderTsType(tupleType(scalarType('string'), scalarType('int')))).toBe('[string, number]');
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('renders record type', () => {
|
|
1105
|
+
expect(renderTsType(recordType(scalarType('string'), refType('User')))).toBe('Record<string, User>');
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it('renders inline enum type', () => {
|
|
1109
|
+
expect(renderTsType(enumType('active', 'inactive'))).toBe("'active' | 'inactive'");
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('wraps enum in parens when used as array item', () => {
|
|
1113
|
+
expect(renderTsType(arrayType(enumType('a', 'b')))).toBe("('a' | 'b')[]");
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it('renders string literal type', () => {
|
|
1117
|
+
expect(renderTsType(literalType('draft'))).toBe("'draft'");
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it('renders numeric literal type without quotes', () => {
|
|
1121
|
+
expect(renderTsType(literalType(42))).toBe('42');
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it('renders intersection type', () => {
|
|
1125
|
+
expect(renderTsType({ kind: 'intersection', members: [refType('A'), refType('B')] })).toBe('A & B');
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it('renders lazy type by unwrapping inner type', () => {
|
|
1129
|
+
expect(renderTsType(lazyType(refType('User')))).toBe('User');
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it('renders inline object with optional field', () => {
|
|
1133
|
+
const type = inlineObjectType([field('id', scalarType('uuid')), field('name', scalarType('string'), { optional: true })]);
|
|
1134
|
+
expect(renderTsType(type)).toBe('{ id: string; name?: string }');
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it('wraps union in parens when used as array item', () => {
|
|
1138
|
+
expect(renderTsType(arrayType(unionType(scalarType('string'), scalarType('null'))))).toBe('(string | null)[]');
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it('wraps intersection in parens when used as array item', () => {
|
|
1142
|
+
expect(renderTsType(arrayType({ kind: 'intersection', members: [refType('A'), refType('B')] }))).toBe('(A & B)[]');
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
// ─── renderInputTsType — modelsWithInput substitution ─────────────────────
|
|
1147
|
+
|
|
1148
|
+
describe('renderInputTsType', () => {
|
|
1149
|
+
const withInput = new Set(['User']);
|
|
1150
|
+
|
|
1151
|
+
it('substitutes ref with Input variant when in modelsWithInput', () => {
|
|
1152
|
+
expect(renderInputTsType(refType('User'), withInput)).toBe('UserInput');
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('leaves ref unchanged when not in modelsWithInput', () => {
|
|
1156
|
+
expect(renderInputTsType(refType('Category'), withInput)).toBe('Category');
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it('substitutes ref inside array', () => {
|
|
1160
|
+
expect(renderInputTsType(arrayType(refType('User')), withInput)).toBe('UserInput[]');
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
it('substitutes ref inside union', () => {
|
|
1164
|
+
expect(renderInputTsType(unionType(refType('User'), scalarType('null')), withInput)).toBe('UserInput | null');
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it('substitutes ref inside intersection', () => {
|
|
1168
|
+
expect(renderInputTsType({ kind: 'intersection', members: [refType('User'), refType('Extra')] }, withInput)).toBe('UserInput & Extra');
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it('substitutes ref inside inlineObject field', () => {
|
|
1172
|
+
const type = inlineObjectType([field('user', refType('User'))]);
|
|
1173
|
+
expect(renderInputTsType(type, withInput)).toBe('{ user: UserInput }');
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it('substitutes ref inside lazy', () => {
|
|
1177
|
+
expect(renderInputTsType(lazyType(refType('User')), withInput)).toBe('UserInput');
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
it('falls back to renderTsType when modelsWithInput is empty', () => {
|
|
1181
|
+
expect(renderInputTsType(refType('User'), new Set())).toBe('User');
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
it('falls back to renderTsType when modelsWithInput is undefined', () => {
|
|
1185
|
+
expect(renderInputTsType(refType('User'), undefined)).toBe('User');
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
// ─── buildMethodParams — ParamSource shapes ───────────────────────────────
|
|
1190
|
+
|
|
1191
|
+
describe('generateSdk — path param shapes', () => {
|
|
1192
|
+
it('handles string-typed route params (model ref)', () => {
|
|
1193
|
+
const root = opRoot([
|
|
1194
|
+
opRoute(
|
|
1195
|
+
'/things/{id}',
|
|
1196
|
+
[opOperation('get', { sdk: 'getThing', responses: [opResponse(200, 'Thing', 'application/json')] })],
|
|
1197
|
+
'ThingParams',
|
|
1198
|
+
),
|
|
1199
|
+
]);
|
|
1200
|
+
const out = generateSdk(root);
|
|
1201
|
+
expect(out).toContain('async getThing(params: ThingParams)');
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it('substitutes Input variant for string-typed route params when in modelsWithInput', () => {
|
|
1205
|
+
const root = opRoot([
|
|
1206
|
+
opRoute(
|
|
1207
|
+
'/things/{id}',
|
|
1208
|
+
[opOperation('get', { sdk: 'getThing', responses: [opResponse(200, 'Thing', 'application/json')] })],
|
|
1209
|
+
'ThingParams',
|
|
1210
|
+
),
|
|
1211
|
+
]);
|
|
1212
|
+
const out = generateSdk(root, { modelsWithInput: new Set(['ThingParams']) });
|
|
1213
|
+
expect(out).toContain('params: ThingParamsInput');
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
it('handles ContractTypeNode-typed route params', () => {
|
|
1217
|
+
const root = opRoot([
|
|
1218
|
+
opRoute(
|
|
1219
|
+
'/things/{id}',
|
|
1220
|
+
[opOperation('get', { sdk: 'getThing', responses: [opResponse(200, 'Thing', 'application/json')] })],
|
|
1221
|
+
inlineObjectType([field('id', scalarType('uuid'))]),
|
|
1222
|
+
),
|
|
1223
|
+
]);
|
|
1224
|
+
const out = generateSdk(root);
|
|
1225
|
+
expect(out).toContain('async getThing(params: { id: string })');
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
describe('generateSdk — headers shapes', () => {
|
|
1230
|
+
it('handles string-typed headers (model ref)', () => {
|
|
1231
|
+
const root = opRoot([
|
|
1232
|
+
opRoute('/users', [
|
|
1233
|
+
opOperation('get', { sdk: 'listUsers', headers: 'AuthHeaders', responses: [opResponse(200, 'User', 'application/json')] }),
|
|
1234
|
+
]),
|
|
1235
|
+
]);
|
|
1236
|
+
const out = generateSdk(root);
|
|
1237
|
+
expect(out).toContain('customHeaders?: AuthHeaders');
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it('handles ContractTypeNode-typed headers', () => {
|
|
1241
|
+
const root = opRoot([
|
|
1242
|
+
opRoute('/users', [
|
|
1243
|
+
opOperation('get', {
|
|
1244
|
+
sdk: 'listUsers',
|
|
1245
|
+
headers: inlineObjectType([field('authorization', scalarType('string'))]),
|
|
1246
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
1247
|
+
}),
|
|
1248
|
+
]),
|
|
1249
|
+
]);
|
|
1250
|
+
const out = generateSdk(root);
|
|
1251
|
+
expect(out).toContain('customHeaders?: { authorization: string }');
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it('handles ContractTypeNode-typed query', () => {
|
|
1255
|
+
const root = opRoot([
|
|
1256
|
+
opRoute('/users', [
|
|
1257
|
+
opOperation('get', {
|
|
1258
|
+
sdk: 'listUsers',
|
|
1259
|
+
query: inlineObjectType([field('page', scalarType('int')), field('size', scalarType('int'))]),
|
|
1260
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
1261
|
+
}),
|
|
1262
|
+
]),
|
|
1263
|
+
]);
|
|
1264
|
+
const out = generateSdk(root);
|
|
1265
|
+
expect(out).toContain('query?: { page: number; size: number }');
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
describe('generateSdk — multipart/form-data', () => {
|
|
1270
|
+
it('uses FormData body type and omits Content-Type header', () => {
|
|
1271
|
+
const root = opRoot([
|
|
1272
|
+
opRoute('/uploads', [
|
|
1273
|
+
opOperation('post', {
|
|
1274
|
+
sdk: 'upload',
|
|
1275
|
+
request: opRequest(scalarType('string'), 'multipart/form-data'),
|
|
1276
|
+
responses: [opResponse(201, 'Upload', 'application/json')],
|
|
1277
|
+
}),
|
|
1278
|
+
]),
|
|
1279
|
+
]);
|
|
1280
|
+
const out = generateSdk(root);
|
|
1281
|
+
expect(out).toContain('async upload(body: FormData)');
|
|
1282
|
+
expect(out).toContain('body: body');
|
|
1283
|
+
expect(out).not.toContain("'Content-Type': 'application/json'");
|
|
1284
|
+
expect(out).not.toContain('JSON.stringify');
|
|
1285
|
+
});
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
// ─── generateMethod fetch assembly ────────────────────────────────────────
|
|
1289
|
+
|
|
1290
|
+
describe('generateSdk — fetch call assembly', () => {
|
|
1291
|
+
it('passes headers: customHeaders for GET with headers and no body', () => {
|
|
1292
|
+
const root = opRoot([
|
|
1293
|
+
opRoute('/users', [
|
|
1294
|
+
opOperation('get', {
|
|
1295
|
+
sdk: 'listUsers',
|
|
1296
|
+
headers: [opParam('x-api-key', scalarType('string'))],
|
|
1297
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
1298
|
+
}),
|
|
1299
|
+
]),
|
|
1300
|
+
]);
|
|
1301
|
+
const out = generateSdk(root);
|
|
1302
|
+
expect(out).toContain('headers: customHeaders');
|
|
1303
|
+
expect(out).not.toContain("'Content-Type': 'application/json'");
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it('merges Content-Type and customHeaders for JSON body + headers', () => {
|
|
1307
|
+
const root = opRoot([
|
|
1308
|
+
opRoute('/users', [
|
|
1309
|
+
opOperation('post', {
|
|
1310
|
+
sdk: 'createUser',
|
|
1311
|
+
request: opRequest('CreateUserInput'),
|
|
1312
|
+
headers: [opParam('x-idempotency-key', scalarType('string'))],
|
|
1313
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
1314
|
+
}),
|
|
1315
|
+
]),
|
|
1316
|
+
]);
|
|
1317
|
+
const out = generateSdk(root);
|
|
1318
|
+
expect(out).toContain("'Content-Type': 'application/json', ...customHeaders");
|
|
1319
|
+
expect(out).toContain('JSON.stringify(body, bigIntReplacer)');
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it('emits both URLSearchParams and JSON.stringify for query + body', () => {
|
|
1323
|
+
const root = opRoot([
|
|
1324
|
+
opRoute('/users', [
|
|
1325
|
+
opOperation('post', {
|
|
1326
|
+
sdk: 'searchUsers',
|
|
1327
|
+
query: [opParam('page', scalarType('int'))],
|
|
1328
|
+
request: opRequest('SearchInput'),
|
|
1329
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
1330
|
+
}),
|
|
1331
|
+
]),
|
|
1332
|
+
]);
|
|
1333
|
+
const out = generateSdk(root);
|
|
1334
|
+
expect(out).toContain('URLSearchParams');
|
|
1335
|
+
expect(out).toContain('JSON.stringify(body, bigIntReplacer)');
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
// ─── generateTypeImports ──────────────────────────────────────────────────
|
|
1340
|
+
|
|
1341
|
+
describe('generateSdk — type import paths', () => {
|
|
1342
|
+
it('uses default #modules/ path when no modelOutPaths or template', () => {
|
|
1343
|
+
const root = opRoot(
|
|
1344
|
+
[opRoute('/users', [opOperation('get', { sdk: 'getUser', responses: [opResponse(200, 'User', 'application/json')] })])],
|
|
1345
|
+
'users.op',
|
|
1346
|
+
);
|
|
1347
|
+
const out = generateSdk(root);
|
|
1348
|
+
expect(out).toContain("from '#modules/users/types/index.js'");
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it('substitutes {module} and {base} in typeImportPathTemplate', () => {
|
|
1352
|
+
const root = opRoot(
|
|
1353
|
+
[opRoute('/users', [opOperation('get', { sdk: 'getUser', responses: [opResponse(200, 'User', 'application/json')] })])],
|
|
1354
|
+
'users.op',
|
|
1355
|
+
);
|
|
1356
|
+
const out = generateSdk(root, { typeImportPathTemplate: '@myapp/{module}/types' });
|
|
1357
|
+
expect(out).toContain("from '@myapp/users/types'");
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
it('splits types from different output files into separate import lines', () => {
|
|
1361
|
+
const root = opRoot([
|
|
1362
|
+
opRoute('/users', [
|
|
1363
|
+
opOperation('post', {
|
|
1364
|
+
sdk: 'createUser',
|
|
1365
|
+
request: opRequest('CreateUserInput'),
|
|
1366
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
1367
|
+
}),
|
|
1368
|
+
]),
|
|
1369
|
+
]);
|
|
1370
|
+
const out = generateSdk(root, {
|
|
1371
|
+
outPath: '/out/users.client.ts',
|
|
1372
|
+
modelOutPaths: new Map([
|
|
1373
|
+
['User', '/out/types/user.ts'],
|
|
1374
|
+
['CreateUserInput', '/out/types/create-user-input.ts'],
|
|
1375
|
+
]),
|
|
1376
|
+
});
|
|
1377
|
+
expect(out).toContain("import type { User } from './types/user.js'");
|
|
1378
|
+
expect(out).toContain("import type { CreateUserInput } from './types/create-user-input.js'");
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it('falls back to pascalToDotCase for types not in modelOutPaths', () => {
|
|
1382
|
+
const root = opRoot([
|
|
1383
|
+
opRoute('/users', [
|
|
1384
|
+
opOperation('post', {
|
|
1385
|
+
sdk: 'createUser',
|
|
1386
|
+
request: opRequest('CreateUserInput'),
|
|
1387
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
1388
|
+
}),
|
|
1389
|
+
]),
|
|
1390
|
+
]);
|
|
1391
|
+
const out = generateSdk(root, {
|
|
1392
|
+
outPath: '/out/users.client.ts',
|
|
1393
|
+
modelOutPaths: new Map([['User', '/out/types/user.ts']]),
|
|
1394
|
+
});
|
|
1395
|
+
expect(out).toContain("from './types/user.js'");
|
|
1396
|
+
expect(out).toContain("from './create.user.input.js'");
|
|
1397
|
+
});
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
// ─── generateSdkAggregator — additional cases ─────────────────────────────
|
|
1401
|
+
|
|
1402
|
+
describe('generateSdkAggregator — additional cases', () => {
|
|
1403
|
+
it('uses custom sdkClassName', () => {
|
|
1404
|
+
const out = generateSdkAggregator(
|
|
1405
|
+
[{ className: 'UsersClient', propertyName: 'users', importPath: './users.client.js' }],
|
|
1406
|
+
'./sdk-options.js',
|
|
1407
|
+
'ApiClient',
|
|
1408
|
+
);
|
|
1409
|
+
expect(out).toContain('export class ApiClient {');
|
|
1410
|
+
expect(out).not.toContain('export class Sdk {');
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it('handles empty clients array', () => {
|
|
1414
|
+
const out = generateSdkAggregator([]);
|
|
1415
|
+
expect(out).toContain('export class Sdk {');
|
|
1416
|
+
expect(out).toContain('constructor(options: SdkOptions)');
|
|
1417
|
+
expect(out).not.toContain('readonly ');
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
// ─── collectPublicTypeNames — modelsWithInput ─────────────────────────────
|
|
1422
|
+
|
|
1423
|
+
describe('collectPublicTypeNames — modelsWithInput', () => {
|
|
1424
|
+
it('includes Input variant ref when model is in modelsWithInput and used in request body', () => {
|
|
1425
|
+
const root = opRoot([
|
|
1426
|
+
opRoute('/users', [
|
|
1427
|
+
opOperation('post', {
|
|
1428
|
+
request: opRequest('User'),
|
|
1429
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
1430
|
+
}),
|
|
1431
|
+
]),
|
|
1432
|
+
]);
|
|
1433
|
+
const types = collectPublicTypeNames(root, new Set(['User']));
|
|
1434
|
+
expect(types.has('UserInput')).toBe(true);
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
it('does not include Input variant when model is not in modelsWithInput', () => {
|
|
1438
|
+
const root = opRoot([
|
|
1439
|
+
opRoute('/users', [
|
|
1440
|
+
opOperation('post', {
|
|
1441
|
+
request: opRequest('User'),
|
|
1442
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
1443
|
+
}),
|
|
1444
|
+
]),
|
|
1445
|
+
]);
|
|
1446
|
+
const types = collectPublicTypeNames(root, new Set());
|
|
1447
|
+
expect(types.has('UserInput')).toBe(false);
|
|
1448
|
+
expect(types.has('User')).toBe(true);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it('includes Input variant for string-typed route params', () => {
|
|
1452
|
+
const root = opRoot([
|
|
1453
|
+
opRoute('/things/{id}', [opOperation('get', { responses: [opResponse(200, 'Thing', 'application/json')] })], 'ThingParams'),
|
|
1454
|
+
]);
|
|
1455
|
+
const types = collectPublicTypeNames(root, new Set(['ThingParams']));
|
|
1456
|
+
expect(types.has('ThingParamsInput')).toBe(true);
|
|
1457
|
+
});
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
// ─── sdkNeedsJson — query, headers, path params ───────────────────────────
|
|
1461
|
+
|
|
1462
|
+
describe('generateSdk — json type in query / headers / params', () => {
|
|
1463
|
+
it('emits JsonValue when json scalar used in query params', () => {
|
|
1464
|
+
const root = opRoot([
|
|
1465
|
+
opRoute('/search', [
|
|
1466
|
+
opOperation('get', {
|
|
1467
|
+
query: [opParam('filter', scalarType('json'))],
|
|
1468
|
+
responses: [opResponse(200, 'Result', 'application/json')],
|
|
1469
|
+
}),
|
|
1470
|
+
]),
|
|
1471
|
+
]);
|
|
1472
|
+
const out = generateSdk(root);
|
|
1473
|
+
expect(out).toContain('export type JsonValue =');
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it('emits JsonValue when json scalar used in headers', () => {
|
|
1477
|
+
const root = opRoot([
|
|
1478
|
+
opRoute('/data', [
|
|
1479
|
+
opOperation('get', {
|
|
1480
|
+
headers: [opParam('x-context', scalarType('json'))],
|
|
1481
|
+
responses: [opResponse(200, 'Result', 'application/json')],
|
|
1482
|
+
}),
|
|
1483
|
+
]),
|
|
1484
|
+
]);
|
|
1485
|
+
const out = generateSdk(root);
|
|
1486
|
+
expect(out).toContain('export type JsonValue =');
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it('emits JsonValue when json scalar used in path params', () => {
|
|
1490
|
+
const root = opRoot([
|
|
1491
|
+
opRoute(
|
|
1492
|
+
'/things/{meta}',
|
|
1493
|
+
[opOperation('get', { responses: [opResponse(200, 'Thing', 'application/json')] })],
|
|
1494
|
+
[opParam('meta', scalarType('json'))],
|
|
1495
|
+
),
|
|
1496
|
+
]);
|
|
1497
|
+
const out = generateSdk(root);
|
|
1498
|
+
expect(out).toContain('export type JsonValue =');
|
|
1499
|
+
});
|
|
1500
|
+
});
|