@contractkit/plugin-typescript 0.34.1 → 0.36.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 +5 -5
- package/.turbo/turbo-test$colon$ci.log +25 -18
- package/CHANGELOG.md +56 -0
- package/README.md +4 -3
- package/dist/codegen-mcp.d.ts +8 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +13 -5
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +370 -91
- package/dist/index.js.map +1 -1
- package/dist/server-framework-fastify.d.ts +8 -0
- package/dist/server-framework-fastify.d.ts.map +1 -0
- package/dist/server-framework-koa.d.ts +7 -0
- package/dist/server-framework-koa.d.ts.map +1 -0
- package/dist/server-framework.d.ts +101 -0
- package/dist/server-framework.d.ts.map +1 -0
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/src/codegen-mcp.ts +11 -25
- package/src/codegen-operation.ts +146 -77
- package/src/index.ts +37 -5
- package/src/server-framework-fastify.ts +128 -0
- package/src/server-framework-koa.ts +114 -0
- package/src/server-framework.ts +129 -0
- package/tests/codegen-mcp.test.ts +12 -0
- package/tests/codegen-operation-framework.test.ts +184 -0
- package/tests/codegen-operation.test.ts +74 -0
- package/tests/codegen-server.test.ts +56 -0
- package/tests/server-framework-fastify.test.ts +148 -0
- package/tests/server-framework-koa.test.ts +115 -0
- package/tests/server-framework.test.ts +25 -0
package/src/codegen-operation.ts
CHANGED
|
@@ -21,10 +21,47 @@ import {
|
|
|
21
21
|
import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted, sourceLink } from './ts-render.js';
|
|
22
22
|
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
23
23
|
import { basename, dirname, relative } from 'path';
|
|
24
|
+
import type { ServerFramework } from './server-framework.js';
|
|
25
|
+
import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
26
|
+
|
|
27
|
+
/** Which request-side object a validation block reads from. Names the variable the block declares. */
|
|
28
|
+
export type ParamKind = 'params' | 'query' | 'headers';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Identifiers the generated handler body binds for itself. A path parameter destructured under one
|
|
32
|
+
* of these names would redeclare it, so those bindings are renamed the same way a collision with a
|
|
33
|
+
* handler parameter is.
|
|
34
|
+
*/
|
|
35
|
+
const GENERATOR_HANDLER_LOCALS = ['service', 'result', 'body', 'multipartBody', 'params', 'query', 'headers'] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Local identifier for each inline path parameter, keyed by its declared name.
|
|
39
|
+
*
|
|
40
|
+
* The declared name is what the framework keys its params object by, so it stays the schema key and
|
|
41
|
+
* the route placeholder; only the local binding moves, and a `_` is appended until it collides with
|
|
42
|
+
* neither a reserved identifier nor another parameter's binding. Path parameters are spread
|
|
43
|
+
* positionally into the service call, so a rename is invisible to the service.
|
|
44
|
+
*/
|
|
45
|
+
function bindPathParams(nodes: readonly { name: string }[], handlerLocals: readonly string[]): Map<string, string> {
|
|
46
|
+
const reserved = new Set<string>([...handlerLocals, ...GENERATOR_HANDLER_LOCALS]);
|
|
47
|
+
const taken = new Set<string>();
|
|
48
|
+
const bindings = new Map<string, string>();
|
|
49
|
+
for (const node of nodes) {
|
|
50
|
+
let local = toIdentifier(node.name);
|
|
51
|
+
while (reserved.has(local) || taken.has(local)) local += '_';
|
|
52
|
+
taken.add(local);
|
|
53
|
+
bindings.set(node.name, local);
|
|
54
|
+
}
|
|
55
|
+
return bindings;
|
|
56
|
+
}
|
|
24
57
|
|
|
25
58
|
// ─── Content-type helpers ──────────────────────────────────────────────────
|
|
26
59
|
|
|
27
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Map a request MIME type to the ServerKit parser token used in middleware. The tokens are the keys
|
|
62
|
+
* of the parser map in `@maroonedsoftware/servercore`, so they are the same whichever HTTP framework
|
|
63
|
+
* the router targets.
|
|
64
|
+
*/
|
|
28
65
|
function bodyParserToken(contentType: string): string {
|
|
29
66
|
switch (classifyContentType(contentType)) {
|
|
30
67
|
case 'urlencoded':
|
|
@@ -34,9 +71,8 @@ function bodyParserToken(contentType: string): string {
|
|
|
34
71
|
case 'text':
|
|
35
72
|
return 'text';
|
|
36
73
|
case 'binary':
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// multipart/form-data.
|
|
74
|
+
// There is no native binary token; fall back to text so the body is still readable as a
|
|
75
|
+
// string. Services handling binary uploads should switch to multipart/form-data.
|
|
40
76
|
return 'text';
|
|
41
77
|
default:
|
|
42
78
|
return 'json';
|
|
@@ -125,7 +161,7 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
|
|
|
125
161
|
|
|
126
162
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
127
163
|
|
|
128
|
-
/** Options controlling how {@link generateOp} renders a
|
|
164
|
+
/** Options controlling how {@link generateOp} renders a server router module. */
|
|
129
165
|
export interface OpCodegenOptions {
|
|
130
166
|
servicePathTemplate?: string;
|
|
131
167
|
typeImportPathTemplate?: string;
|
|
@@ -143,8 +179,8 @@ export interface OpCodegenOptions {
|
|
|
143
179
|
*/
|
|
144
180
|
includeInternal?: boolean;
|
|
145
181
|
/**
|
|
146
|
-
* Re-parse the service result through its declared response schema before writing
|
|
147
|
-
* and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
|
|
182
|
+
* Re-parse the service result through its declared response schema before writing the response
|
|
183
|
+
* body, and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
|
|
148
184
|
* plain interfaces are types, with no runtime schema value to validate against. Default false.
|
|
149
185
|
*/
|
|
150
186
|
validateResponses?: boolean;
|
|
@@ -154,10 +190,18 @@ export interface OpCodegenOptions {
|
|
|
154
190
|
* post-transform shape, which the schema itself cannot re-parse.
|
|
155
191
|
*/
|
|
156
192
|
modelsWithTransform?: Set<string>;
|
|
193
|
+
/**
|
|
194
|
+
* Which HTTP framework the emitted router targets. Every framework-specific string in the output
|
|
195
|
+
* comes from here. Defaults to Koa, the only framework shipped today.
|
|
196
|
+
*/
|
|
197
|
+
framework?: ServerFramework;
|
|
157
198
|
}
|
|
158
199
|
|
|
200
|
+
/** {@link OpCodegenOptions} after {@link generateOp} has filled in the framework default. */
|
|
201
|
+
type ResolvedOpCodegenOptions = OpCodegenOptions & { framework: ServerFramework };
|
|
202
|
+
|
|
159
203
|
/**
|
|
160
|
-
* Generate a
|
|
204
|
+
* Generate a server router module for every operation in `root`, including the imports, type
|
|
161
205
|
* aliases, and handler list.
|
|
162
206
|
*
|
|
163
207
|
* Imports are derived from the generated body — each candidate symbol is emitted only if it
|
|
@@ -166,6 +210,10 @@ export interface OpCodegenOptions {
|
|
|
166
210
|
* trips `noUnusedLocals` and lint downstream.
|
|
167
211
|
*/
|
|
168
212
|
export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
|
|
213
|
+
// Resolved once here rather than defaulted at each use, so every helper below reads a framework
|
|
214
|
+
// that is definitely present and no branch can quietly fall back to a different one.
|
|
215
|
+
const resolved: ResolvedOpCodegenOptions = { ...options, framework: options.framework ?? KOA_SERVER_FRAMEWORK };
|
|
216
|
+
const framework = resolved.framework;
|
|
169
217
|
// Collect all referenced types across all routes
|
|
170
218
|
const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
|
|
171
219
|
const services = collectServices(root);
|
|
@@ -177,14 +225,14 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
177
225
|
lines.push('/**');
|
|
178
226
|
lines.push(` * generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`);
|
|
179
227
|
lines.push('*/');
|
|
180
|
-
lines.push(
|
|
228
|
+
lines.push(framework.routerDeclaration(routerName));
|
|
181
229
|
lines.push('');
|
|
182
230
|
|
|
183
231
|
const includeInternal = options.includeInternal ?? true;
|
|
184
232
|
for (const route of root.routes) {
|
|
185
233
|
for (const op of route.operations) {
|
|
186
234
|
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
187
|
-
lines.push(...generateHandler(route, op, root,
|
|
235
|
+
lines.push(...generateHandler(route, op, root, resolved));
|
|
188
236
|
lines.push('');
|
|
189
237
|
}
|
|
190
238
|
}
|
|
@@ -231,10 +279,7 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
231
279
|
|
|
232
280
|
const body: string[] = [];
|
|
233
281
|
|
|
234
|
-
|
|
235
|
-
if (koaImports.length > 0) {
|
|
236
|
-
body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
|
|
237
|
-
}
|
|
282
|
+
body.push(...framework.imports(uses));
|
|
238
283
|
|
|
239
284
|
// Services and model names come from the AST, which over-approximates two ways: a model with an
|
|
240
285
|
// Input/Output variant contributes its base name even when only the variant is ever annotated,
|
|
@@ -280,11 +325,12 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
280
325
|
|
|
281
326
|
// ─── Handler generation ────────────────────────────────────────────────────
|
|
282
327
|
|
|
283
|
-
function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options:
|
|
328
|
+
function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options: ResolvedOpCodegenOptions): string[] {
|
|
284
329
|
const lines: string[] = [];
|
|
285
330
|
const file = root.file;
|
|
286
331
|
const outPath = options.outPath;
|
|
287
332
|
const modelsWithInput = options.modelsWithInput;
|
|
333
|
+
const framework = options.framework;
|
|
288
334
|
|
|
289
335
|
lines.push('/**');
|
|
290
336
|
|
|
@@ -310,10 +356,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
310
356
|
lines.push('*/');
|
|
311
357
|
|
|
312
358
|
const method = op.method;
|
|
313
|
-
//
|
|
314
|
-
// a path placeholder's name never reaches the wire —
|
|
315
|
-
// is free, and it is what lets
|
|
316
|
-
const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) =>
|
|
359
|
+
// The framework's placeholder syntax, from `{name}`, mapped to a valid identifier. Unlike a query
|
|
360
|
+
// parameter or a header, a path placeholder's name never reaches the wire — the framework matches
|
|
361
|
+
// by position — so renaming it is free, and it is what lets the params object be destructured.
|
|
362
|
+
const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) => framework.pathParam(toIdentifier(name)));
|
|
317
363
|
const bodies = op.request?.bodies ?? [];
|
|
318
364
|
const hasBody = bodies.length > 0;
|
|
319
365
|
const isSingleMultipart = bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data';
|
|
@@ -328,39 +374,40 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
328
374
|
: policy === false
|
|
329
375
|
? '{ policy: false }'
|
|
330
376
|
: `{ policy: '${policy}' }`;
|
|
331
|
-
middlewares.push(
|
|
377
|
+
middlewares.push(framework.middleware.policy(args));
|
|
332
378
|
}
|
|
333
379
|
if (hasBody) {
|
|
334
380
|
const parserTokens = Array.from(new Set(bodies.map(b => bodyParserToken(b.contentType))));
|
|
335
381
|
const tokensExpr = parserTokens.map(t => `'${t}'`).join(', ');
|
|
336
|
-
middlewares.push(
|
|
382
|
+
middlewares.push(framework.middleware.bodyParser(tokensExpr));
|
|
337
383
|
}
|
|
338
384
|
if (op.signature) {
|
|
339
385
|
const sigArgs = op.signaturePolicy
|
|
340
386
|
? `'${escapeSingleQuoted(op.signature)}', { policy: '${escapeSingleQuoted(op.signaturePolicy)}' }`
|
|
341
387
|
: `'${escapeSingleQuoted(op.signature)}'`;
|
|
342
|
-
middlewares.push(
|
|
388
|
+
middlewares.push(framework.middleware.signature(sigArgs));
|
|
343
389
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async ctx => {`);
|
|
390
|
+
lines.push(framework.routeOpen(deriveRouterName(file), method, path, middlewares));
|
|
347
391
|
|
|
348
392
|
// Params / query / headers validation (request-side — use Input variants)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
393
|
+
const pathBindings =
|
|
394
|
+
route.params?.kind === 'params' ? bindPathParams(route.params.nodes, framework.handlerLocals) : undefined;
|
|
395
|
+
|
|
396
|
+
lines.push(...generateParamValidation(route.params, 'params', framework.request.params, route.paramsMode ?? 'strict', '', modelsWithInput, pathBindings));
|
|
397
|
+
lines.push(...generateParamValidation(op.query, 'query', framework.request.query, op.queryMode ?? 'strict', '', modelsWithInput));
|
|
398
|
+
lines.push(...generateParamValidation(op.headers, 'headers', framework.request.headers, op.headersMode ?? 'strip', '', modelsWithInput));
|
|
352
399
|
|
|
353
400
|
// Body validation (request-side — use Input variants)
|
|
354
401
|
if (hasBody && op.request) {
|
|
355
402
|
if (isSingleMultipart) {
|
|
356
|
-
lines.push(` const multipartBody =
|
|
403
|
+
lines.push(` const multipartBody = ${framework.request.parsedBody} as MultipartBody;`);
|
|
357
404
|
lines.push('');
|
|
358
405
|
} else if (bodies.length === 1) {
|
|
359
|
-
lines.push(` const body = await parseAndValidate(
|
|
406
|
+
lines.push(` const body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
|
|
360
407
|
lines.push('');
|
|
361
408
|
} else if (bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType))) {
|
|
362
409
|
// All declared MIMEs share the same body shape — single validation suffices
|
|
363
|
-
lines.push(` const body = await parseAndValidate(
|
|
410
|
+
lines.push(` const body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
|
|
364
411
|
lines.push('');
|
|
365
412
|
} else {
|
|
366
413
|
// Different body types per MIME — dispatch on Content-Type
|
|
@@ -370,13 +417,13 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
370
417
|
)
|
|
371
418
|
.join(' | ');
|
|
372
419
|
lines.push(` let body!: ${annotation};`);
|
|
373
|
-
lines.push(` switch (
|
|
420
|
+
lines.push(` switch (${framework.request.contentType}) {`);
|
|
374
421
|
for (const b of bodies) {
|
|
375
422
|
lines.push(` case '${b.contentType}':`);
|
|
376
423
|
if (b.contentType === 'multipart/form-data') {
|
|
377
|
-
lines.push(` body =
|
|
424
|
+
lines.push(` body = ${framework.request.parsedBody} as MultipartBody;`);
|
|
378
425
|
} else {
|
|
379
|
-
lines.push(` body = await parseAndValidate(
|
|
426
|
+
lines.push(` body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(b.bodyType, modelsWithInput)});`);
|
|
380
427
|
}
|
|
381
428
|
lines.push(` break;`);
|
|
382
429
|
}
|
|
@@ -389,7 +436,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
389
436
|
// responsible for producing; the rest are documentation, or the thrown-error path.
|
|
390
437
|
const emitted = emittedResponses(op);
|
|
391
438
|
const serviceParts = inferService(op, route, file);
|
|
392
|
-
const call = `await service.${serviceParts.methodName}(${buildArgs(route, op)})`;
|
|
439
|
+
const call = `await service.${serviceParts.methodName}(${buildArgs(route, op, pathBindings)})`;
|
|
393
440
|
|
|
394
441
|
if (emitted.length > 1) {
|
|
395
442
|
lines.push(...generateMultiStatusResult(emitted, serviceParts.className, call, options));
|
|
@@ -397,25 +444,26 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
397
444
|
lines.push(...generateSingleStatusResult(emitted[0], op, serviceParts.className, call, options));
|
|
398
445
|
}
|
|
399
446
|
|
|
400
|
-
lines.push(
|
|
447
|
+
lines.push(...framework.routeClose());
|
|
401
448
|
|
|
402
449
|
return lines;
|
|
403
450
|
}
|
|
404
451
|
|
|
405
452
|
/**
|
|
406
453
|
* The service produces exactly one status (or none): the result is the body itself, or
|
|
407
|
-
* `{ body, headers }` when the status declares headers, and
|
|
454
|
+
* `{ body, headers }` when the status declares headers, and the status code is a constant.
|
|
408
455
|
*
|
|
409
456
|
* A status declaring several mimes also gains a `contentType` the service picks, which is the
|
|
410
|
-
* only thing here that can turn
|
|
457
|
+
* only thing here that can turn the response content type from a literal into an expression.
|
|
411
458
|
*/
|
|
412
459
|
function generateSingleStatusResult(
|
|
413
460
|
resp: OpResponseNode | undefined,
|
|
414
461
|
op: OpOperationNode,
|
|
415
462
|
className: string,
|
|
416
463
|
call: string,
|
|
417
|
-
options:
|
|
464
|
+
options: ResolvedOpCodegenOptions,
|
|
418
465
|
): string[] {
|
|
466
|
+
const framework = options.framework;
|
|
419
467
|
const lines: string[] = [];
|
|
420
468
|
const bodies = resp ? resp.bodies : [];
|
|
421
469
|
const respHeaders = resp?.headers ?? [];
|
|
@@ -428,7 +476,7 @@ function generateSingleStatusResult(
|
|
|
428
476
|
const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
|
|
429
477
|
if (prelude) lines.push(` ${prelude}`);
|
|
430
478
|
bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? 'resultType' : undefined);
|
|
431
|
-
lines.push(` const service =
|
|
479
|
+
lines.push(` const service = ${framework.resolveService(className)};`);
|
|
432
480
|
if (hasRespHeaders) {
|
|
433
481
|
lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
|
|
434
482
|
} else {
|
|
@@ -439,10 +487,10 @@ function generateSingleStatusResult(
|
|
|
439
487
|
const { members, preludes } = rendered;
|
|
440
488
|
bodySchema = rendered.bodySchema;
|
|
441
489
|
for (const prelude of preludes) lines.push(` ${prelude}`);
|
|
442
|
-
lines.push(` const service =
|
|
490
|
+
lines.push(` const service = ${framework.resolveService(className)};`);
|
|
443
491
|
lines.push(` const result: ${members.join(' | ')} = ${call};`);
|
|
444
492
|
} else {
|
|
445
|
-
lines.push(` const service =
|
|
493
|
+
lines.push(` const service = ${framework.resolveService(className)};`);
|
|
446
494
|
if (hasRespHeaders) {
|
|
447
495
|
lines.push(` const result: { headers: ${headersAnnotation} } = ${call};`);
|
|
448
496
|
} else {
|
|
@@ -456,15 +504,18 @@ function generateSingleStatusResult(
|
|
|
456
504
|
// also what aligns the three generators: `observableResponses` excludes a bare `400:` too, so
|
|
457
505
|
// the SDK already types such a method `Promise<void>` and `thrownResponses` puts the 400 in
|
|
458
506
|
// `@throws`. A bodyless 204 success is exactly what `Promise<void>` means.
|
|
459
|
-
lines.push(`
|
|
460
|
-
lines.push(...headerSetLines(respHeaders, ' '));
|
|
507
|
+
lines.push(` ${framework.response.status(String(resp?.statusCode ?? 204))}`);
|
|
508
|
+
lines.push(...headerSetLines(respHeaders, ' ', framework));
|
|
461
509
|
|
|
462
510
|
if (bodies.length === 1) {
|
|
463
|
-
lines.push(`
|
|
464
|
-
lines.push(
|
|
511
|
+
lines.push(` ${framework.response.type(`'${bodies[0]!.contentType}'`)}`);
|
|
512
|
+
lines.push(...indent(framework.response.send(responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)), ' '));
|
|
465
513
|
} else if (bodies.length > 1) {
|
|
466
|
-
lines.push(`
|
|
467
|
-
lines.push(
|
|
514
|
+
lines.push(` ${framework.response.type('result.contentType')}`);
|
|
515
|
+
lines.push(...indent(framework.response.send(responseBodyExpr('result.body', bodySchema)), ' '));
|
|
516
|
+
} else {
|
|
517
|
+
// Nothing to write, but a framework that ends a response by returning still needs a statement.
|
|
518
|
+
lines.push(...indent(framework.response.send(undefined), ' '));
|
|
468
519
|
}
|
|
469
520
|
|
|
470
521
|
return lines;
|
|
@@ -475,7 +526,8 @@ function generateSingleStatusResult(
|
|
|
475
526
|
* `status`, and the handler switches on it so each status writes only its own headers, mime
|
|
476
527
|
* and body.
|
|
477
528
|
*/
|
|
478
|
-
function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options:
|
|
529
|
+
function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options: ResolvedOpCodegenOptions): string[] {
|
|
530
|
+
const framework = options.framework;
|
|
479
531
|
const lines: string[] = [];
|
|
480
532
|
const members: string[] = [];
|
|
481
533
|
const preludes: string[] = [];
|
|
@@ -490,21 +542,23 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
|
|
|
490
542
|
}
|
|
491
543
|
|
|
492
544
|
for (const prelude of preludes) lines.push(` ${prelude}`);
|
|
493
|
-
lines.push(` const service =
|
|
545
|
+
lines.push(` const service = ${framework.resolveService(className)};`);
|
|
494
546
|
lines.push(` const result:`);
|
|
495
547
|
for (const member of members) lines.push(` | ${member}`);
|
|
496
548
|
lines.push(` = ${call};`);
|
|
497
549
|
lines.push('');
|
|
498
|
-
lines.push(`
|
|
550
|
+
lines.push(` ${framework.response.status('result.status')}`);
|
|
499
551
|
lines.push(` switch (result.status) {`);
|
|
500
552
|
for (const resp of emitted) {
|
|
501
553
|
lines.push(` case ${resp.statusCode}:`);
|
|
502
|
-
lines.push(...headerSetLines(resp.headers ?? [], ' '));
|
|
554
|
+
lines.push(...headerSetLines(resp.headers ?? [], ' ', framework));
|
|
503
555
|
if (resp.bodies.length > 0) {
|
|
504
|
-
lines.push(`
|
|
505
|
-
lines.push(
|
|
556
|
+
lines.push(` ${framework.response.type('result.contentType')}`);
|
|
557
|
+
lines.push(...indent(framework.response.send(responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))), ' '));
|
|
558
|
+
} else {
|
|
559
|
+
lines.push(...indent(framework.response.send(undefined), ' '));
|
|
506
560
|
}
|
|
507
|
-
lines.push(
|
|
561
|
+
lines.push(...indent(framework.response.caseEnd(), ' '));
|
|
508
562
|
}
|
|
509
563
|
lines.push(` }`);
|
|
510
564
|
|
|
@@ -565,16 +619,20 @@ function renderHeadersAnnotation(headers: OpResponseHeaderNode[], modelsWithOutp
|
|
|
565
619
|
return `{ ${fields.join('; ')} }`;
|
|
566
620
|
}
|
|
567
621
|
|
|
568
|
-
/**
|
|
569
|
-
function headerSetLines(headers: OpResponseHeaderNode[],
|
|
622
|
+
/** Response-header writes for a status's declared headers, guarding the optional ones. */
|
|
623
|
+
function headerSetLines(headers: OpResponseHeaderNode[], pad: string, framework: ServerFramework): string[] {
|
|
570
624
|
return headers.map(h => {
|
|
571
625
|
const accessor = `result.headers[${JSON.stringify(headerNameToProperty(h.name))}]`;
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
: `${indent}ctx.set('${h.name}', String(${accessor}));`;
|
|
626
|
+
const write = framework.response.header(h.name, `String(${accessor})`);
|
|
627
|
+
return h.optional ? `${pad}if (${accessor} !== undefined) ${write}` : `${pad}${write}`;
|
|
575
628
|
});
|
|
576
629
|
}
|
|
577
630
|
|
|
631
|
+
/** Prefix each of a framework's statements with the handler indentation the caller is writing at. */
|
|
632
|
+
function indent(lines: string[], pad: string): string[] {
|
|
633
|
+
return lines.map(line => `${pad}${line}`);
|
|
634
|
+
}
|
|
635
|
+
|
|
578
636
|
// ─── Inference helpers ─────────────────────────────────────────────────────
|
|
579
637
|
|
|
580
638
|
/**
|
|
@@ -627,12 +685,14 @@ function inferMethodName(method: string, path: string): string {
|
|
|
627
685
|
*
|
|
628
686
|
* @returns The rendered argument list, or an empty string when the method takes no arguments.
|
|
629
687
|
*/
|
|
630
|
-
export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
|
|
688
|
+
export function buildArgs(route: OpRouteNode, op: OpOperationNode, bindings?: Map<string, string>): string {
|
|
631
689
|
const args: string[] = [];
|
|
632
690
|
// Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
|
|
633
691
|
if (route.params) {
|
|
634
692
|
if (route.params.kind === 'params') {
|
|
635
|
-
|
|
693
|
+
// `bindings` carries any rename the handler needed; the MCP generator passes none, since
|
|
694
|
+
// its handlers bind different locals than a router's.
|
|
695
|
+
args.push(...route.params.nodes.map(p => bindings?.get(p.name) ?? toIdentifier(p.name)));
|
|
636
696
|
} else {
|
|
637
697
|
args.push('params');
|
|
638
698
|
}
|
|
@@ -778,7 +838,7 @@ function responseBodySchema(bodyType: ContractTypeNode, options: OpCodegenOption
|
|
|
778
838
|
}
|
|
779
839
|
|
|
780
840
|
/**
|
|
781
|
-
* The
|
|
841
|
+
* The right-hand side of a response body write: the raw result expression, or a
|
|
782
842
|
* `parseAndValidate` of it. The `500` is deliberate — a service returning a shape its own contract
|
|
783
843
|
* rejects is a server fault, not a client one, and `@maroonedsoftware/zod` routes the field-level
|
|
784
844
|
* detail to `internalDetails` (log-only) rather than the response body at 5xx.
|
|
@@ -789,37 +849,46 @@ function responseBodyExpr(value: string, schema: string | undefined): string {
|
|
|
789
849
|
|
|
790
850
|
function generateParamValidation(
|
|
791
851
|
source: ParamSource | undefined,
|
|
792
|
-
|
|
793
|
-
|
|
852
|
+
kind: ParamKind,
|
|
853
|
+
sourceExpr: string,
|
|
794
854
|
mode: ObjectMode,
|
|
795
855
|
suffix = '',
|
|
796
856
|
modelsWithInput?: Set<string>,
|
|
857
|
+
bindings?: Map<string, string>,
|
|
797
858
|
): string[] {
|
|
798
859
|
if (!source) return [];
|
|
799
860
|
const lines: string[] = [];
|
|
800
|
-
const isQuery =
|
|
861
|
+
const isQuery = kind === 'query';
|
|
862
|
+
// Path params are destructured and spread into the service call; query and headers pass as
|
|
863
|
+
// whole objects. The variable the block declares is named after the kind either way.
|
|
864
|
+
const isPathParams = kind === 'params';
|
|
801
865
|
if (source.kind === 'ref') {
|
|
802
866
|
// Type reference — apply mode as a method call on the schema
|
|
803
867
|
const typeName = modelsWithInput?.has(source.name) ? `${source.name}Input` : source.name;
|
|
804
|
-
lines.push(` const ${
|
|
868
|
+
lines.push(` const ${kind} = await parseAndValidate(${sourceExpr}, ${typeName}.${mode}());`);
|
|
805
869
|
lines.push('');
|
|
806
870
|
} else if (source.kind === 'params') {
|
|
807
871
|
// Inline param declarations — wrap with the appropriate z.*Object constructor
|
|
808
872
|
if (source.nodes.length > 0) {
|
|
809
|
-
// Destructure only for params (spread individually in service call);
|
|
810
|
-
// query/headers are passed as whole objects.
|
|
811
|
-
// Path params are destructured and spread into the service call; query and headers pass
|
|
812
|
-
// as whole objects.
|
|
813
|
-
const isPathParams = ctxExpr === 'ctx.params';
|
|
814
873
|
const bind = (name: string) => (isPathParams ? toIdentifier(name) : name);
|
|
815
|
-
|
|
874
|
+
// A path param whose binding was renamed is destructured under an alias, so the key the
|
|
875
|
+
// framework supplies stays the declared one while the local is collision-free.
|
|
876
|
+
const lhs = isPathParams
|
|
877
|
+
? `{ ${source.nodes
|
|
878
|
+
.map(p => {
|
|
879
|
+
const wire = bind(p.name);
|
|
880
|
+
const local = bindings?.get(p.name) ?? wire;
|
|
881
|
+
return wire === local ? wire : `${wire}: ${local}`;
|
|
882
|
+
})
|
|
883
|
+
.join(', ')} }`
|
|
884
|
+
: kind;
|
|
816
885
|
lines.push(` const ${lhs} = await parseAndValidate(`);
|
|
817
|
-
lines.push(` ${
|
|
886
|
+
lines.push(` ${sourceExpr},`);
|
|
818
887
|
lines.push(` ${modeToWrapper(mode)}({`);
|
|
819
888
|
for (const param of source.nodes) {
|
|
820
889
|
// For path params the key must match the name in the route pattern above, which
|
|
821
|
-
// is what
|
|
822
|
-
// quoted when that is not an identifier — those the client actually sends.
|
|
890
|
+
// is what the framework keys its params object by. For query and headers it is the
|
|
891
|
+
// wire name, quoted when that is not an identifier — those the client actually sends.
|
|
823
892
|
const bound = bind(param.name);
|
|
824
893
|
const key = isValidIdentifier(bound) ? bound : `'${bound}'`;
|
|
825
894
|
// Delegating to renderQueryType rather than hand-rolling the array preprocess here:
|
|
@@ -837,7 +906,7 @@ function generateParamValidation(
|
|
|
837
906
|
// ContractTypeNode — use query-aware rendering for query params (coerces single string → array),
|
|
838
907
|
// otherwise use Input variant rendering; apply mode as a method call
|
|
839
908
|
const schema = isQuery ? renderQueryType(source.node, modelsWithInput) : renderInputType(source.node, modelsWithInput);
|
|
840
|
-
lines.push(` const ${
|
|
909
|
+
lines.push(` const ${kind} = await parseAndValidate(${sourceExpr}, (${schema}).${mode}());`);
|
|
841
910
|
lines.push('');
|
|
842
911
|
}
|
|
843
912
|
return lines;
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,17 @@ import {
|
|
|
45
45
|
} from './codegen-sdk.js';
|
|
46
46
|
import { generatePlainTypes } from './codegen-plain-types.js';
|
|
47
47
|
import { DEFAULT_REVIVABLE_SCALARS } from './codegen-revive.js';
|
|
48
|
+
import { resolveServerFramework, SERVER_FRAMEWORK_NAMES, type ServerFrameworkName } from './server-framework.js';
|
|
49
|
+
export {
|
|
50
|
+
SERVER_FRAMEWORK_NAMES,
|
|
51
|
+
DEFAULT_SERVER_FRAMEWORK_NAME,
|
|
52
|
+
resolveServerFramework,
|
|
53
|
+
SERVER_FRAMEWORKS,
|
|
54
|
+
type ServerFramework,
|
|
55
|
+
type ServerFrameworkName,
|
|
56
|
+
} from './server-framework.js';
|
|
57
|
+
export { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
58
|
+
export { FASTIFY_SERVER_FRAMEWORK } from './server-framework-fastify.js';
|
|
48
59
|
|
|
49
60
|
/** Taint set for the SDK's bigint response reviver. */
|
|
50
61
|
const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
|
|
@@ -65,14 +76,19 @@ import {
|
|
|
65
76
|
|
|
66
77
|
// ─── Sub-config interfaces ─────────────────────────────────────────────────
|
|
67
78
|
|
|
68
|
-
/**
|
|
79
|
+
/** Server output: routers, and the type or Zod schema files they import. */
|
|
69
80
|
export interface ServerConfig {
|
|
70
81
|
/** Directory (relative to rootDir) where server files are written. Default: rootDir. */
|
|
71
82
|
baseDir?: string;
|
|
83
|
+
/**
|
|
84
|
+
* HTTP framework the generated routers target. Also selects the flavour of the optional
|
|
85
|
+
* `mcp.router.ts` the `mcp` sub-config emits. Supported: `'koa'`. Default `'koa'`.
|
|
86
|
+
*/
|
|
87
|
+
framework?: ServerFrameworkName;
|
|
72
88
|
/** When true, `output.types` emits Zod schema files (via `generateContract`). When false/omitted, emits plain TypeScript. */
|
|
73
89
|
zod?: boolean;
|
|
74
90
|
output?: {
|
|
75
|
-
/** Path template for
|
|
91
|
+
/** Path template for router files. Supports {filename}, {dir}, {area}. */
|
|
76
92
|
routes?: string;
|
|
77
93
|
/** Path template for type/schema files. Supports {filename}, {dir}, {area}. */
|
|
78
94
|
types?: string;
|
|
@@ -151,7 +167,7 @@ export interface McpConfig {
|
|
|
151
167
|
*/
|
|
152
168
|
types?: string;
|
|
153
169
|
};
|
|
154
|
-
/** Emit the `mcp.router.ts` route boilerplate. Default true. */
|
|
170
|
+
/** Emit the `mcp.router.ts` route boilerplate. Its framework follows `server.framework`. Default true. */
|
|
155
171
|
emitRouter?: boolean;
|
|
156
172
|
/** Mount path used in the emitted router. Default `/mcp`. */
|
|
157
173
|
path?: string;
|
|
@@ -207,6 +223,14 @@ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir:
|
|
|
207
223
|
|
|
208
224
|
/** Reject config combinations that would generate code that cannot compile or cannot run. */
|
|
209
225
|
function assertValidConfig(config: TypescriptPluginConfig): void {
|
|
226
|
+
// Runtime check, not just a type: config arrives as JSON, so `ServerFrameworkName` constrains
|
|
227
|
+
// programmatic callers only. Checked before the rest so a typo'd framework is the error reported.
|
|
228
|
+
const framework = config.server?.framework;
|
|
229
|
+
if (framework !== undefined && !(SERVER_FRAMEWORK_NAMES as readonly string[]).includes(framework)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`plugin-typescript: server.framework '${String(framework)}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
210
234
|
if (config.server?.validateResponses && !config.server.zod) {
|
|
211
235
|
throw new Error(
|
|
212
236
|
'plugin-typescript: server.validateResponses requires server.zod: true — without it output.types emits plain TypeScript interfaces, which are types with no runtime schema value for the router to validate against.',
|
|
@@ -367,6 +391,7 @@ function collectServerOutput(
|
|
|
367
391
|
units: IncrementalUnit[],
|
|
368
392
|
): void {
|
|
369
393
|
const serverBase = resolve(rootDir, config.baseDir ?? '.');
|
|
394
|
+
const framework = resolveServerFramework(config.framework);
|
|
370
395
|
const modelsWithInput = inputs.modelsWithInput as Set<string>;
|
|
371
396
|
const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
|
|
372
397
|
// Not `modelsWithOutput`: that set seeds only from `format(output=...)`, because only that case
|
|
@@ -418,7 +443,7 @@ function collectServerOutput(
|
|
|
418
443
|
currentOutPath: typeOutPath,
|
|
419
444
|
modelsWithInput,
|
|
420
445
|
modelsWithOutput,
|
|
421
|
-
// These types are consumed by
|
|
446
|
+
// These types are consumed by server handlers, so `binary` is a Buffer, not a Blob.
|
|
422
447
|
target: 'server' as const,
|
|
423
448
|
};
|
|
424
449
|
const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
|
|
@@ -446,6 +471,9 @@ function collectServerOutput(
|
|
|
446
471
|
// this router's output with no change to `root` or the config.
|
|
447
472
|
modelsWithTransform: sliceModelSet(refs, new Set(), modelsWithTransform),
|
|
448
473
|
validateResponses: config.validateResponses ?? false,
|
|
474
|
+
// Covered by `sub` already, which is the whole sub-config; explicit for the same reason
|
|
475
|
+
// `validateResponses` is — the inputs that change a router's text read at a glance.
|
|
476
|
+
framework: framework.name,
|
|
449
477
|
sub: subConfigKey,
|
|
450
478
|
});
|
|
451
479
|
units.push({
|
|
@@ -463,6 +491,7 @@ function collectServerOutput(
|
|
|
463
491
|
modelsWithTransform,
|
|
464
492
|
includeInternal: config.includeInternal,
|
|
465
493
|
validateResponses: config.validateResponses,
|
|
494
|
+
framework,
|
|
466
495
|
}),
|
|
467
496
|
},
|
|
468
497
|
],
|
|
@@ -1107,7 +1136,10 @@ function collectMcpOutput(
|
|
|
1107
1136
|
// ── Router (global, optional) ──
|
|
1108
1137
|
if (config.emitRouter !== false) {
|
|
1109
1138
|
const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
|
|
1110
|
-
|
|
1139
|
+
// The mount is server-side wiring, so it follows the server sub-config's framework. Koa when
|
|
1140
|
+
// there is no `server` sub-config at all, which is the same default the router generator has.
|
|
1141
|
+
const framework = resolveServerFramework(fullConfig.server?.framework);
|
|
1142
|
+
globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path, framework }) });
|
|
1111
1143
|
}
|
|
1112
1144
|
}
|
|
1113
1145
|
|