@contractkit/plugin-typescript 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$ci.log +4 -4
- package/.turbo/turbo-test$colon$ci.log +19 -19
- package/CHANGELOG.md +35 -0
- package/README.md +8 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +9 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts +8 -0
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.js +487 -168
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/codegen-mcp.ts +15 -7
- package/src/codegen-operation.ts +243 -167
- package/src/codegen-sdk.ts +274 -49
- package/src/index.ts +1 -1
- package/tests/codegen-operation.test.ts +288 -7
- package/tests/codegen-sdk.test.ts +184 -6
- package/tests/helpers.ts +20 -1
package/src/codegen-sdk.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
OpRootNode,
|
|
3
|
+
OpRouteNode,
|
|
4
|
+
OpOperationNode,
|
|
5
|
+
OpRequestBodyNode,
|
|
6
|
+
OpResponseNode,
|
|
7
|
+
OpResponseBodyNode,
|
|
8
|
+
OpResponseHeaderNode,
|
|
9
|
+
ContractTypeNode,
|
|
10
|
+
ParamSource,
|
|
11
|
+
} from '@contractkit/core';
|
|
12
|
+
import { resolveModifiers, isJsonMime, classifyContentType, observableResponses, thrownResponses } from '@contractkit/core';
|
|
3
13
|
import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
4
14
|
import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
|
|
5
15
|
import { bodyTypesStructurallyEqual } from './codegen-operation.js';
|
|
@@ -128,16 +138,17 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
128
138
|
if (sdkNeedsBigIntReplacer(root, includeInternal)) valueImports.push('bigIntReplacer');
|
|
129
139
|
if (sdkNeedsBigIntReviver(root, includeInternal)) valueImports.push('parseJson');
|
|
130
140
|
if (sdkNeedsQueryString(root, includeInternal)) valueImports.push('buildQueryString');
|
|
141
|
+
if (sdkNeedsReadContentType(root, includeInternal)) valueImports.push('readContentType');
|
|
131
142
|
if (valueImports.length > 0) {
|
|
132
143
|
lines.push(`import { ${valueImports.join(', ')} } from '${rel}';`);
|
|
133
144
|
}
|
|
134
145
|
} else {
|
|
135
146
|
lines.push('');
|
|
136
|
-
lines.push('export class SdkError extends Error {');
|
|
147
|
+
lines.push('export class SdkError<TBody = unknown> extends Error {');
|
|
137
148
|
lines.push(' constructor(');
|
|
138
149
|
lines.push(' public readonly status: number,');
|
|
139
150
|
lines.push(' public readonly statusText: string,');
|
|
140
|
-
lines.push(' public readonly body:
|
|
151
|
+
lines.push(' public readonly body: TBody,');
|
|
141
152
|
lines.push(' public readonly headers: Headers,');
|
|
142
153
|
lines.push(' ) {');
|
|
143
154
|
lines.push(' super(`${status} ${statusText}`);');
|
|
@@ -145,7 +156,16 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
145
156
|
lines.push(' }');
|
|
146
157
|
lines.push('}');
|
|
147
158
|
lines.push('');
|
|
148
|
-
lines.push('export
|
|
159
|
+
lines.push('export interface SdkRequestInit extends RequestInit {');
|
|
160
|
+
lines.push(' /**');
|
|
161
|
+
lines.push(' * Statuses this operation declares as values rather than errors — a 304 from');
|
|
162
|
+
lines.push(' * conditional-GET middleware, or an error status the service returns deliberately.');
|
|
163
|
+
lines.push(' * Anything else at or above 400 still throws SdkError.');
|
|
164
|
+
lines.push(' */');
|
|
165
|
+
lines.push(' expectStatuses?: number[];');
|
|
166
|
+
lines.push('}');
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push('export type SdkFetch = (url: string, init: SdkRequestInit) => Promise<Response>;');
|
|
149
169
|
lines.push('');
|
|
150
170
|
lines.push('export interface SdkOptions {');
|
|
151
171
|
lines.push(' baseUrl: string;');
|
|
@@ -155,9 +175,13 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
155
175
|
lines.push(' requestIdFactory?: () => string;');
|
|
156
176
|
lines.push('}');
|
|
157
177
|
lines.push('');
|
|
178
|
+
lines.push('export function readContentType(res: Response): string {');
|
|
179
|
+
lines.push(" return res.headers.get('content-type')?.split(';')[0]?.trim() ?? '';");
|
|
180
|
+
lines.push('}');
|
|
181
|
+
lines.push('');
|
|
158
182
|
lines.push('export function createSdkFetch(options: SdkOptions): SdkFetch {');
|
|
159
183
|
lines.push(' const getRequestId = options.requestIdFactory ?? (() => crypto.randomUUID());');
|
|
160
|
-
lines.push(' return async (url: string, init:
|
|
184
|
+
lines.push(' return async (url: string, init: SdkRequestInit): Promise<Response> => {');
|
|
161
185
|
lines.push(" const baseHeaders = typeof options.headers === 'function'");
|
|
162
186
|
lines.push(' ? await options.headers()');
|
|
163
187
|
lines.push(' : options.headers ?? {};');
|
|
@@ -165,7 +189,7 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
165
189
|
lines.push(' ...init,');
|
|
166
190
|
lines.push(" headers: { ...baseHeaders, 'X-Request-ID': getRequestId(), ...init.headers as Record<string, string> },");
|
|
167
191
|
lines.push(' });');
|
|
168
|
-
lines.push(' if (!res.ok) {');
|
|
192
|
+
lines.push(' if (!res.ok && !(init.expectStatuses ?? []).includes(res.status)) {');
|
|
169
193
|
lines.push(' const text = await res.text();');
|
|
170
194
|
lines.push(' let body: unknown;');
|
|
171
195
|
lines.push(' try { body = JSON.parse(text); } catch { body = text; }');
|
|
@@ -199,6 +223,12 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
|
|
|
199
223
|
|
|
200
224
|
lines.push('');
|
|
201
225
|
|
|
226
|
+
const errorAliases = generateErrorBodyAliases(root, options);
|
|
227
|
+
if (errorAliases.length > 0) {
|
|
228
|
+
lines.push(...errorAliases);
|
|
229
|
+
lines.push('');
|
|
230
|
+
}
|
|
231
|
+
|
|
202
232
|
// Client class
|
|
203
233
|
lines.push('/**');
|
|
204
234
|
const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
|
|
@@ -266,29 +296,49 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
|
|
|
266
296
|
|
|
267
297
|
// Determine return type — response side uses Output variants (post-transform wire shape).
|
|
268
298
|
// For non-JSON responses the schema is ignored: text/* is read as string, binary as Blob.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
299
|
+
//
|
|
300
|
+
// `observableResponses` is the client-side mirror of the router's `emittedResponses`: it also
|
|
301
|
+
// covers statuses the service never writes but a client can still receive, such as a 304 from
|
|
302
|
+
// conditional-GET middleware. Anything left over reaches the caller as a thrown SdkError.
|
|
303
|
+
const observable = observableResponses(op);
|
|
304
|
+
const thrown = thrownResponses(op);
|
|
305
|
+
const isMultiStatus = observable.length > 1;
|
|
306
|
+
const primaryResponse = observable[0];
|
|
307
|
+
const primaryBodies = primaryResponse ? primaryResponse.bodies : [];
|
|
308
|
+
const isVoid = primaryBodies.length === 0;
|
|
279
309
|
const respHeaders = primaryResponse?.headers ?? [];
|
|
280
310
|
const hasRespHeaders = respHeaders.length > 0;
|
|
281
|
-
const headersShape = hasRespHeaders
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
311
|
+
const headersShape = hasRespHeaders ? renderSdkHeadersShape(respHeaders, modelsWithOutput) : '';
|
|
312
|
+
|
|
313
|
+
// A union of more than one member is broken across lines — a four-status operation runs to
|
|
314
|
+
// several hundred characters on one line otherwise.
|
|
315
|
+
let returnMembers: string[] | undefined;
|
|
316
|
+
let returnType = '';
|
|
317
|
+
if (isMultiStatus) {
|
|
318
|
+
returnMembers = observable.flatMap(r => sdkResponseMembers(r, modelsWithOutput, true));
|
|
319
|
+
} else if (primaryBodies.length > 1) {
|
|
320
|
+
returnMembers = sdkResponseMembers(primaryResponse!, modelsWithOutput, false);
|
|
321
|
+
} else {
|
|
322
|
+
const dataType = isVoid ? 'void' : sdkDataType(primaryBodies[0]!, modelsWithOutput);
|
|
323
|
+
returnType = hasRespHeaders ? (isVoid ? `{ headers: ${headersShape} }` : `{ data: ${dataType}; headers: ${headersShape} }`) : dataType;
|
|
324
|
+
}
|
|
325
|
+
if (returnMembers?.length === 1) {
|
|
326
|
+
returnType = returnMembers[0]!;
|
|
327
|
+
returnMembers = undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Statuses the shared fetch would otherwise reject. All-2xx operations pass nothing, so the
|
|
331
|
+
// overwhelmingly common case keeps its existing call shape.
|
|
332
|
+
const expectStatuses = observable.filter(r => r.statusCode < 200 || r.statusCode >= 300).map(r => r.statusCode);
|
|
285
333
|
|
|
286
334
|
// JSDoc
|
|
287
335
|
const desc = op.description ?? route.description;
|
|
288
|
-
|
|
336
|
+
const errorBodyName = thrown.some(r => r.bodies.length > 0) ? errorBodyTypeName(route, op) : undefined;
|
|
337
|
+
if (op.name || desc || errorBodyName) {
|
|
289
338
|
const tags: string[] = [];
|
|
290
339
|
if (op.name) tags.push(`@name ${op.name}`);
|
|
291
340
|
if (desc) tags.push(`@description ${desc}`);
|
|
341
|
+
if (errorBodyName) tags.push(`@throws {SdkError<${errorBodyName}>} on ${thrown.map(r => r.statusCode).join(', ')}`);
|
|
292
342
|
const contentLines = tags.flatMap(t => escapeJsDocLines(t));
|
|
293
343
|
if (contentLines.length === 1) {
|
|
294
344
|
lines.push(` /** ${contentLines[0]} */`);
|
|
@@ -299,7 +349,13 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
|
|
|
299
349
|
}
|
|
300
350
|
}
|
|
301
351
|
|
|
302
|
-
|
|
352
|
+
if (returnMembers) {
|
|
353
|
+
lines.push(` async ${methodName}(${paramStr}): Promise<`);
|
|
354
|
+
for (const member of returnMembers) lines.push(` | ${member}`);
|
|
355
|
+
lines.push(` > {`);
|
|
356
|
+
} else {
|
|
357
|
+
lines.push(` async ${methodName}(${paramStr}): Promise<${returnType}> {`);
|
|
358
|
+
}
|
|
303
359
|
|
|
304
360
|
// Build URL with path params
|
|
305
361
|
const urlExpr = buildUrlExpression(route.path, route.params);
|
|
@@ -378,7 +434,10 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
|
|
|
378
434
|
}
|
|
379
435
|
}
|
|
380
436
|
|
|
381
|
-
|
|
437
|
+
if (expectStatuses.length > 0) fetchArgs.push(`expectStatuses: [${expectStatuses.join(', ')}]`);
|
|
438
|
+
|
|
439
|
+
const needsResult = isMultiStatus || !isVoid || hasRespHeaders;
|
|
440
|
+
const resultPrefix = needsResult ? 'const result = ' : '';
|
|
382
441
|
if (fetchArgs.length === 2 && !hasBody && !hasOpHeaders && !hasQuery) {
|
|
383
442
|
// Simple case — inline
|
|
384
443
|
lines.push(` ${resultPrefix}await this.fetch(\`${fetchUrl}\`, { method: '${httpMethod}' });`);
|
|
@@ -390,21 +449,30 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
|
|
|
390
449
|
lines.push(` });`);
|
|
391
450
|
}
|
|
392
451
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
.
|
|
452
|
+
if (isMultiStatus) {
|
|
453
|
+
// The status is only known at runtime, so the caller gets a union to narrow. The lowest
|
|
454
|
+
// status is the default branch, which keeps the function exhaustively returning.
|
|
455
|
+
const [fallback, ...rest] = observable;
|
|
456
|
+
lines.push(` switch (result.status) {`);
|
|
457
|
+
for (const resp of rest) {
|
|
458
|
+
lines.push(` case ${resp.statusCode}:`);
|
|
459
|
+
lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true));
|
|
460
|
+
}
|
|
461
|
+
lines.push(` default:`);
|
|
462
|
+
lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true));
|
|
463
|
+
lines.push(` }`);
|
|
464
|
+
} else if (primaryBodies.length > 1) {
|
|
465
|
+
lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false));
|
|
466
|
+
} else if (hasRespHeaders) {
|
|
467
|
+
const headerEntries = sdkHeaderEntries(respHeaders);
|
|
400
468
|
if (isVoid) {
|
|
401
469
|
lines.push(` return { headers: { ${headerEntries} } };`);
|
|
402
470
|
} else {
|
|
403
|
-
lines.push(` const data = ${
|
|
471
|
+
lines.push(` const data = ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput)};`);
|
|
404
472
|
lines.push(` return { data, headers: { ${headerEntries} } };`);
|
|
405
473
|
}
|
|
406
474
|
} else if (!isVoid) {
|
|
407
|
-
lines.push(` return ${
|
|
475
|
+
lines.push(` return ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput)};`);
|
|
408
476
|
}
|
|
409
477
|
|
|
410
478
|
lines.push(' }');
|
|
@@ -412,6 +480,130 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
|
|
|
412
480
|
return lines;
|
|
413
481
|
}
|
|
414
482
|
|
|
483
|
+
// ─── Response shapes ──────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
/** The TypeScript type a client sees for one response body. */
|
|
486
|
+
function sdkDataType(body: OpResponseBodyNode, modelsWithOutput?: Set<string>): string {
|
|
487
|
+
const category = classifyContentType(body.contentType);
|
|
488
|
+
if (category === 'text') return 'string';
|
|
489
|
+
if (category === 'binary') return 'Blob';
|
|
490
|
+
return renderOutputTsType(body.bodyType, modelsWithOutput);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** How a client reads one response body off the `Response`. */
|
|
494
|
+
function sdkReadExpr(body: OpResponseBodyNode, modelsWithOutput?: Set<string>): string {
|
|
495
|
+
const category = classifyContentType(body.contentType);
|
|
496
|
+
if (category === 'text') return 'await result.text()';
|
|
497
|
+
if (category === 'binary') return 'await result.blob()';
|
|
498
|
+
return `await parseJson<${renderOutputTsType(body.bodyType, modelsWithOutput)}>(result)`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function renderSdkHeadersShape(headers: OpResponseHeaderNode[], modelsWithOutput?: Set<string>): string {
|
|
502
|
+
const fields = headers.map(
|
|
503
|
+
h => `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, modelsWithOutput)}`,
|
|
504
|
+
);
|
|
505
|
+
return `{ ${fields.join('; ')} }`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function sdkHeaderEntries(headers: OpResponseHeaderNode[]): string {
|
|
509
|
+
return headers.map(h => `${quoteKey(headerNameToProperty(h.name))}: result.headers.get('${h.name}') ?? undefined`).join(', ');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Render one response as the members of the client's return union — the mirror of the router's
|
|
514
|
+
* service-result members, with `data` in place of `body`.
|
|
515
|
+
*
|
|
516
|
+
* Collapses to a single member with a union of mime literals when every declared mime yields the
|
|
517
|
+
* same data type; otherwise one member per mime, so `contentType` and `data` stay correlated.
|
|
518
|
+
*/
|
|
519
|
+
function sdkResponseMembers(resp: OpResponseNode, modelsWithOutput: Set<string> | undefined, includeStatus: boolean): string[] {
|
|
520
|
+
const bodies = resp.bodies;
|
|
521
|
+
const headers = resp.headers ?? [];
|
|
522
|
+
const leading = includeStatus ? [`status: ${resp.statusCode}`] : [];
|
|
523
|
+
const trailing = headers.length > 0 ? [`headers: ${renderSdkHeadersShape(headers, modelsWithOutput)}`] : [];
|
|
524
|
+
|
|
525
|
+
if (bodies.length === 0) {
|
|
526
|
+
return [`{ ${[...leading, ...trailing].join('; ')} }`];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const dataTypes = bodies.map(b => sdkDataType(b, modelsWithOutput));
|
|
530
|
+
if (dataTypes.every(t => t === dataTypes[0])) {
|
|
531
|
+
const contentType = bodies.map(b => `'${b.contentType}'`).join(' | ');
|
|
532
|
+
return [`{ ${[...leading, `contentType: ${contentType}`, `data: ${dataTypes[0]}`, ...trailing].join('; ')} }`];
|
|
533
|
+
}
|
|
534
|
+
return bodies.map((b, i) => `{ ${[...leading, `contentType: '${b.contentType}'`, `data: ${dataTypes[i]}`, ...trailing].join('; ')} }`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The `return` statement(s) that build one response's member of the return union. */
|
|
538
|
+
function sdkReturnLines(resp: OpResponseNode, modelsWithOutput: Set<string> | undefined, indent: string, includeStatus: boolean): string[] {
|
|
539
|
+
const bodies = resp.bodies;
|
|
540
|
+
const headers = resp.headers ?? [];
|
|
541
|
+
const leading = includeStatus ? [`status: ${resp.statusCode}`] : [];
|
|
542
|
+
const trailing = headers.length > 0 ? [`headers: { ${sdkHeaderEntries(headers)} }`] : [];
|
|
543
|
+
|
|
544
|
+
if (bodies.length === 0) {
|
|
545
|
+
return [`${indent}return { ${[...leading, ...trailing].join(', ')} };`];
|
|
546
|
+
}
|
|
547
|
+
if (bodies.length === 1) {
|
|
548
|
+
const fields = [...leading, `contentType: '${bodies[0]!.contentType}'`, `data: ${sdkReadExpr(bodies[0]!, modelsWithOutput)}`, ...trailing];
|
|
549
|
+
return [`${indent}return { ${fields.join(', ')} };`];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const dataTypes = bodies.map(b => sdkDataType(b, modelsWithOutput));
|
|
553
|
+
if (dataTypes.every(t => t === dataTypes[0])) {
|
|
554
|
+
// Every mime reads the same way, so only the label has to come off the wire.
|
|
555
|
+
const cast = bodies.map(b => `'${b.contentType}'`).join(' | ');
|
|
556
|
+
const fields = [...leading, `contentType: readContentType(result) as ${cast}`, `data: ${sdkReadExpr(bodies[0]!, modelsWithOutput)}`, ...trailing];
|
|
557
|
+
return [`${indent}return { ${fields.join(', ')} };`];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// The mimes read differently, so the client has to dispatch on what actually came back.
|
|
561
|
+
const lines = [`${indent}switch (readContentType(result)) {`];
|
|
562
|
+
for (const body of bodies.slice(1)) {
|
|
563
|
+
const fields = [...leading, `contentType: '${body.contentType}'`, `data: ${sdkReadExpr(body, modelsWithOutput)}`, ...trailing];
|
|
564
|
+
lines.push(`${indent} case '${body.contentType}':`);
|
|
565
|
+
lines.push(`${indent} return { ${fields.join(', ')} };`);
|
|
566
|
+
}
|
|
567
|
+
const first = bodies[0]!;
|
|
568
|
+
const fallbackFields = [...leading, `contentType: '${first.contentType}'`, `data: ${sdkReadExpr(first, modelsWithOutput)}`, ...trailing];
|
|
569
|
+
lines.push(`${indent} default:`);
|
|
570
|
+
lines.push(`${indent} return { ${fallbackFields.join(', ')} };`);
|
|
571
|
+
lines.push(`${indent}}`);
|
|
572
|
+
return lines;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ─── Error body typing ────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
function errorBodyTypeName(route: OpRouteNode, op: OpOperationNode): string {
|
|
578
|
+
const method = deriveMethodName(op, route);
|
|
579
|
+
return `${method.charAt(0).toUpperCase()}${method.slice(1)}ErrorBody`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Module-level `…ErrorBody` aliases for every operation whose thrown statuses declare a body.
|
|
584
|
+
*
|
|
585
|
+
* TypeScript cannot type a `throw`, so the alias plus the method's `@throws` tag is as far as the
|
|
586
|
+
* error contract can be carried: it gives callers something to narrow `SdkError.body` to instead
|
|
587
|
+
* of leaving them with `unknown`.
|
|
588
|
+
*/
|
|
589
|
+
export function generateErrorBodyAliases(root: OpRootNode, options: SdkCodegenOptions): string[] {
|
|
590
|
+
const includeInternal = options.includeInternal ?? false;
|
|
591
|
+
const lines: string[] = [];
|
|
592
|
+
for (const route of root.routes) {
|
|
593
|
+
for (const op of route.operations) {
|
|
594
|
+
const mods = resolveModifiers(route, op);
|
|
595
|
+
if (!includeInternal && mods.includes('internal')) continue;
|
|
596
|
+
const types = new Set<string>();
|
|
597
|
+
for (const resp of thrownResponses(op)) {
|
|
598
|
+
for (const body of resp.bodies) types.add(sdkDataType(body, options.modelsWithOutput));
|
|
599
|
+
}
|
|
600
|
+
if (types.size === 0) continue;
|
|
601
|
+
lines.push(`export type ${errorBodyTypeName(route, op)} = ${[...types].join(' | ')};`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return lines;
|
|
605
|
+
}
|
|
606
|
+
|
|
415
607
|
// ─── URL building ─────────────────────────────────────────────────────────
|
|
416
608
|
|
|
417
609
|
function buildUrlExpression(path: string, _?: ParamSource): string {
|
|
@@ -632,9 +824,9 @@ function collectTypes(root: OpRootNode, modelsWithInput?: Set<string>, modelsWit
|
|
|
632
824
|
}
|
|
633
825
|
}
|
|
634
826
|
for (const resp of op.responses) {
|
|
635
|
-
|
|
636
|
-
collectTypeNodeRefs(
|
|
637
|
-
collectOutputTypeNodeRefs(
|
|
827
|
+
for (const body of resp.bodies) {
|
|
828
|
+
collectTypeNodeRefs(body.bodyType, types);
|
|
829
|
+
collectOutputTypeNodeRefs(body.bodyType, types, modelsWithOutput);
|
|
638
830
|
}
|
|
639
831
|
if (resp.headers) {
|
|
640
832
|
for (const h of resp.headers) {
|
|
@@ -738,6 +930,20 @@ function sdkNeedsQueryString(root: OpRootNode, includeInternal = false): boolean
|
|
|
738
930
|
return false;
|
|
739
931
|
}
|
|
740
932
|
|
|
933
|
+
/**
|
|
934
|
+
* True if any emitted operation has a status declaring several mimes, so the client has to read
|
|
935
|
+
* the actual content type off the response to know which it got.
|
|
936
|
+
*/
|
|
937
|
+
function sdkNeedsReadContentType(root: OpRootNode, includeInternal = false): boolean {
|
|
938
|
+
for (const route of root.routes) {
|
|
939
|
+
for (const op of route.operations) {
|
|
940
|
+
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
941
|
+
if (observableResponses(op).some(r => r.bodies.length > 1)) return true;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
|
|
741
947
|
/** True if any emitted operation serializes a JSON request body (uses bigIntReplacer). */
|
|
742
948
|
function sdkNeedsBigIntReplacer(root: OpRootNode, includeInternal = false): boolean {
|
|
743
949
|
for (const route of root.routes) {
|
|
@@ -754,13 +960,8 @@ function sdkNeedsBigIntReviver(root: OpRootNode, includeInternal = false): boole
|
|
|
754
960
|
for (const route of root.routes) {
|
|
755
961
|
for (const op of route.operations) {
|
|
756
962
|
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
if (!r.bodyType) return false;
|
|
760
|
-
// Only JSON-shaped responses use parseJson — text/binary read raw.
|
|
761
|
-
return !r.contentType || classifyContentType(r.contentType) === 'json';
|
|
762
|
-
})
|
|
763
|
-
) {
|
|
963
|
+
// Only JSON-shaped responses use parseJson — text/binary read raw.
|
|
964
|
+
if (op.responses.some(r => r.bodies.some(b => classifyContentType(b.contentType) === 'json'))) {
|
|
764
965
|
return true;
|
|
765
966
|
}
|
|
766
967
|
}
|
|
@@ -779,7 +980,7 @@ function sdkNeedsJson(root: OpRootNode, includeInternal = false): boolean {
|
|
|
779
980
|
};
|
|
780
981
|
if (
|
|
781
982
|
!!op.request?.bodies.some(b => typeNeedsScalar(b.bodyType, 'json')) ||
|
|
782
|
-
op.responses.some(r => r.
|
|
983
|
+
op.responses.some(r => r.bodies.some(b => typeNeedsScalar(b.bodyType, 'json'))) ||
|
|
783
984
|
check(op.query) ||
|
|
784
985
|
check(op.headers) ||
|
|
785
986
|
check(route.params)
|
|
@@ -882,11 +1083,11 @@ function deriveTypeImportPath(file: string, template?: string): string {
|
|
|
882
1083
|
/** Generate the shared SdkOptions interface file. */
|
|
883
1084
|
export function generateSdkOptions(): string {
|
|
884
1085
|
return [
|
|
885
|
-
'export class SdkError extends Error {',
|
|
1086
|
+
'export class SdkError<TBody = unknown> extends Error {',
|
|
886
1087
|
' constructor(',
|
|
887
1088
|
' public readonly status: number,',
|
|
888
1089
|
' public readonly statusText: string,',
|
|
889
|
-
' public readonly body:
|
|
1090
|
+
' public readonly body: TBody,',
|
|
890
1091
|
' public readonly headers: Headers,',
|
|
891
1092
|
' ) {',
|
|
892
1093
|
' super(`${status} ${statusText}`);',
|
|
@@ -894,7 +1095,16 @@ export function generateSdkOptions(): string {
|
|
|
894
1095
|
' }',
|
|
895
1096
|
'}',
|
|
896
1097
|
'',
|
|
897
|
-
'export
|
|
1098
|
+
'export interface SdkRequestInit extends RequestInit {',
|
|
1099
|
+
' /**',
|
|
1100
|
+
' * Statuses this operation declares as values rather than errors — a 304 from',
|
|
1101
|
+
' * conditional-GET middleware, or an error status the service returns deliberately.',
|
|
1102
|
+
' * Anything else at or above 400 still throws SdkError.',
|
|
1103
|
+
' */',
|
|
1104
|
+
' expectStatuses?: number[];',
|
|
1105
|
+
'}',
|
|
1106
|
+
'',
|
|
1107
|
+
'export type SdkFetch = (url: string, init: SdkRequestInit) => Promise<Response>;',
|
|
898
1108
|
'',
|
|
899
1109
|
'export interface SdkOptions {',
|
|
900
1110
|
' baseUrl: string;',
|
|
@@ -920,9 +1130,13 @@ export function generateSdkOptions(): string {
|
|
|
920
1130
|
'',
|
|
921
1131
|
JSON_VALUE_TYPE_DECL,
|
|
922
1132
|
'',
|
|
1133
|
+
'export function readContentType(res: Response): string {',
|
|
1134
|
+
" return res.headers.get('content-type')?.split(';')[0]?.trim() ?? '';",
|
|
1135
|
+
'}',
|
|
1136
|
+
'',
|
|
923
1137
|
'export function createSdkFetch(options: SdkOptions): SdkFetch {',
|
|
924
1138
|
' const getRequestId = options.requestIdFactory ?? (() => crypto.randomUUID());',
|
|
925
|
-
' return async (url: string, init:
|
|
1139
|
+
' return async (url: string, init: SdkRequestInit): Promise<Response> => {',
|
|
926
1140
|
" const baseHeaders = typeof options.headers === 'function'",
|
|
927
1141
|
' ? await options.headers()',
|
|
928
1142
|
' : options.headers ?? {};',
|
|
@@ -930,7 +1144,7 @@ export function generateSdkOptions(): string {
|
|
|
930
1144
|
' ...init,',
|
|
931
1145
|
" headers: { ...baseHeaders, 'X-Request-ID': getRequestId(), ...init.headers as Record<string, string> },",
|
|
932
1146
|
' });',
|
|
933
|
-
' if (!res.ok) {',
|
|
1147
|
+
' if (!res.ok && !(init.expectStatuses ?? []).includes(res.status)) {',
|
|
934
1148
|
' const text = await res.text();',
|
|
935
1149
|
' let body: unknown;',
|
|
936
1150
|
' try { body = JSON.parse(text); } catch { body = text; }',
|
|
@@ -1116,6 +1330,8 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1116
1330
|
|
|
1117
1331
|
// ── Merge inputs across all inline files ────────────────────────────────
|
|
1118
1332
|
const collectedMethodLines: string[] = [];
|
|
1333
|
+
// Aliases are keyed off method names, which already collide-check below, so a Set is enough.
|
|
1334
|
+
const collectedErrorAliases = new Set<string>();
|
|
1119
1335
|
const seenMethods = new Set<string>();
|
|
1120
1336
|
const typesByImportPath = new Map<string, Set<string>>();
|
|
1121
1337
|
const unresolvedTypes = new Set<string>();
|
|
@@ -1123,6 +1339,7 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1123
1339
|
let needsBigIntReplacer = false;
|
|
1124
1340
|
let needsBigIntReviver = false;
|
|
1125
1341
|
let needsQueryString = false;
|
|
1342
|
+
let needsReadContentType = false;
|
|
1126
1343
|
|
|
1127
1344
|
for (const inline of inlineFiles) {
|
|
1128
1345
|
const includeInternal = inline.codegenOptions.includeInternal ?? false;
|
|
@@ -1136,10 +1353,12 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1136
1353
|
seenMethods.add(name);
|
|
1137
1354
|
}
|
|
1138
1355
|
collectedMethodLines.push(...methodLines);
|
|
1356
|
+
for (const alias of generateErrorBodyAliases(inline.root, inline.codegenOptions)) collectedErrorAliases.add(alias);
|
|
1139
1357
|
if (sdkNeedsJson(inline.root, includeInternal)) needsJson = true;
|
|
1140
1358
|
if (sdkNeedsBigIntReplacer(inline.root, includeInternal)) needsBigIntReplacer = true;
|
|
1141
1359
|
if (sdkNeedsBigIntReviver(inline.root, includeInternal)) needsBigIntReviver = true;
|
|
1142
1360
|
if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
|
|
1361
|
+
if (sdkNeedsReadContentType(inline.root, includeInternal)) needsReadContentType = true;
|
|
1143
1362
|
|
|
1144
1363
|
// Resolve each file's type refs against THIS file's modelOutPaths, but
|
|
1145
1364
|
// produce import paths relative to the area client's outPath (not the
|
|
@@ -1179,6 +1398,7 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1179
1398
|
if (needsBigIntReplacer) valueImports.push('bigIntReplacer');
|
|
1180
1399
|
if (needsBigIntReviver) valueImports.push('parseJson');
|
|
1181
1400
|
if (needsQueryString) valueImports.push('buildQueryString');
|
|
1401
|
+
if (needsReadContentType) valueImports.push('readContentType');
|
|
1182
1402
|
if (valueImports.length > 0) {
|
|
1183
1403
|
lines.push(`import { ${valueImports.join(', ')} } from '${sdkOptionsRel}';`);
|
|
1184
1404
|
}
|
|
@@ -1201,6 +1421,11 @@ export function generateAreaClient(input: AreaClientInput): string {
|
|
|
1201
1421
|
}
|
|
1202
1422
|
lines.push('');
|
|
1203
1423
|
|
|
1424
|
+
if (collectedErrorAliases.size > 0) {
|
|
1425
|
+
lines.push(...collectedErrorAliases);
|
|
1426
|
+
lines.push('');
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1204
1429
|
// ── <Area>Client class ──────────────────────────────────────────────────
|
|
1205
1430
|
lines.push(`export class ${className} {`);
|
|
1206
1431
|
for (const sc of subareaClients) {
|
package/src/index.ts
CHANGED
|
@@ -262,7 +262,7 @@ function collectOpRootRefs(root: OpRootNode, modelMap: Map<string, ModelNode>):
|
|
|
262
262
|
for (const body of op.request.bodies) seeds.push(body.bodyType);
|
|
263
263
|
}
|
|
264
264
|
for (const resp of op.responses) {
|
|
265
|
-
|
|
265
|
+
for (const body of resp.bodies) seeds.push(body.bodyType);
|
|
266
266
|
if (resp.headers) {
|
|
267
267
|
for (const h of resp.headers) seeds.push(h.type);
|
|
268
268
|
}
|